From ecfe869c8aa8ec173eb2f7b04044e7d507bdaaa4 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Thu, 8 May 2025 18:55:15 +0300 Subject: [PATCH 01/53] remove empty request_test.go file. (#796) the file contains only two consts that are not used anywhere (same consts are defined in runserver.go Signed-off-by: Nir Rozenbaum --- pkg/epp/handlers/request_test.go | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100644 pkg/epp/handlers/request_test.go diff --git a/pkg/epp/handlers/request_test.go b/pkg/epp/handlers/request_test.go deleted file mode 100644 index f4f2eb136..000000000 --- a/pkg/epp/handlers/request_test.go +++ /dev/null @@ -1,22 +0,0 @@ -/* -Copyright 2025 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 handlers - -const ( - DefaultDestinationEndpointHintMetadataNamespace = "envoy.lb" // default for --destinationEndpointHintMetadataNamespace - DefaultDestinationEndpointHintKey = "x-gateway-destination-endpoint" // default for --destinationEndpointHintKey -) From 2ed990b501a99f6bb6034d45a2917ba8afb7bcd3 Mon Sep 17 00:00:00 2001 From: Cong Liu Date: Thu, 8 May 2025 11:53:14 -0700 Subject: [PATCH 02/53] Clean up filters (#802) --- pkg/epp/scheduling/config.go | 12 +++-- pkg/epp/scheduling/plugins/filter/filter.go | 38 +++++++++++++++ pkg/epp/scheduling/scheduler.go | 52 --------------------- 3 files changed, 45 insertions(+), 57 deletions(-) diff --git a/pkg/epp/scheduling/config.go b/pkg/epp/scheduling/config.go index c02d9f56c..a68fd9b0f 100644 --- a/pkg/epp/scheduling/config.go +++ b/pkg/epp/scheduling/config.go @@ -16,7 +16,11 @@ limitations under the License. package scheduling -import "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" +import ( + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/filter" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/picker" +) // NewSchedulerConfig creates a new SchedulerConfig object with the given plugins. func NewSchedulerConfig(preSchedulePlugins []plugins.PreSchedule, filters []plugins.Filter, scorers map[plugins.Scorer]int, @@ -39,16 +43,14 @@ type SchedulerConfig struct { postSchedulePlugins []plugins.PostSchedule } -var defPlugin = &defaultPlugin{} - // When the scheduler is initialized with NewScheduler function, this config will be used as default. // it's possible to call NewSchedulerWithConfig to pass a different argument. // For build time plugins changes, it's recommended to change the defaultConfig variable in this file. var defaultConfig = &SchedulerConfig{ preSchedulePlugins: []plugins.PreSchedule{}, - filters: []plugins.Filter{defPlugin}, + filters: []plugins.Filter{&filter.SheddableRequestFilter{}, filter.LowLatencyFilter}, scorers: map[plugins.Scorer]int{}, - picker: defPlugin, + picker: &picker.RandomPicker{}, postSchedulePlugins: []plugins.PostSchedule{}, } diff --git a/pkg/epp/scheduling/plugins/filter/filter.go b/pkg/epp/scheduling/plugins/filter/filter.go index 86620aa9f..aa7b03d64 100644 --- a/pkg/epp/scheduling/plugins/filter/filter.go +++ b/pkg/epp/scheduling/plugins/filter/filter.go @@ -276,3 +276,41 @@ func (pp podPredicate) and(another podPredicate) podPredicate { return pp(req, pod) && another(req, pod) } } + +var LowLatencyFilter = &DecisionTreeFilter{ + Current: LowQueueFilter, + NextOnSuccess: &DecisionTreeFilter{ + Current: LoRAAffinityFilter, + NextOnSuccessOrFailure: &DecisionTreeFilter{ + Current: LeastQueueFilter, + NextOnSuccessOrFailure: &DecisionTreeFilter{ + Current: LeastKVCacheFilter, + }, + }, + }, + NextOnFailure: &DecisionTreeFilter{ + Current: LeastQueueFilter, + NextOnSuccessOrFailure: &DecisionTreeFilter{ + Current: LoRAAffinityFilter, + NextOnSuccessOrFailure: &DecisionTreeFilter{ + Current: LeastKVCacheFilter, + }, + }, + }, +} + +type SheddableRequestFilter struct{} + +func (p *SheddableRequestFilter) Name() string { + return "SheddableRequestFilter" +} + +func (p *SheddableRequestFilter) Filter(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { + if ctx.Req.Critical { + // Allow all pods to pass through if the request is critical, even if all pods reach their capacity. + return pods + } + + // Only allow pods that have enough capacity to handle the request. + return HasCapacityFilter.Filter(ctx, pods) +} diff --git a/pkg/epp/scheduling/scheduler.go b/pkg/epp/scheduling/scheduler.go index 9215489fe..37d818e11 100644 --- a/pkg/epp/scheduling/scheduler.go +++ b/pkg/epp/scheduling/scheduler.go @@ -26,47 +26,11 @@ import ( backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/filter" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/picker" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/error" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) -var ( - lowLatencyFilter = &filter.DecisionTreeFilter{ - Current: filter.LowQueueFilter, - NextOnSuccess: &filter.DecisionTreeFilter{ - Current: filter.LoRAAffinityFilter, - NextOnSuccessOrFailure: &filter.DecisionTreeFilter{ - Current: filter.LeastQueueFilter, - NextOnSuccessOrFailure: &filter.DecisionTreeFilter{ - Current: filter.LeastKVCacheFilter, - }, - }, - }, - NextOnFailure: &filter.DecisionTreeFilter{ - Current: filter.LeastQueueFilter, - NextOnSuccessOrFailure: &filter.DecisionTreeFilter{ - Current: filter.LoRAAffinityFilter, - NextOnSuccessOrFailure: &filter.DecisionTreeFilter{ - Current: filter.LeastKVCacheFilter, - }, - }, - }, - } - - sheddableRequestFilter = &filter.DecisionTreeFilter{ - // When there is at least one model server that's not queuing requests, and still has KV - // cache below a certain threshold, we consider this model server has capacity to handle - // a sheddable request without impacting critical requests. - Current: filter.HasCapacityFilter, - NextOnSuccess: lowLatencyFilter, - // If all pods are queuing or running above the KVCache threshold, we drop the sheddable - // request to make room for critical requests. for this, we don't define nextOnFailure. - } -) - func NewScheduler(datastore Datastore) *Scheduler { return NewSchedulerWithConfig(datastore, defaultConfig) } @@ -206,19 +170,3 @@ func (s *Scheduler) runPostSchedulePlugins(ctx *types.SchedulingContext, res *ty metrics.RecordSchedulerPluginProcessingLatency(plugins.PostSchedulePluginType, plugin.Name(), time.Since(before)) } } - -type defaultPlugin struct { - picker.RandomPicker -} - -func (p *defaultPlugin) Name() string { - return "DefaultPlugin" -} - -func (p *defaultPlugin) Filter(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { - if ctx.Req.Critical { - return lowLatencyFilter.Filter(ctx, pods) - } - - return sheddableRequestFilter.Filter(ctx, pods) -} From cb52769a1252066dc11d10461b2f9b1cd120ffbb Mon Sep 17 00:00:00 2001 From: Luke Van Drie Date: Thu, 8 May 2025 12:17:14 -0700 Subject: [PATCH 03/53] Refactor: Improve env utility (#803) Refactored the environment variable utility (pkg/epp/util/env) to enhance code quality, readability, and maintainability. Key changes: - Introduced generic helper functions `parseEnvWithValue` and `getEnvWithParser` to centralize common logic for fetching and parsing environment variables, significantly reducing code duplication. - Standardized logging messages for consistency across all `GetEnv` functions. - Added `GetEnvDuration`. --- pkg/epp/util/env/env.go | 78 ++++++++++++++++++++---------------- pkg/epp/util/env/env_test.go | 75 ++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 34 deletions(-) diff --git a/pkg/epp/util/env/env.go b/pkg/epp/util/env/env.go index 0c6d1c6d4..b4edcad42 100644 --- a/pkg/epp/util/env/env.go +++ b/pkg/epp/util/env/env.go @@ -1,61 +1,71 @@ package env import ( + "fmt" "os" + "reflect" "strconv" + "time" "github.com/go-logr/logr" ) -// getEnvFloat gets a float64 from an environment variable with a default value -func GetEnvFloat(key string, defaultVal float64, logger logr.Logger) float64 { - val, exists := os.LookupEnv(key) - if !exists { - logger.Info("Environment variable not set, using default value", - "key", key, "defaultValue", defaultVal) - return defaultVal - } - - floatVal, err := strconv.ParseFloat(val, 64) +// parseEnvWithValue attempts to parse a string value using the provided +// parser. +// It logs success or failure and returns the parsed value or the default +// value. +// This helper is used when the environment variable is confirmed to exist. +func parseEnvWithValue[T any](key string, valueStr string, defaultVal T, + parser func(string) (T, error), logger logr.Logger) T { + parsedVal, err := parser(valueStr) if err != nil { - logger.Info("Failed to parse environment variable as float, using default value", - "key", key, "value", val, "error", err, "defaultValue", defaultVal) + logger.Info(fmt.Sprintf("Failed to parse environment variable as %s, using default value", reflect.TypeOf(defaultVal)), + "key", key, "rawValue", valueStr, "error", err, + "defaultValue", defaultVal) return defaultVal } logger.Info("Successfully loaded environment variable", - "key", key, "value", floatVal) - return floatVal + "key", key, "value", parsedVal) + return parsedVal } -// getEnvInt gets an int from an environment variable with a default value -func GetEnvInt(key string, defaultVal int, logger logr.Logger) int { - val, exists := os.LookupEnv(key) +// getEnvWithParser retrieves an environment variable. If set, it uses the +// provided parser to convert it. Otherwise, it returns the default value. +// It delegates to parseEnvWithValue for the actual parsing and detailed +// logging once the variable is confirmed to exist. +func getEnvWithParser[T any](key string, defaultVal T, + parser func(string) (T, error), logger logr.Logger) T { + valueStr, exists := os.LookupEnv(key) if !exists { logger.Info("Environment variable not set, using default value", "key", key, "defaultValue", defaultVal) return defaultVal } + return parseEnvWithValue(key, valueStr, defaultVal, parser, logger) +} - intVal, err := strconv.Atoi(val) - if err != nil { - logger.Info("Failed to parse environment variable as int, using default value", - "key", key, "value", val, "error", err, "defaultValue", defaultVal) - return defaultVal - } +// GetEnvFloat gets a float64 from an environment variable with a default +// value. +func GetEnvFloat(key string, defaultVal float64, logger logr.Logger) float64 { + parser := func(s string) (float64, error) { return strconv.ParseFloat(s, 64) } + return getEnvWithParser(key, defaultVal, parser, logger) +} - logger.Info("Successfully loaded environment variable", - "key", key, "value", intVal) - return intVal +// GetEnvInt gets an int from an environment variable with a default value. +func GetEnvInt(key string, defaultVal int, logger logr.Logger) int { + return getEnvWithParser(key, defaultVal, strconv.Atoi, logger) +} + +// GetEnvDuration gets a time.Duration from an environment variable with a +// default value. +func GetEnvDuration(key string, defaultVal time.Duration, logger logr.Logger) time.Duration { + return getEnvWithParser(key, defaultVal, time.ParseDuration, logger) } -// GetEnvString gets a string from an environment variable with a default value +// GetEnvString gets a string from an environment variable with a default +// value. func GetEnvString(key string, defaultVal string, logger logr.Logger) string { - val, exists := os.LookupEnv(key) - if !exists { - logger.Info("Environment variable not set, using default value", - "key", key, "defaultValue", defaultVal) - return defaultVal - } - return val + parser := func(s string) (string, error) { return s, nil } + return getEnvWithParser(key, defaultVal, parser, logger) } diff --git a/pkg/epp/util/env/env_test.go b/pkg/epp/util/env/env_test.go index 105beb280..f83104ca6 100644 --- a/pkg/epp/util/env/env_test.go +++ b/pkg/epp/util/env/env_test.go @@ -3,6 +3,7 @@ package env import ( "os" "testing" + "time" "github.com/go-logr/logr/testr" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" @@ -69,6 +70,80 @@ func TestGetEnvFloat(t *testing.T) { } } +func TestGetEnvDuration(t *testing.T) { + logger := testr.New(t) + + tests := []struct { + name string + key string + value string + defaultVal time.Duration + expected time.Duration + setup func() + teardown func() + }{ + { + name: "env variable exists and is valid", + key: "TEST_DURATION", + value: "1h30m", + defaultVal: 0, + expected: 1*time.Hour + 30*time.Minute, + setup: func() { + os.Setenv("TEST_DURATION", "1h30m") + }, + teardown: func() { + os.Unsetenv("TEST_DURATION") + }, + }, + { + name: "env variable exists but is invalid", + key: "TEST_DURATION", + value: "invalid-duration", + defaultVal: 5 * time.Minute, + expected: 5 * time.Minute, + setup: func() { + os.Setenv("TEST_DURATION", "invalid-duration") + }, + teardown: func() { + os.Unsetenv("TEST_DURATION") + }, + }, + { + name: "env variable does not exist", + key: "TEST_DURATION_MISSING", + defaultVal: 10 * time.Second, + expected: 10 * time.Second, + setup: func() {}, + teardown: func() {}, + }, + { + name: "env variable is empty string", + key: "TEST_DURATION_EMPTY", + value: "", + defaultVal: 1 * time.Millisecond, + expected: 1 * time.Millisecond, + setup: func() { + os.Setenv("TEST_DURATION_EMPTY", "") + }, + teardown: func() { + os.Unsetenv("TEST_DURATION_EMPTY") + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tc.setup() + defer tc.teardown() + + result := GetEnvDuration(tc.key, tc.defaultVal, logger.V(logutil.VERBOSE)) + if result != tc.expected { + t.Errorf("GetEnvDuration(%s, %v) = %v, expected %v", tc.key, tc.defaultVal, result, tc.expected) + } + }) + } +} + func TestGetEnvInt(t *testing.T) { logger := testr.New(t) From d212757ed80583d332bbe4a3ee2c2cd6de76a896 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Thu, 8 May 2025 23:45:14 +0300 Subject: [PATCH 04/53] refactor scheduler filters package (#797) * refactor schdeuler filters package to simplify and improve readability and maintainability Signed-off-by: Nir Rozenbaum * filter refactor finalizing Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum --- pkg/epp/scheduling/config.go | 14 - .../plugins/filter/decision_tree_filter.go | 81 +++++ pkg/epp/scheduling/plugins/filter/filter.go | 316 ------------------ .../scheduling/plugins/filter/filter_test.go | 69 ++-- .../plugins/filter/has_capacity_filter.go | 58 ++++ .../plugins/filter/least_kvcache_filter.go | 68 ++++ .../plugins/filter/least_queue_filter.go | 70 ++++ .../plugins/filter/lora_affinity_filter.go | 90 +++++ .../plugins/filter/low_queue_filter.go | 56 ++++ .../filter/sheddable_request_filter.go | 53 +++ pkg/epp/scheduling/scheduler.go | 41 +++ 11 files changed, 544 insertions(+), 372 deletions(-) create mode 100644 pkg/epp/scheduling/plugins/filter/decision_tree_filter.go delete mode 100644 pkg/epp/scheduling/plugins/filter/filter.go create mode 100644 pkg/epp/scheduling/plugins/filter/has_capacity_filter.go create mode 100644 pkg/epp/scheduling/plugins/filter/least_kvcache_filter.go create mode 100644 pkg/epp/scheduling/plugins/filter/least_queue_filter.go create mode 100644 pkg/epp/scheduling/plugins/filter/lora_affinity_filter.go create mode 100644 pkg/epp/scheduling/plugins/filter/low_queue_filter.go create mode 100644 pkg/epp/scheduling/plugins/filter/sheddable_request_filter.go diff --git a/pkg/epp/scheduling/config.go b/pkg/epp/scheduling/config.go index a68fd9b0f..a4f4c2950 100644 --- a/pkg/epp/scheduling/config.go +++ b/pkg/epp/scheduling/config.go @@ -18,8 +18,6 @@ package scheduling import ( "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/filter" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/picker" ) // NewSchedulerConfig creates a new SchedulerConfig object with the given plugins. @@ -42,15 +40,3 @@ type SchedulerConfig struct { picker plugins.Picker postSchedulePlugins []plugins.PostSchedule } - -// When the scheduler is initialized with NewScheduler function, this config will be used as default. -// it's possible to call NewSchedulerWithConfig to pass a different argument. - -// For build time plugins changes, it's recommended to change the defaultConfig variable in this file. -var defaultConfig = &SchedulerConfig{ - preSchedulePlugins: []plugins.PreSchedule{}, - filters: []plugins.Filter{&filter.SheddableRequestFilter{}, filter.LowLatencyFilter}, - scorers: map[plugins.Scorer]int{}, - picker: &picker.RandomPicker{}, - postSchedulePlugins: []plugins.PostSchedule{}, -} diff --git a/pkg/epp/scheduling/plugins/filter/decision_tree_filter.go b/pkg/epp/scheduling/plugins/filter/decision_tree_filter.go new file mode 100644 index 000000000..399780b6f --- /dev/null +++ b/pkg/epp/scheduling/plugins/filter/decision_tree_filter.go @@ -0,0 +1,81 @@ +/* +Copyright 2025 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 filter + +import ( + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" +) + +// DecisionTreeFilter applies current fitler, and then recursively applies next filters +// depending success or failure of the current filter. +// It can be used to construct a flow chart algorithm. +type DecisionTreeFilter struct { + Current plugins.Filter + // NextOnSuccess filter will be applied after successfully applying the current filter. + // The filtered results will be passed to the next filter. + NextOnSuccess plugins.Filter + // NextOnFailure filter will be applied if current filter results in no pods. + // The original input will be passed to the next filter. + NextOnFailure plugins.Filter + // NextOnSuccessOrFailure is a convenience field to configure the next filter regardless of the + // success or failure of the current filter. + // NOTE: When using NextOnSuccessOrFailure, both nextOnSuccess and nextOnFailure SHOULD be nil. + // However if that's not the case, nextOnSuccess and nextOnFailure will be used, instead of + // NextOnSuccessOrFailure, in the success and failure scenarios, respectively. + NextOnSuccessOrFailure plugins.Filter +} + +// Name returns the name of the filter. +func (f *DecisionTreeFilter) Name() string { + if f == nil { + return "nil" + } + return f.Current.Name() +} + +// Filter filters out pods that doesn't meet the filter criteria. +func (f *DecisionTreeFilter) Filter(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { + loggerTrace := ctx.Logger.V(logutil.TRACE) + filteredPod := f.Current.Filter(ctx, pods) + + next := f.NextOnSuccessOrFailure + if len(filteredPod) > 0 { + if f.NextOnSuccess == nil && f.NextOnSuccessOrFailure == nil { + // No succeeding filters to run, return. + return filteredPod + } + if f.NextOnSuccess != nil { + next = f.NextOnSuccess + } + loggerTrace.Info("Filter succeeded", "filter", f.Name(), "next", next.Name(), "filteredPodCount", len(filteredPod)) + // On success, pass the filtered result to the next filter. + return next.Filter(ctx, filteredPod) + } else { + if f.NextOnFailure == nil && f.NextOnSuccessOrFailure == nil { + // No succeeding filters to run, return. + return filteredPod + } + if f.NextOnFailure != nil { + next = f.NextOnFailure + } + loggerTrace.Info("Filter failed", "filter", f.Name(), "next", next.Name()) + // On failure, pass the initial set of pods to the next filter. + return next.Filter(ctx, pods) + } +} diff --git a/pkg/epp/scheduling/plugins/filter/filter.go b/pkg/epp/scheduling/plugins/filter/filter.go deleted file mode 100644 index aa7b03d64..000000000 --- a/pkg/epp/scheduling/plugins/filter/filter.go +++ /dev/null @@ -1,316 +0,0 @@ -/* -Copyright 2025 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 filter - -import ( - "math" - "math/rand" - "time" - - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/config" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" - logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" -) - -type baseFilter struct { - name string - filter filterFunc -} - -func (f *baseFilter) Name() string { - if f == nil { - return "nil" - } - return f.name -} - -func (f *baseFilter) Filter(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { - loggerTrace := ctx.Logger.V(logutil.TRACE) - loggerTrace.Info("Running a filter", "name", f.Name(), "podCount", len(pods)) - - return f.filter(ctx, pods) -} - -// DecisionTreeFilter applies current filterFunc, and then recursively applies next filters -// depending success or failure of the current filter. -// It can be used to construct a flow chart algorithm. -type DecisionTreeFilter struct { - Current plugins.Filter - // NextOnSuccess filter will be applied after successfully applying the current filter. - // The filtered results will be passed to the next filter. - NextOnSuccess plugins.Filter - // NextOnFailure filter will be applied if current filter fails. - // The original input will be passed to the next filter. - NextOnFailure plugins.Filter - // NextOnSuccessOrFailure is a convenience field to configure the next filter regardless of the - // success or failure of the current filter. - // NOTE: When using NextOnSuccessOrFailure, both nextOnSuccess and nextOnFailure SHOULD be nil. - // However if that's not the case, nextOnSuccess and nextOnFailure will be used, instead of - // NextOnSuccessOrFailure, in the success and failure scenarios, respectively. - NextOnSuccessOrFailure plugins.Filter -} - -func (f *DecisionTreeFilter) Name() string { - if f == nil { - return "nil" - } - return f.Current.Name() -} - -func (f *DecisionTreeFilter) Filter(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { - loggerTrace := ctx.Logger.V(logutil.TRACE) - filtered := f.Current.Filter(ctx, pods) - - next := f.NextOnSuccessOrFailure - if len(filtered) > 0 { - if f.NextOnSuccess == nil && f.NextOnSuccessOrFailure == nil { - // No succeeding filters to run, return. - return filtered - } - if f.NextOnSuccess != nil { - next = f.NextOnSuccess - } - loggerTrace.Info("Filter succeeded", "filter", f.Name(), "next", next.Name(), "filteredPodCount", len(filtered)) - // On success, pass the filtered result to the next filter. - return next.Filter(ctx, filtered) - } else { - if f.NextOnFailure == nil && f.NextOnSuccessOrFailure == nil { - // No succeeding filters to run, return. - return filtered - } - if f.NextOnFailure != nil { - next = f.NextOnFailure - } - loggerTrace.Info("Filter failed", "filter", f.Name(), "next", next.Name()) - // On failure, pass the initial set of pods to the next filter. - return next.Filter(ctx, pods) - } -} - -// filterFunc filters a set of input pods to a subset. -type filterFunc func(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod - -// toFilterFunc is a helper function to convert a per pod filter func to the FilterFunc. -func toFilterFunc(pp podPredicate) filterFunc { - return func(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { - filtered := []types.Pod{} - for _, pod := range pods { - pass := pp(ctx.Req, pod) - if pass { - filtered = append(filtered, pod) - } - } - - return filtered - } -} - -var LeastQueueFilter = &baseFilter{ - name: "least queuing", - filter: leastQueuingFilterFunc, -} - -// leastQueuingFilterFunc finds the max and min queue size of all pods, divides the whole range -// (max-min) by the number of pods, and finds the pods that fall into the first range. -// The intuition is that if there are multiple pods that share similar queue size in the low range, -// we should consider them all instead of the absolute minimum one. This worked better than picking -// the least one as it gives more choices for the next filter, which on aggregate gave better -// results. -// TODO: Compare this strategy with other strategies such as top K. -func leastQueuingFilterFunc(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { - min := math.MaxInt - max := 0 - filtered := []types.Pod{} - - for _, pod := range pods { - if pod.GetMetrics().WaitingQueueSize <= min { - min = pod.GetMetrics().WaitingQueueSize - } - if pod.GetMetrics().WaitingQueueSize >= max { - max = pod.GetMetrics().WaitingQueueSize - } - } - - for _, pod := range pods { - if pod.GetMetrics().WaitingQueueSize >= min && pod.GetMetrics().WaitingQueueSize <= min+(max-min)/len(pods) { - filtered = append(filtered, pod) - } - } - return filtered -} - -var LowQueueFilter = &baseFilter{ - name: "low queueing filter", - filter: toFilterFunc((queueThresholdPredicate(config.Conf.QueueingThresholdLoRA))), -} - -var LeastKVCacheFilter = &baseFilter{ - name: "least KV cache percent", - filter: leastKVCacheFilterFunc, -} - -// leastKVCacheFilterFunc finds the max and min KV cache of all pods, divides the whole range -// (max-min) by the number of pods, and finds the pods that fall into the first range. -// The intuition is that if there are multiple pods that share similar KV cache in the low range, we -// should consider them all instead of the absolute minimum one. This worked better than picking the -// least one as it gives more choices for the next filter, which on aggregate gave better results. -// TODO: Compare this strategy with other strategies such as top K. -func leastKVCacheFilterFunc(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { - min := math.MaxFloat64 - var max float64 = 0 - filtered := []types.Pod{} - - for _, pod := range pods { - if pod.GetMetrics().KVCacheUsagePercent <= min { - min = pod.GetMetrics().KVCacheUsagePercent - } - if pod.GetMetrics().KVCacheUsagePercent >= max { - max = pod.GetMetrics().KVCacheUsagePercent - } - } - - for _, pod := range pods { - if pod.GetMetrics().KVCacheUsagePercent >= min && pod.GetMetrics().KVCacheUsagePercent <= min+(max-min)/float64(len(pods)) { - filtered = append(filtered, pod) - } - } - return filtered -} - -var LoRAAffinityFilter = &baseFilter{ - name: "affinity LoRA", - filter: loRASoftAffinityFilterFunc, -} - -// loRASoftAffinityPredicate implements a pod selection strategy that prioritizes pods -// with existing LoRA model affinity while allowing for load balancing through randomization. -// -// The function works by: -// 1. Separating pods into two groups: those with target model affinity and those with available capacity -// 2. Using a probability threshold to sometimes select from non-affinity pods to enable load balancing -// 3. Falling back to whatever group has pods if one group is empty -// -// Parameters: -// - logger: Logger interface for diagnostic output -// - req: LLM request containing the resolved target model -// - pods: Slice of pod metrics to filter -// -// Returns: -// - Filtered slice of pod metrics based on affinity and availability -// - Error if any issues occur during filtering -func loRASoftAffinityFilterFunc(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { - - // Pre-allocate slices with estimated capacity - filtered_affinity := make([]types.Pod, 0, len(pods)) - filtered_available := make([]types.Pod, 0, len(pods)) - - // Categorize pods based on affinity and availability - for _, pod := range pods { - _, active := pod.GetMetrics().ActiveModels[ctx.Req.ResolvedTargetModel] - _, waiting := pod.GetMetrics().WaitingModels[ctx.Req.ResolvedTargetModel] - - if active || waiting { - filtered_affinity = append(filtered_affinity, pod) - } else if len(pod.GetMetrics().ActiveModels)+len(pod.GetMetrics().WaitingModels) < pod.GetMetrics().MaxActiveModels { - filtered_available = append(filtered_available, pod) - } - } - - // Use crypto/rand for better randomization in production environments - randSource := rand.NewSource(time.Now().UnixNano()) - randGen := rand.New(randSource) - - // If both groups have pods, use probability to select which group to return - if len(filtered_affinity) > 0 && len(filtered_available) > 0 { - if randGen.Float64() < config.Conf.LoraAffinityThreshold { - return filtered_affinity - } - return filtered_available - } - - // Return whichever group has pods - if len(filtered_affinity) > 0 { - return filtered_affinity - } - - return filtered_available -} - -var HasCapacityFilter = &baseFilter{ - name: "has capacity for sheddable requests", - filter: toFilterFunc(queueThresholdPredicate(config.Conf.QueueThresholdCritical).and(kvCacheThresholdPredicate(config.Conf.KVCacheThreshold))), -} - -// podPredicate is a filter function to check whether a pod is desired. -type podPredicate func(req *types.LLMRequest, pod types.Pod) bool - -func queueThresholdPredicate(queueThreshold int) podPredicate { - return func(req *types.LLMRequest, pod types.Pod) bool { - return pod.GetMetrics().WaitingQueueSize <= queueThreshold - } -} - -func kvCacheThresholdPredicate(kvCacheThreshold float64) podPredicate { - return func(req *types.LLMRequest, pod types.Pod) bool { - return pod.GetMetrics().KVCacheUsagePercent <= kvCacheThreshold - } -} - -func (pp podPredicate) and(another podPredicate) podPredicate { - return func(req *types.LLMRequest, pod types.Pod) bool { - return pp(req, pod) && another(req, pod) - } -} - -var LowLatencyFilter = &DecisionTreeFilter{ - Current: LowQueueFilter, - NextOnSuccess: &DecisionTreeFilter{ - Current: LoRAAffinityFilter, - NextOnSuccessOrFailure: &DecisionTreeFilter{ - Current: LeastQueueFilter, - NextOnSuccessOrFailure: &DecisionTreeFilter{ - Current: LeastKVCacheFilter, - }, - }, - }, - NextOnFailure: &DecisionTreeFilter{ - Current: LeastQueueFilter, - NextOnSuccessOrFailure: &DecisionTreeFilter{ - Current: LoRAAffinityFilter, - NextOnSuccessOrFailure: &DecisionTreeFilter{ - Current: LeastKVCacheFilter, - }, - }, - }, -} - -type SheddableRequestFilter struct{} - -func (p *SheddableRequestFilter) Name() string { - return "SheddableRequestFilter" -} - -func (p *SheddableRequestFilter) Filter(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { - if ctx.Req.Critical { - // Allow all pods to pass through if the request is critical, even if all pods reach their capacity. - return pods - } - - // Only allow pods that have enough capacity to handle the request. - return HasCapacityFilter.Filter(ctx, pods) -} diff --git a/pkg/epp/scheduling/plugins/filter/filter_test.go b/pkg/epp/scheduling/plugins/filter/filter_test.go index 2354c3ef5..1818c5209 100644 --- a/pkg/epp/scheduling/plugins/filter/filter_test.go +++ b/pkg/epp/scheduling/plugins/filter/filter_test.go @@ -25,60 +25,42 @@ import ( "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/config" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" ) -func TestFilter(t *testing.T) { - tests := []struct { - name string - req *types.LLMRequest - input []types.Pod - output []types.Pod - filter *DecisionTreeFilter - }{ - { - name: "simple filter without available pods", - filter: &DecisionTreeFilter{ - Current: &baseFilter{ - name: "filter all", - filter: func(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { - return []types.Pod{} - }, - }, - }, - output: []types.Pod{}, - }, - } +type filterAll struct{} - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - ctx := types.NewSchedulingContext(context.Background(), test.req, test.input) - got := test.filter.Filter(ctx, test.input) +func (f *filterAll) Name() string { + return "filter all" +} - if diff := cmp.Diff(test.output, got); diff != "" { - t.Errorf("Unexpected output (-want +got): %v", diff) - } - }) - } +func (f *filterAll) Filter(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { + return []types.Pod{} } -func TestFilterFunc(t *testing.T) { +func TestFilter(t *testing.T) { tests := []struct { name string - f filterFunc req *types.LLMRequest + filter plugins.Filter input []types.Pod output []types.Pod }{ + { + name: "simple filter filters all pods", + filter: &filterAll{}, + output: []types.Pod{}, + }, { name: "least queuing empty input", - f: leastQueuingFilterFunc, + filter: NewLeastQueueFilter(), input: []types.Pod{}, output: []types.Pod{}, }, { - name: "least queuing", - f: leastQueuingFilterFunc, + name: "least queuing", + filter: NewLeastQueueFilter(), input: []types.Pod{ &types.PodMetrics{ Metrics: &backendmetrics.Metrics{ @@ -111,13 +93,13 @@ func TestFilterFunc(t *testing.T) { }, { name: "least kv cache empty input", - f: leastKVCacheFilterFunc, + filter: NewLeastKVCacheFilter(), input: []types.Pod{}, output: []types.Pod{}, }, { - name: "least kv cache", - f: leastKVCacheFilterFunc, + name: "least kv cache", + filter: NewLeastKVCacheFilter(), input: []types.Pod{ &types.PodMetrics{ Metrics: &backendmetrics.Metrics{ @@ -149,8 +131,8 @@ func TestFilterFunc(t *testing.T) { }, }, { - name: "lowQueueAndLessThanKVCacheThresholdPredicate", - f: toFilterFunc(queueThresholdPredicate(0).and(kvCacheThresholdPredicate(0.8))), + name: "lowQueueAndLessThanKVCacheThresholdPredicate", + filter: &HasCapacityFilter{queueThreshold: 0, kvCacheThreshold: 0.8}, input: []types.Pod{ &types.PodMetrics{ // This pod should be returned. @@ -188,7 +170,7 @@ func TestFilterFunc(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { ctx := types.NewSchedulingContext(context.Background(), test.req, test.input) - got := test.f(ctx, test.input) + got := test.filter.Filter(ctx, test.input) if diff := cmp.Diff(test.output, got); diff != "" { t.Errorf("Unexpected output (-want +got): %v", diff) @@ -254,8 +236,11 @@ func TestLoRASoftAffinityDistribution(t *testing.T) { expectedAffinityPercent := config.Conf.LoraAffinityThreshold * 100 expectedAvailabilityPercent := 100 - expectedAffinityPercent + // initialize LoraAffinityFilter + LoraAffinityFilter := NewLoraAffinityFilter() + for i := 0; i < numIterations; i++ { - result := loRASoftAffinityFilterFunc(ctx, pods) + result := LoraAffinityFilter.Filter(ctx, pods) // Check which type of pod was returned if len(result) != 1 { diff --git a/pkg/epp/scheduling/plugins/filter/has_capacity_filter.go b/pkg/epp/scheduling/plugins/filter/has_capacity_filter.go new file mode 100644 index 000000000..e6ff2ebfb --- /dev/null +++ b/pkg/epp/scheduling/plugins/filter/has_capacity_filter.go @@ -0,0 +1,58 @@ +/* +Copyright 2025 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 filter + +import ( + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/config" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" +) + +// compile-time type validation +var _ plugins.Filter = &HasCapacityFilter{} + +// NewHasCapacityFilter returns a new HasCapacityFilter. +func NewHasCapacityFilter() *HasCapacityFilter { + return &HasCapacityFilter{ + queueThreshold: config.Conf.QueueThresholdCritical, + kvCacheThreshold: config.Conf.KVCacheThreshold, + } +} + +// HasCapacityFilter filters only pods that has capacity for sheddable requests. +type HasCapacityFilter struct { + queueThreshold int + kvCacheThreshold float64 +} + +// Name returns the name of the filter. +func (f *HasCapacityFilter) Name() string { + return "has-capacity" +} + +// Filter filters out pods that doesn't meet the filter criteria. +func (f *HasCapacityFilter) Filter(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { + filteredPods := []types.Pod{} + + for _, pod := range pods { + if pod.GetMetrics().WaitingQueueSize <= f.queueThreshold && pod.GetMetrics().KVCacheUsagePercent <= f.kvCacheThreshold { + filteredPods = append(filteredPods, pod) + } + } + + return filteredPods +} diff --git a/pkg/epp/scheduling/plugins/filter/least_kvcache_filter.go b/pkg/epp/scheduling/plugins/filter/least_kvcache_filter.go new file mode 100644 index 000000000..eed647682 --- /dev/null +++ b/pkg/epp/scheduling/plugins/filter/least_kvcache_filter.go @@ -0,0 +1,68 @@ +/* +Copyright 2025 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 filter + +import ( + "math" + + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" +) + +// compile-time type validation +var _ plugins.Filter = &LeastKVCacheFilter{} + +// NewLeastKVCacheFilter returns a new LeastKVCacheFilter. +func NewLeastKVCacheFilter() *LeastKVCacheFilter { + return &LeastKVCacheFilter{} +} + +// LeastKVCacheFilter finds the max and min KV cache of all pods, divides the whole range +// (max-min) by the number of pods, and finds the pods that fall into the first range. +// The intuition is that if there are multiple pods that share similar KV cache in the low range, we +// should consider them all instead of the absolute minimum one. This worked better than picking the +// least one as it gives more choices for the next filter, which on aggregate gave better results. +type LeastKVCacheFilter struct{} + +// Name returns the name of the filter. +func (f *LeastKVCacheFilter) Name() string { + return "least-KV-cache" +} + +// Filter filters out pods that doesn't meet the filter criteria. +func (f *LeastKVCacheFilter) Filter(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { + filteredPods := []types.Pod{} + + min := math.MaxFloat64 + var max float64 = 0 + + for _, pod := range pods { + if pod.GetMetrics().KVCacheUsagePercent <= min { + min = pod.GetMetrics().KVCacheUsagePercent + } + if pod.GetMetrics().KVCacheUsagePercent >= max { + max = pod.GetMetrics().KVCacheUsagePercent + } + } + + for _, pod := range pods { + if pod.GetMetrics().KVCacheUsagePercent >= min && pod.GetMetrics().KVCacheUsagePercent <= min+(max-min)/float64(len(pods)) { + filteredPods = append(filteredPods, pod) + } + } + return filteredPods +} diff --git a/pkg/epp/scheduling/plugins/filter/least_queue_filter.go b/pkg/epp/scheduling/plugins/filter/least_queue_filter.go new file mode 100644 index 000000000..7a71d5191 --- /dev/null +++ b/pkg/epp/scheduling/plugins/filter/least_queue_filter.go @@ -0,0 +1,70 @@ +/* +Copyright 2025 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 filter + +import ( + "math" + + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" +) + +// compile-time type validation +var _ plugins.Filter = &LeastQueueFilter{} + +// NewLeastQueueFilter returns a new LeastQueueFilter. +func NewLeastQueueFilter() *LeastQueueFilter { + return &LeastQueueFilter{} +} + +// LeastQueueFilter finds the max and min queue size of all pods, divides the whole range +// (max-min) by the number of pods, and finds the pods that fall into the first range. +// The intuition is that if there are multiple pods that share similar queue size in the low range, +// we should consider them all instead of the absolute minimum one. This worked better than picking +// the least one as it gives more choices for the next filter, which on aggregate gave better +// results. +type LeastQueueFilter struct{} + +// Name returns the name of the filter. +func (f *LeastQueueFilter) Name() string { + return "least-queue" +} + +// Filter filters out pods that doesn't meet the filter criteria. +func (f *LeastQueueFilter) Filter(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { + filteredPods := []types.Pod{} + + min := math.MaxInt + max := 0 + + for _, pod := range pods { + if pod.GetMetrics().WaitingQueueSize <= min { + min = pod.GetMetrics().WaitingQueueSize + } + if pod.GetMetrics().WaitingQueueSize >= max { + max = pod.GetMetrics().WaitingQueueSize + } + } + + for _, pod := range pods { + if pod.GetMetrics().WaitingQueueSize >= min && pod.GetMetrics().WaitingQueueSize <= min+(max-min)/len(pods) { + filteredPods = append(filteredPods, pod) + } + } + + return filteredPods +} diff --git a/pkg/epp/scheduling/plugins/filter/lora_affinity_filter.go b/pkg/epp/scheduling/plugins/filter/lora_affinity_filter.go new file mode 100644 index 000000000..a1c200412 --- /dev/null +++ b/pkg/epp/scheduling/plugins/filter/lora_affinity_filter.go @@ -0,0 +1,90 @@ +/* +Copyright 2025 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 filter + +import ( + "math/rand" + "time" + + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/config" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" +) + +// compile-time type validation +var _ plugins.Filter = &LoraAffinityFilter{} + +// NewLoraAffinityFilter returns a new LoraAffinityFilter. +func NewLoraAffinityFilter() *LoraAffinityFilter { + return &LoraAffinityFilter{ + loraAffinityThreshold: config.Conf.LoraAffinityThreshold, + } +} + +// LoraAffinityFilter implements a pod selection strategy that prioritizes pods +// with existing LoRA model affinity while allowing for load balancing through randomization. +// +// The function works by: +// 1. Separating pods into two groups: those with target model affinity and those with available capacity +// 2. Using a probability threshold to sometimes select from non-affinity pods to enable load balancing +// 3. Falling back to whatever group has pods if one group is empty +type LoraAffinityFilter struct { + loraAffinityThreshold float64 +} + +// Name returns the name of the filter. +func (f *LoraAffinityFilter) Name() string { + return "lora-affinity" +} + +// Filter filters out pods that doesn't meet the filter criteria. +func (f *LoraAffinityFilter) Filter(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { + // Pre-allocate slices with estimated capacity + filtered_affinity := make([]types.Pod, 0, len(pods)) + filtered_available := make([]types.Pod, 0, len(pods)) + + // Categorize pods based on affinity and availability + for _, pod := range pods { + _, active := pod.GetMetrics().ActiveModels[ctx.Req.ResolvedTargetModel] + _, waiting := pod.GetMetrics().WaitingModels[ctx.Req.ResolvedTargetModel] + + if active || waiting { + filtered_affinity = append(filtered_affinity, pod) + } else if len(pod.GetMetrics().ActiveModels)+len(pod.GetMetrics().WaitingModels) < pod.GetMetrics().MaxActiveModels { + filtered_available = append(filtered_available, pod) + } + } + + // Use crypto/rand for better randomization in production environments + randSource := rand.NewSource(time.Now().UnixNano()) + randGen := rand.New(randSource) + + // If both groups have pods, use probability to select which group to return + if len(filtered_affinity) > 0 && len(filtered_available) > 0 { + if randGen.Float64() < f.loraAffinityThreshold { + return filtered_affinity + } + return filtered_available + } + + // Return whichever group has pods + if len(filtered_affinity) > 0 { + return filtered_affinity + } + + return filtered_available +} diff --git a/pkg/epp/scheduling/plugins/filter/low_queue_filter.go b/pkg/epp/scheduling/plugins/filter/low_queue_filter.go new file mode 100644 index 000000000..feb599b43 --- /dev/null +++ b/pkg/epp/scheduling/plugins/filter/low_queue_filter.go @@ -0,0 +1,56 @@ +/* +Copyright 2025 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 filter + +import ( + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/config" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" +) + +// compile-time type validation +var _ plugins.Filter = &LowQueueFilter{} + +// NewLowQueueFilter returns a new LowQueueFilter. +func NewLowQueueFilter() *LowQueueFilter { + return &LowQueueFilter{ + queueingThresholdLoRA: config.Conf.QueueingThresholdLoRA, + } +} + +// LowQueueFilter returns pods that their waiting queue size is less than a configured threshold +type LowQueueFilter struct { + queueingThresholdLoRA int +} + +// Name returns the name of the filter. +func (f *LowQueueFilter) Name() string { + return "low-queue" +} + +// Filter filters out pods that doesn't meet the filter criteria. +func (f *LowQueueFilter) Filter(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { + filteredPods := []types.Pod{} + + for _, pod := range pods { + if pod.GetMetrics().WaitingQueueSize <= f.queueingThresholdLoRA { + filteredPods = append(filteredPods, pod) + } + } + + return filteredPods +} diff --git a/pkg/epp/scheduling/plugins/filter/sheddable_request_filter.go b/pkg/epp/scheduling/plugins/filter/sheddable_request_filter.go new file mode 100644 index 000000000..7eb6326be --- /dev/null +++ b/pkg/epp/scheduling/plugins/filter/sheddable_request_filter.go @@ -0,0 +1,53 @@ +/* +Copyright 2025 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 filter + +import ( + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" +) + +// compile-time type validation +var _ plugins.Filter = &SheddableRequestFilter{} + +// NewSheddableRequestFilter returns a new SheddableRequestFilter. +func NewSheddableRequestFilter() *SheddableRequestFilter { + return &SheddableRequestFilter{ + hasCapacityFilter: NewHasCapacityFilter(), + } +} + +// LowQueueFilter returns pods that their waiting queue size is less than a configured threshold +type SheddableRequestFilter struct { + hasCapacityFilter *HasCapacityFilter +} + +// Name returns the name of the filter. +func (f *SheddableRequestFilter) Name() string { + return "sheddable-request" +} + +// Filter filters out pods that doesn't meet the filter criteria. +func (f *SheddableRequestFilter) Filter(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { + if ctx.Req.Critical { + // Allow all pods to pass through if the request is critical, even if all pods reach their capacity. + return pods + } + + // Only allow pods that have enough capacity to handle the request. + return f.hasCapacityFilter.Filter(ctx, pods) +} diff --git a/pkg/epp/scheduling/scheduler.go b/pkg/epp/scheduling/scheduler.go index 37d818e11..b96730437 100644 --- a/pkg/epp/scheduling/scheduler.go +++ b/pkg/epp/scheduling/scheduler.go @@ -26,15 +26,56 @@ import ( backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/filter" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/picker" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/error" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) +// NewScheduler returns a new scheduler with default scheduler plugins configuration. func NewScheduler(datastore Datastore) *Scheduler { + // When the scheduler is initialized with NewScheduler function, thw below config will be used as default. + // it's possible to call NewSchedulerWithConfig to pass a different scheduler config. + // For build time plugins changes, it's recommended to call in main.go to NewSchedulerWithConfig. + loraAffinityFilter := filter.NewLoraAffinityFilter() + leastQueueFilter := filter.NewLeastQueueFilter() + leastKvCacheFilter := filter.NewLeastKVCacheFilter() + + lowLatencyFilter := &filter.DecisionTreeFilter{ + Current: filter.NewLowQueueFilter(), + NextOnSuccess: &filter.DecisionTreeFilter{ + Current: loraAffinityFilter, + NextOnSuccessOrFailure: &filter.DecisionTreeFilter{ + Current: leastQueueFilter, + NextOnSuccessOrFailure: &filter.DecisionTreeFilter{ + Current: leastKvCacheFilter, + }, + }, + }, + NextOnFailure: &filter.DecisionTreeFilter{ + Current: leastQueueFilter, + NextOnSuccessOrFailure: &filter.DecisionTreeFilter{ + Current: loraAffinityFilter, + NextOnSuccessOrFailure: &filter.DecisionTreeFilter{ + Current: leastKvCacheFilter, + }, + }, + }, + } + + defaultConfig := &SchedulerConfig{ + preSchedulePlugins: []plugins.PreSchedule{}, + filters: []plugins.Filter{filter.NewSheddableRequestFilter(), lowLatencyFilter}, + scorers: map[plugins.Scorer]int{}, + picker: &picker.RandomPicker{}, + postSchedulePlugins: []plugins.PostSchedule{}, + } + return NewSchedulerWithConfig(datastore, defaultConfig) } +// NewSchedulerWithConfig returns a new scheduler with the given scheduler plugins configuration. func NewSchedulerWithConfig(datastore Datastore, config *SchedulerConfig) *Scheduler { return &Scheduler{ datastore: datastore, From 2b66451744de467dede1ad9ec06d84261850d27e Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Thu, 8 May 2025 23:59:15 +0300 Subject: [PATCH 05/53] fix labels not cloned bug (#804) Signed-off-by: Nir Rozenbaum --- pkg/epp/backend/metrics/pod_metrics.go | 18 +++++++++++------- pkg/epp/backend/pod.go | 6 +++++- pkg/epp/scheduling/scheduler_test.go | 6 +++--- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/pkg/epp/backend/metrics/pod_metrics.go b/pkg/epp/backend/metrics/pod_metrics.go index 735f297e0..8660bc3c9 100644 --- a/pkg/epp/backend/metrics/pod_metrics.go +++ b/pkg/epp/backend/metrics/pod_metrics.go @@ -64,18 +64,22 @@ func (pm *podMetrics) GetMetrics() *Metrics { return pm.metrics.Load() } -func (pm *podMetrics) UpdatePod(in *corev1.Pod) { - pm.pod.Store(toInternalPod(in)) +func (pm *podMetrics) UpdatePod(pod *corev1.Pod) { + pm.pod.Store(toInternalPod(pod)) } -func toInternalPod(in *corev1.Pod) *backend.Pod { +func toInternalPod(pod *corev1.Pod) *backend.Pod { + labels := make(map[string]string, len(pod.GetLabels())) + for key, value := range pod.GetLabels() { + labels[key] = value + } return &backend.Pod{ NamespacedName: types.NamespacedName{ - Name: in.Name, - Namespace: in.Namespace, + Name: pod.Name, + Namespace: pod.Namespace, }, - Address: in.Status.PodIP, - Labels: in.Labels, + Address: pod.Status.PodIP, + Labels: labels, } } diff --git a/pkg/epp/backend/pod.go b/pkg/epp/backend/pod.go index 57f034b53..bb7debe68 100644 --- a/pkg/epp/backend/pod.go +++ b/pkg/epp/backend/pod.go @@ -36,12 +36,16 @@ func (p *Pod) Clone() *Pod { if p == nil { return nil } + clonedLabels := make(map[string]string, len(p.Labels)) + for key, value := range p.Labels { + clonedLabels[key] = value + } return &Pod{ NamespacedName: types.NamespacedName{ Name: p.NamespacedName.Name, Namespace: p.NamespacedName.Namespace, }, Address: p.Address, - Labels: p.Labels, + Labels: clonedLabels, } } diff --git a/pkg/epp/scheduling/scheduler_test.go b/pkg/epp/scheduling/scheduler_test.go index b44c7ac2e..07f67d6a4 100644 --- a/pkg/epp/scheduling/scheduler_test.go +++ b/pkg/epp/scheduling/scheduler_test.go @@ -96,7 +96,7 @@ func TestSchedule(t *testing.T) { wantRes: &types.Result{ TargetPod: &types.ScoredPod{ Pod: &types.PodMetrics{ - Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}, + Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}, Labels: make(map[string]string)}, Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 3, KVCacheUsagePercent: 0.1, @@ -159,7 +159,7 @@ func TestSchedule(t *testing.T) { wantRes: &types.Result{ TargetPod: &types.ScoredPod{ Pod: &types.PodMetrics{ - Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}, + Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}, Labels: make(map[string]string)}, Metrics: &backendmetrics.Metrics{ WaitingQueueSize: 0, KVCacheUsagePercent: 0.2, @@ -370,7 +370,7 @@ func TestSchedulePlugins(t *testing.T) { // Validate output wantPod := &types.PodMetrics{ - Pod: &backend.Pod{NamespacedName: test.wantTargetPod}, + Pod: &backend.Pod{NamespacedName: test.wantTargetPod, Labels: make(map[string]string)}, } wantRes := &types.Result{TargetPod: wantPod} if diff := cmp.Diff(wantRes, got); diff != "" { From 7beb4719a0929836cf17c425aa5ecf46aa3bb4e9 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Fri, 9 May 2025 19:43:16 +0300 Subject: [PATCH 06/53] fixed datastore bug to clean all go routines when pool is unset. (#810) current implementation leaves dangling go routines and structs which will consume resources and hold unused objects from being GCd Signed-off-by: Nir Rozenbaum --- pkg/epp/datastore/datastore.go | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/pkg/epp/datastore/datastore.go b/pkg/epp/datastore/datastore.go index 22c500220..5ae496478 100644 --- a/pkg/epp/datastore/datastore.go +++ b/pkg/epp/datastore/datastore.go @@ -101,6 +101,11 @@ func (ds *datastore) Clear() { defer ds.poolAndModelsMu.Unlock() ds.pool = nil ds.models = make(map[string]*v1alpha2.InferenceModel) + // stop all pods go routines before clearing the pods map. + ds.pods.Range(func(_, v any) bool { + v.(backendmetrics.PodMetrics).StopRefreshLoop() + return true + }) ds.pods.Clear() } @@ -245,14 +250,15 @@ func (ds *datastore) PodGetAll() []backendmetrics.PodMetrics { func (ds *datastore) PodList(predicate func(backendmetrics.PodMetrics) bool) []backendmetrics.PodMetrics { res := []backendmetrics.PodMetrics{} - fn := func(k, v any) bool { + + ds.pods.Range(func(k, v any) bool { pm := v.(backendmetrics.PodMetrics) if predicate(pm) { res = append(res, pm) } return true - } - ds.pods.Range(fn) + }) + return res } @@ -307,15 +313,14 @@ func (ds *datastore) podResyncAll(ctx context.Context, ctrlClient client.Client) } // Remove pods that don't belong to the pool or not ready any more. - deleteFn := func(k, v any) bool { + ds.pods.Range(func(k, v any) bool { pm := v.(backendmetrics.PodMetrics) if exist := activePods[pm.GetPod().NamespacedName.Name]; !exist { logger.V(logutil.VERBOSE).Info("Removing pod", "pod", pm.GetPod()) ds.PodDelete(pm.GetPod().NamespacedName) } return true - } - ds.pods.Range(deleteFn) + }) return nil } From 4029a37a664c96e9b7b5876c8e02eb72872e2ae5 Mon Sep 17 00:00:00 2001 From: GunaKKIBM <92353386+GunaKKIBM@users.noreply.github.com> Date: Fri, 9 May 2025 22:33:15 +0530 Subject: [PATCH 07/53] Optimize Dockerfile for Multiple Extensions (#811) --- Dockerfile | 6 +++--- bbr.Dockerfile | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9cb62e282..acf05b7fa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,11 +16,11 @@ COPY go.mod go.sum ./ RUN go mod download # Sources -COPY cmd ./cmd -COPY pkg ./pkg +COPY cmd/epp ./cmd +COPY pkg/epp ./pkg/epp COPY internal ./internal COPY api ./api -WORKDIR /src/cmd/epp +WORKDIR /src/cmd RUN go build -ldflags="-X sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics.CommitSHA=${COMMIT_SHA}" -o /epp ## Multistage deploy diff --git a/bbr.Dockerfile b/bbr.Dockerfile index 03024e497..8f21eeaaf 100644 --- a/bbr.Dockerfile +++ b/bbr.Dockerfile @@ -15,10 +15,10 @@ COPY go.mod go.sum ./ RUN go mod download # Sources -COPY cmd ./cmd +COPY cmd/bbr ./cmd COPY pkg ./pkg COPY internal ./internal -WORKDIR /src/cmd/bbr +WORKDIR /src/cmd RUN go build -o /bbr ## Multistage deploy From 2dce3ea482b9fb797ebfca021d5b61d953239200 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Fri, 9 May 2025 23:23:15 +0300 Subject: [PATCH 08/53] merge has capacity filter with sheddable filter. (#809) * merge has capacity filter with sheddable filter. has capacity only use was for sheddable requests (passthrough for critical ones). Signed-off-by: Nir Rozenbaum * Update pkg/epp/scheduling/plugins/filter/filter_test.go Co-authored-by: Cong Liu --------- Signed-off-by: Nir Rozenbaum Co-authored-by: Cong Liu --- .../scheduling/plugins/filter/filter_test.go | 5 +- ...filter.go => sheddable_capacity_filter.go} | 22 ++++---- .../filter/sheddable_request_filter.go | 53 ------------------- pkg/epp/scheduling/scheduler.go | 2 +- 4 files changed, 17 insertions(+), 65 deletions(-) rename pkg/epp/scheduling/plugins/filter/{has_capacity_filter.go => sheddable_capacity_filter.go} (67%) delete mode 100644 pkg/epp/scheduling/plugins/filter/sheddable_request_filter.go diff --git a/pkg/epp/scheduling/plugins/filter/filter_test.go b/pkg/epp/scheduling/plugins/filter/filter_test.go index 1818c5209..d78452a62 100644 --- a/pkg/epp/scheduling/plugins/filter/filter_test.go +++ b/pkg/epp/scheduling/plugins/filter/filter_test.go @@ -131,8 +131,9 @@ func TestFilter(t *testing.T) { }, }, { - name: "lowQueueAndLessThanKVCacheThresholdPredicate", - filter: &HasCapacityFilter{queueThreshold: 0, kvCacheThreshold: 0.8}, + name: "SheddableCapacityFilter, sheddable request", + req: &types.LLMRequest{Critical: false}, + filter: &SheddableCapacityFilter{queueThreshold: 0, kvCacheThreshold: 0.8}, input: []types.Pod{ &types.PodMetrics{ // This pod should be returned. diff --git a/pkg/epp/scheduling/plugins/filter/has_capacity_filter.go b/pkg/epp/scheduling/plugins/filter/sheddable_capacity_filter.go similarity index 67% rename from pkg/epp/scheduling/plugins/filter/has_capacity_filter.go rename to pkg/epp/scheduling/plugins/filter/sheddable_capacity_filter.go index e6ff2ebfb..5a298a022 100644 --- a/pkg/epp/scheduling/plugins/filter/has_capacity_filter.go +++ b/pkg/epp/scheduling/plugins/filter/sheddable_capacity_filter.go @@ -23,29 +23,33 @@ import ( ) // compile-time type validation -var _ plugins.Filter = &HasCapacityFilter{} +var _ plugins.Filter = &SheddableCapacityFilter{} -// NewHasCapacityFilter returns a new HasCapacityFilter. -func NewHasCapacityFilter() *HasCapacityFilter { - return &HasCapacityFilter{ +// NewSheddableCapacityFilter returns a new SheddableCapacityFilter. +func NewSheddableCapacityFilter() *SheddableCapacityFilter { + return &SheddableCapacityFilter{ queueThreshold: config.Conf.QueueThresholdCritical, kvCacheThreshold: config.Conf.KVCacheThreshold, } } -// HasCapacityFilter filters only pods that has capacity for sheddable requests. -type HasCapacityFilter struct { +// SheddableCapacityFilter filters only pods that has capacity for sheddable requests. +type SheddableCapacityFilter struct { queueThreshold int kvCacheThreshold float64 } // Name returns the name of the filter. -func (f *HasCapacityFilter) Name() string { - return "has-capacity" +func (f *SheddableCapacityFilter) Name() string { + return "sheddable-capacity" } // Filter filters out pods that doesn't meet the filter criteria. -func (f *HasCapacityFilter) Filter(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { +func (f *SheddableCapacityFilter) Filter(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { + if ctx.Req.Critical { + return pods // // Allow all pods to passthrough if the request is critical, even if all pods reach their capacity. + } + filteredPods := []types.Pod{} for _, pod := range pods { diff --git a/pkg/epp/scheduling/plugins/filter/sheddable_request_filter.go b/pkg/epp/scheduling/plugins/filter/sheddable_request_filter.go deleted file mode 100644 index 7eb6326be..000000000 --- a/pkg/epp/scheduling/plugins/filter/sheddable_request_filter.go +++ /dev/null @@ -1,53 +0,0 @@ -/* -Copyright 2025 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 filter - -import ( - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" -) - -// compile-time type validation -var _ plugins.Filter = &SheddableRequestFilter{} - -// NewSheddableRequestFilter returns a new SheddableRequestFilter. -func NewSheddableRequestFilter() *SheddableRequestFilter { - return &SheddableRequestFilter{ - hasCapacityFilter: NewHasCapacityFilter(), - } -} - -// LowQueueFilter returns pods that their waiting queue size is less than a configured threshold -type SheddableRequestFilter struct { - hasCapacityFilter *HasCapacityFilter -} - -// Name returns the name of the filter. -func (f *SheddableRequestFilter) Name() string { - return "sheddable-request" -} - -// Filter filters out pods that doesn't meet the filter criteria. -func (f *SheddableRequestFilter) Filter(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { - if ctx.Req.Critical { - // Allow all pods to pass through if the request is critical, even if all pods reach their capacity. - return pods - } - - // Only allow pods that have enough capacity to handle the request. - return f.hasCapacityFilter.Filter(ctx, pods) -} diff --git a/pkg/epp/scheduling/scheduler.go b/pkg/epp/scheduling/scheduler.go index b96730437..2e85619af 100644 --- a/pkg/epp/scheduling/scheduler.go +++ b/pkg/epp/scheduling/scheduler.go @@ -66,7 +66,7 @@ func NewScheduler(datastore Datastore) *Scheduler { defaultConfig := &SchedulerConfig{ preSchedulePlugins: []plugins.PreSchedule{}, - filters: []plugins.Filter{filter.NewSheddableRequestFilter(), lowLatencyFilter}, + filters: []plugins.Filter{filter.NewSheddableCapacityFilter(), lowLatencyFilter}, scorers: map[plugins.Scorer]int{}, picker: &picker.RandomPicker{}, postSchedulePlugins: []plugins.PostSchedule{}, From 64a37d1a016c083bf73c4d58bcd04fabf9407ac6 Mon Sep 17 00:00:00 2001 From: sina chavoshi Date: Fri, 9 May 2025 14:51:13 -0700 Subject: [PATCH 09/53] feat(conformance): Add initial InferencePool tests and shared Gateway setup (#772) * Add inferencepool_lifecycle test. * Resolve setup issues and enable InferencePool test * correct Lint error Multiplication of durations * Fix missing containerPort, is missing * change gateway name from "gateway-conformance-app" to "conformance-gateway" * clarify why K8s types are needed. * Update conformance/conformance.go Co-authored-by: Lior Lieberman * Update conformance/conformance.go Co-authored-by: Lior Lieberman * remove for loop when adding SupportedFeatures * remove exessive logging * Update conformance/conformance.go Co-authored-by: Lior Lieberman * move excess debug logs behind debug flag. * remove CONFORMANCE.GO prefix from logs. * change the pull logic and use default value from GatewayMustHaveAddress * fix mt.Sprintf can be replaced with string concatenation * add a function for logDebug * factor out ensureGatewayAvailableAndReady * removed todo comment in helper.go * remove CONFORMANCE.GO from log * error messages, should not be capitalized or end with punctuation --------- Co-authored-by: Lior Lieberman --- conformance/conformance.go | 163 ++++++++++++++---- conformance/embed.go | 2 +- .../resources/manifests/manifests.yaml | 18 +- .../tests/basic/inferencepool_accepted.yaml | 76 +++++++- conformance/utils/kubernetes/helpers.go | 127 ++++++++++++-- 5 files changed, 326 insertions(+), 60 deletions(-) diff --git a/conformance/conformance.go b/conformance/conformance.go index 20d80fde5..1e847fd62 100644 --- a/conformance/conformance.go +++ b/conformance/conformance.go @@ -19,30 +19,38 @@ limitations under the License. package conformance import ( + "context" + "errors" "fmt" "io/fs" "os" "testing" + "time" "github.com/stretchr/testify/require" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/wait" clientset "k8s.io/client-go/kubernetes" + clientsetscheme "k8s.io/client-go/kubernetes/scheme" // Import runtime package for scheme creation "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/sets" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/config" + k8sconfig "sigs.k8s.io/controller-runtime/pkg/client/config" "sigs.k8s.io/yaml" // Import necessary types and utilities from the core Gateway API conformance suite. - // Assumes sigs.k8s.io/gateway-api is a dependency in the go.mod. gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" // Import core Gateway API types confapis "sigs.k8s.io/gateway-api/conformance/apis/v1" // Report struct definition confconfig "sigs.k8s.io/gateway-api/conformance/utils/config" confflags "sigs.k8s.io/gateway-api/conformance/utils/flags" + apikubernetes "sigs.k8s.io/gateway-api/conformance/utils/kubernetes" confsuite "sigs.k8s.io/gateway-api/conformance/utils/suite" - "sigs.k8s.io/gateway-api/pkg/features" // Using core features definitions if applicable + "sigs.k8s.io/gateway-api/pkg/features" // Import the test definitions package to access the ConformanceTests slice "sigs.k8s.io/gateway-api-inference-extension/conformance/tests" @@ -58,48 +66,59 @@ import ( inferencev1alpha2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" ) +// Constants for the shared Gateway +const ( + SharedGatewayName = "conformance-gateway" // Name of the Gateway in manifests.yaml + SharedGatewayNamespace = "gateway-conformance-infra" // Namespace of the Gateway +) + // GatewayLayerProfileName defines the name for the conformance profile that tests // the Gateway API layer aspects of the Inference Extension (e.g., InferencePool, InferenceModel CRDs). // Future profiles will cover EPP and ModelServer layers. const GatewayLayerProfileName confsuite.ConformanceProfileName = "Gateway" -var InferenceCoreFeatures = sets.New[features.FeatureName]() // Placeholder - Populate with actual features specific to this profile or manage features per profile +// InferenceCoreFeatures defines the core features that implementations +// of the "Gateway" profile for the Inference Extension MUST support. +var InferenceCoreFeatures = sets.New( + features.SupportGateway, // This is needed to ensure manifest gets applied during setup. +) -// GatewayLayerProfile defines the conformance profile for the Gateway API layer -// of the Inference Extension. -// In future iterations, we will add constants and ConformanceProfile structs for -// EPPProfileName ("EPP") and ModelServerProfileName ("ModelServer") -// to cover their respective conformance layers. var GatewayLayerProfile = confsuite.ConformanceProfile{ Name: GatewayLayerProfileName, CoreFeatures: InferenceCoreFeatures, } +// logDebugf conditionally logs a debug message if debug mode is enabled. +func logDebugf(t *testing.T, debug bool, format string, args ...any) { + if debug { + t.Helper() + t.Logf(format, args...) + } +} + // DefaultOptions parses command line flags and sets up the suite options. // Adapted from the core Gateway API conformance suite. func DefaultOptions(t *testing.T) confsuite.ConformanceOptions { t.Helper() - cfg, err := config.GetConfig() + cfg, err := k8sconfig.GetConfig() require.NoError(t, err, "error loading Kubernetes config") - // Initialize client options. The scheme must include Gateway API types - // and the Inference Extension types. - clientOptions := client.Options{} - scheme := clientOptions.Scheme - if scheme == nil { - // If default options don't provide a scheme, create one using runtime.NewScheme(). - scheme = runtime.NewScheme() - clientOptions.Scheme = scheme - } + scheme := runtime.NewScheme() + + t.Log("Registering API types with scheme...") + // Register core K8s types (like v1.Secret for certs) to scheme, needed by client to create/manage these resources. + require.NoError(t, clientsetscheme.AddToScheme(scheme), "failed to add core Kubernetes types to scheme") + // Add Gateway API types + require.NoError(t, gatewayv1.Install(scheme), "failed to install gatewayv1 types into scheme") + // Add APIExtensions types (for CRDs) + require.NoError(t, apiextensionsv1.AddToScheme(scheme), "failed to add apiextensionsv1 types to scheme") - // Register necessary API Types - require.NoError(t, gatewayv1.Install(scheme)) // Add core Gateway API types - // Add the Inference Extension API types to the scheme using the correct import alias - require.NoError(t, inferencev1alpha2.Install(scheme)) - require.NoError(t, apiextensionsv1.AddToScheme(scheme)) // Needed for CRD checks + // Register Inference Extension API types + t.Logf("Attempting to install inferencev1alpha2 types into scheme from package: %s", inferencev1alpha2.GroupName) + require.NoError(t, inferencev1alpha2.Install(scheme), "failed to install inferencev1alpha2 types into scheme") - // Create the Kubernetes clients + clientOptions := client.Options{Scheme: scheme} c, err := client.New(cfg, clientOptions) require.NoError(t, err, "error initializing Kubernetes client") cs, err := clientset.NewForConfig(cfg) @@ -124,15 +143,18 @@ func DefaultOptions(t *testing.T) confsuite.ConformanceOptions { inferenceExtensionVersion := "v0.3.0" _ = inferenceExtensionVersion // Avoid unused variable error until implemented - // Create ConformanceOptions + baseManifestsValue := "resources/manifests/manifests.yaml" + opts := confsuite.ConformanceOptions{ Client: c, + ClientOptions: clientOptions, Clientset: cs, RestConfig: cfg, GatewayClassName: *confflags.GatewayClassName, + BaseManifests: baseManifestsValue, Debug: *confflags.ShowDebug, CleanupBaseResources: *confflags.CleanupBaseResources, - SupportedFeatures: sets.New[features.FeatureName](), // Initialize empty, will be populated below + SupportedFeatures: sets.New[features.FeatureName](), TimeoutConfig: confconfig.DefaultTimeoutConfig(), SkipTests: skipTests, ExemptFeatures: exemptFeatures, @@ -140,7 +162,7 @@ func DefaultOptions(t *testing.T) confsuite.ConformanceOptions { Mode: *confflags.Mode, Implementation: implementation, ConformanceProfiles: conformanceProfiles, - ManifestFS: []fs.FS{&Manifests}, // Assumes embed.go defines `Manifests` + ManifestFS: []fs.FS{&Manifests}, ReportOutputPath: *confflags.ReportOutput, SkipProvisionalTests: *confflags.SkipProvisionalTests, // TODO: Add the inference extension specific fields to ConformanceOptions struct if needed, @@ -152,16 +174,20 @@ func DefaultOptions(t *testing.T) confsuite.ConformanceOptions { // Populate SupportedFeatures based on the GatewayLayerProfile. // Since all features are mandatory for this profile, add all defined core features. if opts.ConformanceProfiles.Has(GatewayLayerProfileName) { - for feature := range GatewayLayerProfile.CoreFeatures { - opts.SupportedFeatures.Insert(feature) + logDebugf(t, opts.Debug, "Populating SupportedFeatures with GatewayLayerProfile.CoreFeatures: %v", GatewayLayerProfile.CoreFeatures.UnsortedList()) + if GatewayLayerProfile.CoreFeatures.Len() > 0 { + opts.SupportedFeatures = opts.SupportedFeatures.Insert(GatewayLayerProfile.CoreFeatures.UnsortedList()...) } } // Remove any features explicitly exempted via flags. - for feature := range opts.ExemptFeatures { - opts.SupportedFeatures.Delete(feature) + if opts.ExemptFeatures.Len() > 0 { + logDebugf(t, opts.Debug, "Removing ExemptFeatures from SupportedFeatures: %v", opts.ExemptFeatures.UnsortedList()) + opts.SupportedFeatures = opts.SupportedFeatures.Delete(opts.ExemptFeatures.UnsortedList()...) } + logDebugf(t, opts.Debug, "Final opts.SupportedFeatures: %v", opts.SupportedFeatures.UnsortedList()) + return opts } @@ -172,7 +198,9 @@ func RunConformance(t *testing.T) { // RunConformanceWithOptions runs the Inference Extension conformance tests with specific options. func RunConformanceWithOptions(t *testing.T, opts confsuite.ConformanceOptions) { + t.Helper() t.Logf("Running Inference Extension conformance tests with GatewayClass %s", opts.GatewayClassName) + logDebugf(t, opts.Debug, "RunConformanceWithOptions: BaseManifests path being used by opts: %q", opts.BaseManifests) // Register the GatewayLayerProfile with the suite runner. // In the future, other profiles (EPP, ModelServer) will also be registered here, @@ -183,13 +211,13 @@ func RunConformanceWithOptions(t *testing.T, opts confsuite.ConformanceOptions) cSuite, err := confsuite.NewConformanceTestSuite(opts) require.NoError(t, err, "error initializing conformance suite") - t.Log("Setting up Inference Extension conformance tests") - // Setup requires the list of tests, which is populated by the init() functions - // triggered by the blank imports at the top of this file. cSuite.Setup(t, tests.ConformanceTests) - t.Log("Running Inference Extension conformance tests") - // Run the tests. + sharedGwNN := types.NamespacedName{Name: SharedGatewayName, Namespace: SharedGatewayNamespace} + + // Validate Gateway setup. + ensureGatewayAvailableAndReady(t, cSuite.Client, opts, sharedGwNN) + t.Log("Running Inference Extension conformance tests against all registered tests") err = cSuite.Run(t, tests.ConformanceTests) require.NoError(t, err, "error running conformance tests") @@ -209,6 +237,67 @@ func RunConformanceWithOptions(t *testing.T, opts confsuite.ConformanceOptions) } } +// ensureGatewayAvailableAndReady polls for the specified Gateway to exist and become ready +// with an address and programmed condition. +func ensureGatewayAvailableAndReady(t *testing.T, k8sClient client.Client, opts confsuite.ConformanceOptions, gatewayNN types.NamespacedName) { + t.Helper() + + t.Logf("Attempting to fetch Gateway %s/%s.", gatewayNN.Namespace, gatewayNN.Name) + gw := &gatewayv1.Gateway{} // This gw instance will be populated by the poll function + + // Define polling interval + // TODO: Make this configurable using a local TimeoutConfig (from ConformanceOptions perhaps) + pollingInterval := 5 * time.Second + // Use the GatewayMustHaveAddress timeout from the suite's TimeoutConfig for the Gateway object to appear + waitForGatewayCreationTimeout := opts.TimeoutConfig.GatewayMustHaveAddress + + logDebugf(t, opts.Debug, "Waiting up to %v for Gateway object %s/%s to appear after manifest application...", waitForGatewayCreationTimeout, gatewayNN.Namespace, gatewayNN.Name) + + ctx := context.TODO() + pollErr := wait.PollUntilContextTimeout(ctx, pollingInterval, waitForGatewayCreationTimeout, true, func(pollCtx context.Context) (bool, error) { + fetchErr := k8sClient.Get(pollCtx, gatewayNN, gw) + if fetchErr == nil { + t.Logf("Successfully fetched Gateway %s/%s. Spec.GatewayClassName: %s", + gw.Namespace, gw.Name, gw.Spec.GatewayClassName) + return true, nil + } + if apierrors.IsNotFound(fetchErr) { + logDebugf(t, opts.Debug, "Gateway %s/%s not found, still waiting...", gatewayNN.Namespace, gatewayNN.Name) + return false, nil // Not found, continue polling + } + // For any other error, stop polling and return this error + t.Logf("Error fetching Gateway %s/%s: %v. Halting polling for this attempt.", gatewayNN.Namespace, gatewayNN.Name, fetchErr) + return false, fetchErr + }) + + // Check if polling timed out or an error occurred during polling + if pollErr != nil { + var failureMessage string + if errors.Is(pollErr, context.DeadlineExceeded) { + failureMessage = fmt.Sprintf("Timed out after %v waiting for Gateway object %s/%s to appear in the API server.", + waitForGatewayCreationTimeout, gatewayNN.Namespace, gatewayNN.Name) + } else { + failureMessage = fmt.Sprintf("Error while waiting for Gateway object %s/%s to appear: %v.", + gatewayNN.Namespace, gatewayNN.Name, pollErr) + } + finalMessage := failureMessage + " The Gateway object should have been created by the base manifest application." + require.FailNow(t, finalMessage) // Use FailNow to stop if the Gateway isn't found. + } + + logDebugf(t, opts.Debug, "Waiting for shared Gateway %s/%s to be ready", gatewayNN.Namespace, gatewayNN.Name) + apikubernetes.GatewayMustHaveCondition(t, k8sClient, opts.TimeoutConfig, gatewayNN, metav1.Condition{ + Type: string(gatewayv1.GatewayConditionAccepted), + Status: metav1.ConditionTrue, + }) + apikubernetes.GatewayMustHaveCondition(t, k8sClient, opts.TimeoutConfig, gatewayNN, metav1.Condition{ + Type: string(gatewayv1.GatewayConditionProgrammed), + Status: metav1.ConditionTrue, + }) + _, err := apikubernetes.WaitForGatewayAddress(t, k8sClient, opts.TimeoutConfig, apikubernetes.NewGatewayRef(gatewayNN)) + require.NoErrorf(t, err, "shared gateway %s/%s did not get an address", gatewayNN.Namespace, gatewayNN.Name) + t.Logf("Shared Gateway %s/%s is ready.", gatewayNN.Namespace, gatewayNN.Name) +} + // writeReport writes the generated conformance report to the specified output file or logs it. // Adapted from the core Gateway API suite. func writeReport(logf func(string, ...any), report confapis.ConformanceReport, output string) error { diff --git a/conformance/embed.go b/conformance/embed.go index f7fa64c93..c9175db1d 100644 --- a/conformance/embed.go +++ b/conformance/embed.go @@ -21,5 +21,5 @@ import "embed" // Manifests embeds the contents of the conformance/resources directory making // the YAML files within them available to the test suite at runtime. // -//go:embed resources/* tests/* +//go:embed resources tests/* var Manifests embed.FS diff --git a/conformance/resources/manifests/manifests.yaml b/conformance/resources/manifests/manifests.yaml index 7b43b784f..190a8845e 100644 --- a/conformance/resources/manifests/manifests.yaml +++ b/conformance/resources/manifests/manifests.yaml @@ -22,15 +22,23 @@ metadata: labels: gateway-conformance: backend +--- +# Namespace for simple web server backends. This is expected by +# the upstream conformance suite's Setup method. +apiVersion: v1 +kind: Namespace +metadata: + name: gateway-conformance-web-backend + labels: + gateway-conformance: web-backend + --- # A basic Gateway resource that allows HTTPRoutes from the same namespace. # Tests can use this as a parent reference for routes that target InferencePools. -# Using a simple echo server instead of an actual model server to simplify the test -# execution, this design may need to be revised based on the test case needs. -apiVersion: gateway.networking.k8s.io/v1 # Using v1 as per latest Gateway API standard +apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: - name: same-namespace + name: conformance-gateway namespace: gateway-conformance-infra spec: # The conformance suite runner will replace this placeholder @@ -42,7 +50,7 @@ spec: protocol: HTTP allowedRoutes: namespaces: - from: Same # Restrict to same namespace initially for simplicity + from: All kinds: # Allows HTTPRoutes to attach, which can then reference InferencePools. - group: gateway.networking.k8s.io diff --git a/conformance/tests/basic/inferencepool_accepted.yaml b/conformance/tests/basic/inferencepool_accepted.yaml index 8ae327d8a..ecee5b3ca 100644 --- a/conformance/tests/basic/inferencepool_accepted.yaml +++ b/conformance/tests/basic/inferencepool_accepted.yaml @@ -1,8 +1,46 @@ # Basic InferencePool for acceptance testing. -# This manifest defines the minimal required fields to create a valid -# InferencePool resource, which the InferencePoolAccepted test will use -# to verify that the controller recognizes and accepts the resource. +# This manifest defines the minimal required fields to create valid +# InferencePool and HTTPRoute resources, which the InferencePoolAccepted +# test will use to verify that the controller recognizes and accepts the resource. +# --- Minimal Backend Deployment (using agnhost echo server) --- +# This Deployment provides Pods for the InferencePool to select. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: infra-backend-v1-deployment + namespace: gateway-conformance-app-backend + labels: + app: infra-backend-v1 +spec: + replicas: 1 + selector: + matchLabels: + app: infra-backend-v1 + template: + metadata: + labels: + app: infra-backend-v1 + spec: + containers: + - name: agnhost-echo + image: k8s.gcr.io/e2e-test-images/agnhost:2.39 + args: + - serve-hostname + - --http-port=8080 + ports: + - name: http + containerPort: 8080 + readinessProbe: + httpGet: + path: / + port: 8080 + initialDelaySeconds: 3 + periodSeconds: 5 + failureThreshold: 2 + +--- +# --- InferencePool Definition --- apiVersion: inference.networking.x-k8s.io/v1alpha2 kind: InferencePool metadata: @@ -18,10 +56,38 @@ spec: app: "infra-backend-v1" # --- Target Port (Required) --- - # The port the model server container listens on. - targetPortNumber: 3000 + # The port the model server container (agnhost in this case) listens on. + targetPortNumber: 8080 # Matches agnhost's http-port # --- Extension Reference --- # GKE-specific configuration reference. extensionRef: + # group: "" # Optional + # kind: Service # Optional name: infra-backend-v1-epp + +--- +# --- HTTPRoute Definition --- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: httproute-for-inferencepool-accepted + namespace: gateway-conformance-app-backend +spec: + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: conformance-gateway # Name of the shared Gateway from maniffests.yaml + namespace: gateway-conformance-infra # Namespace of the shared Gateway + sectionName: http + rules: + - backendRefs: + - group: inference.networking.x-k8s.io # InferencePool API group + kind: InferencePool + name: inferencepool-basic-accepted # Name of the InferencePool this route points to + # namespace: gateway-conformance-app-backend - is omitted since it is in the same namespace as HTTPRoute + port: 8080 # Matching the InferencePool's targetPortNumber + matches: + - path: + type: PathPrefix + value: /accepted-pool-test diff --git a/conformance/utils/kubernetes/helpers.go b/conformance/utils/kubernetes/helpers.go index 3d517863d..af7d5a2a4 100644 --- a/conformance/utils/kubernetes/helpers.go +++ b/conformance/utils/kubernetes/helpers.go @@ -19,31 +19,134 @@ limitations under the License. package kubernetes import ( + "context" + "fmt" + "reflect" "testing" + "time" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/wait" "sigs.k8s.io/controller-runtime/pkg/client" + // Import the Inference Extension API types + inferenceapi "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" // Adjust if your API version is different + // Import necessary utilities from the core Gateway API conformance suite "sigs.k8s.io/gateway-api/conformance/utils/config" ) +// checkCondition is a helper function similar to findConditionInList or CheckCondition +// from the Gateway API conformance utilities. +// It checks if the expectedCondition is present in the conditions list. +// If expectedCondition.Reason is an empty string, it matches any reason. +func checkCondition(t *testing.T, conditions []metav1.Condition, expectedCondition metav1.Condition) bool { + t.Helper() + for _, cond := range conditions { + if cond.Type == expectedCondition.Type { + if cond.Status == expectedCondition.Status { + if expectedCondition.Reason == "" || cond.Reason == expectedCondition.Reason { + return true + } + t.Logf("Condition %s found with Status %s, but Reason %s did not match expected %s", + expectedCondition.Type, cond.Status, cond.Reason, expectedCondition.Reason) + } else { + t.Logf("Condition %s found, but Status %s did not match expected %s", + expectedCondition.Type, cond.Status, expectedCondition.Status) + } + } + } + t.Logf("Condition %s with Status %s (and Reason %s if specified) not found in conditions list: %+v", + expectedCondition.Type, expectedCondition.Status, expectedCondition.Reason, conditions) + return false +} + // InferencePoolMustHaveCondition waits for the specified InferencePool resource -// to exist and report the expected status condition. -// This is a placeholder and needs full implementation. -// -// TODO: Implement the actual logic for this helper function. -// It should fetch the InferencePool using the provided client and check its -// Status.Conditions field, polling until the condition is met or a timeout occurs. -// like HTTPRouteMustHaveCondition. +// to exist and report the expected status condition within one of its parent statuses. +// It polls the InferencePool's status until the condition is met or the timeout occurs. func InferencePoolMustHaveCondition(t *testing.T, c client.Client, timeoutConfig config.TimeoutConfig, poolNN types.NamespacedName, expectedCondition metav1.Condition) { t.Helper() // Marks this function as a test helper - // Placeholder implementation: Log and skip the check. - t.Logf("Verification for InferencePool condition (%s=%s) on %s - Placeholder: Skipping check.", - expectedCondition.Type, expectedCondition.Status, poolNN.String()) + var lastObservedPool *inferenceapi.InferencePool + var lastError error + var conditionFound bool + var interval time.Duration = 5 * time.Second // pull interval for status checks. + + // TODO: Make retry interval configurable. + waitErr := wait.PollUntilContextTimeout(context.Background(), interval, timeoutConfig.DefaultTestTimeout, true, func(ctx context.Context) (bool, error) { + pool := &inferenceapi.InferencePool{} // This is the type instance used for Get + err := c.Get(ctx, poolNN, pool) + if err != nil { + if apierrors.IsNotFound(err) { + t.Logf("InferencePool %s not found yet. Retrying.", poolNN.String()) + lastError = err + return false, nil + } + t.Logf("Error fetching InferencePool %s (type: %s): %v. Retrying.", poolNN.String(), reflect.TypeOf(pool).String(), err) + lastError = err + return false, nil + } + lastObservedPool = pool + lastError = nil + conditionFound = false + + if len(pool.Status.Parents) == 0 { + t.Logf("InferencePool %s has no parent statuses reported yet.", poolNN.String()) + return false, nil + } + + for _, parentStatus := range pool.Status.Parents { + if checkCondition(t, parentStatus.Conditions, expectedCondition) { + conditionFound = true + return true, nil + } + } + return false, nil + }) + + if waitErr != nil || !conditionFound { + debugMsg := "" + if waitErr != nil { + debugMsg += fmt.Sprintf(" Polling error: %v.", waitErr) + } + if lastError != nil { + debugMsg += fmt.Sprintf(" Last error during fetching: %v.", lastError) + } + + if lastObservedPool != nil { + debugMsg += "\nLast observed InferencePool status:" + if len(lastObservedPool.Status.Parents) == 0 { + debugMsg += " (No parent statuses reported)" + } + for i, parentStatus := range lastObservedPool.Status.Parents { + debugMsg += fmt.Sprintf("\n Parent %d (Gateway: %s/%s):", i, parentStatus.GatewayRef.Namespace, parentStatus.GatewayRef.Name) + if len(parentStatus.Conditions) == 0 { + debugMsg += " (No conditions reported for this parent)" + } + for _, cond := range parentStatus.Conditions { + debugMsg += fmt.Sprintf("\n - Type: %s, Status: %s, Reason: %s, Message: %s", cond.Type, cond.Status, cond.Reason, cond.Message) + } + } + } else if lastError == nil || !apierrors.IsNotFound(lastError) { + debugMsg += "\nInferencePool was not found or not observed successfully during polling." + } + + finalMsg := fmt.Sprintf("timed out or condition not met for InferencePool %s to have condition Type=%s, Status=%s", + poolNN.String(), expectedCondition.Type, expectedCondition.Status) + if expectedCondition.Reason != "" { + finalMsg += fmt.Sprintf(", Reason='%s'", expectedCondition.Reason) + } + finalMsg += "." + debugMsg + require.FailNow(t, finalMsg) + } - // Skip the test using this helper until it's fully implemented. - t.Skip("InferencePoolMustHaveCondition helper not yet implemented") + logMsg := fmt.Sprintf("InferencePool %s successfully has condition Type=%s, Status=%s", + poolNN.String(), expectedCondition.Type, expectedCondition.Status) + if expectedCondition.Reason != "" { + logMsg += fmt.Sprintf(", Reason='%s'", expectedCondition.Reason) + } + t.Log(logMsg) } From 10ec261b50f003a73e6cc58a15d455da9da504b6 Mon Sep 17 00:00:00 2001 From: Cong Liu Date: Sat, 10 May 2025 11:35:14 -0700 Subject: [PATCH 10/53] Add prefix cache aware scheduling (#768) * Add prefix cache aware scheduling * Replace scheduler v2 with config v2 * Add score weight to XXScorerConfig * Address comments * Clean up * Change to use container/list lib * cleanup * Add TODO * make linter happy --- cmd/epp/main.go | 42 ++++ pkg/epp/metrics/metrics.go | 56 +++++ pkg/epp/metrics/metrics_test.go | 103 +++++++++ .../testdata/prefix_indexer_hit_bytes_metric | 19 ++ .../testdata/prefix_indexer_hit_ratio_metric | 16 ++ .../testdata/prefix_indexer_size_metric | 3 + pkg/epp/scheduling/config.go | 22 +- pkg/epp/scheduling/plugins/prefix/indexer.go | 173 +++++++++++++++ .../scheduling/plugins/prefix/indexer_test.go | 45 ++++ pkg/epp/scheduling/plugins/prefix/plugin.go | 204 ++++++++++++++++++ .../scheduling/plugins/prefix/plugin_test.go | 137 ++++++++++++ pkg/epp/scheduling/plugins/scorer/kvcache.go | 4 + pkg/epp/scheduling/plugins/scorer/queue.go | 4 + pkg/epp/scheduling/types/types.go | 29 ++- 14 files changed, 851 insertions(+), 6 deletions(-) create mode 100644 pkg/epp/metrics/testdata/prefix_indexer_hit_bytes_metric create mode 100644 pkg/epp/metrics/testdata/prefix_indexer_hit_ratio_metric create mode 100644 pkg/epp/metrics/testdata/prefix_indexer_size_metric create mode 100644 pkg/epp/scheduling/plugins/prefix/indexer.go create mode 100644 pkg/epp/scheduling/plugins/prefix/indexer_test.go create mode 100644 pkg/epp/scheduling/plugins/prefix/plugin.go create mode 100644 pkg/epp/scheduling/plugins/prefix/plugin_test.go diff --git a/cmd/epp/main.go b/cmd/epp/main.go index 9fd401d4e..e674f1c20 100644 --- a/cmd/epp/main.go +++ b/cmd/epp/main.go @@ -34,6 +34,7 @@ import ( "k8s.io/client-go/rest" "k8s.io/component-base/metrics/legacyregistry" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/metrics/filters" @@ -43,7 +44,13 @@ import ( "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics/collectors" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/filter" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/picker" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/prefix" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/scorer" runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" + envutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/env" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) @@ -107,8 +114,22 @@ var ( "Prometheus metric for the LoRA info metrics (must be in vLLM label format).") setupLog = ctrl.Log.WithName("setup") + + // Environment variables + schedulerV2 = envutil.GetEnvString("EXPERIMENTAL_USE_SCHEDULER_V2", "false", setupLog) + prefixCacheScheduling = envutil.GetEnvString("ENABLE_PREFIX_CACHE_SCHEDULING", "false", setupLog) ) +func loadPrefixCacheConfig() prefix.Config { + baseLogger := log.Log.WithName("env-config") + + return prefix.Config{ + HashBlockSize: envutil.GetEnvInt("PREFIX_CACHE_HASH_BLOCK_SIZE", prefix.DefaultHashBlockSize, baseLogger), + MaxPrefixBlocksToMatch: envutil.GetEnvInt("PREFIX_CACHE_MAX_PREFIX_BLOCKS", prefix.DefaultMaxPrefixBlocks, baseLogger), + LRUIndexerCapacity: envutil.GetEnvInt("PREFIX_CACHE_LRU_CAPACITY", prefix.DefaultLRUIndexerCapacity, baseLogger), + } +} + func main() { if err := run(); err != nil { os.Exit(1) @@ -172,6 +193,27 @@ func run() error { datastore := datastore.NewDatastore(ctx, pmf) scheduler := scheduling.NewScheduler(datastore) + if schedulerV2 == "true" { + queueScorerWeight := envutil.GetEnvInt("QUEUE_SCORE_WEIGHT", scorer.DefaultQueueScorerWeight, setupLog) + kvCacheScorerWeight := envutil.GetEnvInt("KV_CACHE_SCORE_WEIGHT", scorer.DefaultKVCacheScorerWeight, setupLog) + scorers := map[plugins.Scorer]int{ + &scorer.QueueScorer{}: queueScorerWeight, + &scorer.KVCacheScorer{}: kvCacheScorerWeight, + } + schedConfigOpts := []scheduling.ConfigOption{} + if prefixCacheScheduling == "true" { + prefixScorerWeight := envutil.GetEnvInt("PREFIX_CACHE_SCORE_WEIGHT", prefix.DefaultScorerWeight, setupLog) + schedConfigOpts = append(schedConfigOpts, scheduling.AddPrefixPlugin(loadPrefixCacheConfig(), prefixScorerWeight)) + } + schedulerConfig := scheduling.NewSchedulerConfig( + []plugins.PreSchedule{}, + []plugins.Filter{filter.NewSheddableCapacityFilter()}, + scorers, + picker.NewMaxScorePicker(), + []plugins.PostSchedule{}, + schedConfigOpts...) + scheduler = scheduling.NewSchedulerWithConfig(datastore, schedulerConfig) + } serverRunner := &runserver.ExtProcServerRunner{ GrpcPort: *grpcPort, DestinationEndpointHintMetadataNamespace: *destinationEndpointHintMetadataNamespace, diff --git a/pkg/epp/metrics/metrics.go b/pkg/epp/metrics/metrics.go index 6cc0cdb83..84f0f1f9a 100644 --- a/pkg/epp/metrics/metrics.go +++ b/pkg/epp/metrics/metrics.go @@ -209,6 +209,40 @@ var ( []string{"plugin_type", "plugin_name"}, ) + // Prefix indexer Metrics + PrefixCacheSize = compbasemetrics.NewGaugeVec( + &compbasemetrics.GaugeOpts{ + Subsystem: InferenceExtension, + Name: "prefix_indexer_size", + Help: "Size of the prefix indexer.", + StabilityLevel: compbasemetrics.ALPHA, + }, + []string{}, + ) + + PrefixCacheHitRatio = compbasemetrics.NewHistogramVec( + &compbasemetrics.HistogramOpts{ + Subsystem: InferenceExtension, + Name: "prefix_indexer_hit_ratio", + Help: "Ratio of prefix length matched to total prefix length in the cache lookup.", + // Buckets from 0.0 to 1.0 in increments + Buckets: []float64{0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0}, + StabilityLevel: compbasemetrics.ALPHA, + }, + []string{}, + ) + + PrefixCacheHitLength = compbasemetrics.NewHistogramVec( + &compbasemetrics.HistogramOpts{ + Subsystem: InferenceExtension, + Name: "prefix_indexer_hit_bytes", + Help: "Length of the prefix match in number of bytes in the cache lookup.", + Buckets: []float64{0, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536}, + StabilityLevel: compbasemetrics.ALPHA, + }, + []string{}, + ) + // Info Metrics InferenceExtensionInfo = compbasemetrics.NewGaugeVec( &compbasemetrics.GaugeOpts{ @@ -244,6 +278,10 @@ func Register() { legacyregistry.MustRegister(SchedulerE2ELatency) legacyregistry.MustRegister(InferenceExtensionInfo) + + legacyregistry.MustRegister(PrefixCacheSize) + legacyregistry.MustRegister(PrefixCacheHitRatio) + legacyregistry.MustRegister(PrefixCacheHitLength) }) } @@ -352,6 +390,24 @@ func RecordSchedulerE2ELatency(duration time.Duration) { SchedulerE2ELatency.WithLabelValues().Observe(duration.Seconds()) } +// RecordPrefixCacheSize records the size of the prefix indexer in megabytes. +func RecordPrefixCacheSize(size int64) { + PrefixCacheSize.WithLabelValues().Set(float64(size)) +} + +// RecordPrefixCacheMatch records both the hit ratio and hit length for a prefix indexer match. +// matchedLength is the number of characters that matched, and totalLength is the total prefix length. +func RecordPrefixCacheMatch(matchedLength, totalLength int) { + // Record the hit length metric + PrefixCacheHitLength.WithLabelValues().Observe(float64(matchedLength)) + + // Record the hit ratio metric if totalLength is positive + if totalLength > 0 { + ratio := float64(matchedLength) / float64(totalLength) + PrefixCacheHitRatio.WithLabelValues().Observe(ratio) + } +} + func RecordInferenceExtensionInfo() { if CommitSHA != "" { InferenceExtensionInfo.WithLabelValues(CommitSHA).Set(1) diff --git a/pkg/epp/metrics/metrics_test.go b/pkg/epp/metrics/metrics_test.go index 3a8136a08..4ad6f96e1 100644 --- a/pkg/epp/metrics/metrics_test.go +++ b/pkg/epp/metrics/metrics_test.go @@ -664,3 +664,106 @@ func TestSchedulerE2ELatency(t *testing.T) { }) } } + +func TestPrefixCacheMetrics(t *testing.T) { + const ( + PrefixCacheSizeMetric = InferenceExtension + "_prefix_indexer_size" + PrefixCacheHitRatioMetric = InferenceExtension + "_prefix_indexer_hit_ratio" + PrefixCacheHitLengthMetric = InferenceExtension + "_prefix_indexer_hit_bytes" + ) + + type cacheMatchRecord struct { + matchedLength int + totalLength int + } + + scenario := struct { + name string + cacheSizes []int64 + cacheMatches []cacheMatchRecord + }{ + name: "multiple cache metrics", + cacheSizes: []int64{1024, 2048, 4096}, + cacheMatches: []cacheMatchRecord{ + { + matchedLength: 5, + totalLength: 10, + }, + { + matchedLength: 0, + totalLength: 10, + }, + { + matchedLength: 10, + totalLength: 10, + }, + { + matchedLength: 7, + totalLength: 10, + }, + { + matchedLength: 64, + totalLength: 128, + }, + { + matchedLength: 0, + totalLength: 128, + }, + }, + } + + Register() + t.Run(scenario.name, func(t *testing.T) { + // Record cache size metrics + for _, size := range scenario.cacheSizes { + RecordPrefixCacheSize(size) + } + + // Record cache match metrics (both hit ratio and hit length) + for _, match := range scenario.cacheMatches { + RecordPrefixCacheMatch(match.matchedLength, match.totalLength) + } + + // Verify cache size metrics + wantCacheSizeMetrics, err := os.Open("testdata/prefix_indexer_size_metric") + defer func() { + if err := wantCacheSizeMetrics.Close(); err != nil { + t.Error(err) + } + }() + if err != nil { + t.Fatal(err) + } + if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, wantCacheSizeMetrics, PrefixCacheSizeMetric); err != nil { + t.Error(err) + } + + // Verify hit ratio metrics + wantHitRatioMetrics, err := os.Open("testdata/prefix_indexer_hit_ratio_metric") + defer func() { + if err := wantHitRatioMetrics.Close(); err != nil { + t.Error(err) + } + }() + if err != nil { + t.Fatal(err) + } + if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, wantHitRatioMetrics, PrefixCacheHitRatioMetric); err != nil { + t.Error(err) + } + + // Verify hit length metrics + wantHitLengthMetrics, err := os.Open("testdata/prefix_indexer_hit_bytes_metric") + defer func() { + if err := wantHitLengthMetrics.Close(); err != nil { + t.Error(err) + } + }() + if err != nil { + t.Fatal(err) + } + if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, wantHitLengthMetrics, PrefixCacheHitLengthMetric); err != nil { + t.Error(err) + } + }) +} diff --git a/pkg/epp/metrics/testdata/prefix_indexer_hit_bytes_metric b/pkg/epp/metrics/testdata/prefix_indexer_hit_bytes_metric new file mode 100644 index 000000000..86b48724e --- /dev/null +++ b/pkg/epp/metrics/testdata/prefix_indexer_hit_bytes_metric @@ -0,0 +1,19 @@ +# HELP inference_extension_prefix_indexer_hit_bytes [ALPHA] Length of the prefix match in number of bytes in the cache lookup. +# TYPE inference_extension_prefix_indexer_hit_bytes histogram +inference_extension_prefix_indexer_hit_bytes_bucket{le="0"} 2 +inference_extension_prefix_indexer_hit_bytes_bucket{le="16"} 5 +inference_extension_prefix_indexer_hit_bytes_bucket{le="32"} 5 +inference_extension_prefix_indexer_hit_bytes_bucket{le="64"} 6 +inference_extension_prefix_indexer_hit_bytes_bucket{le="128"} 6 +inference_extension_prefix_indexer_hit_bytes_bucket{le="256"} 6 +inference_extension_prefix_indexer_hit_bytes_bucket{le="512"} 6 +inference_extension_prefix_indexer_hit_bytes_bucket{le="1024"} 6 +inference_extension_prefix_indexer_hit_bytes_bucket{le="2048"} 6 +inference_extension_prefix_indexer_hit_bytes_bucket{le="4096"} 6 +inference_extension_prefix_indexer_hit_bytes_bucket{le="8192"} 6 +inference_extension_prefix_indexer_hit_bytes_bucket{le="16384"} 6 +inference_extension_prefix_indexer_hit_bytes_bucket{le="32768"} 6 +inference_extension_prefix_indexer_hit_bytes_bucket{le="65536"} 6 +inference_extension_prefix_indexer_hit_bytes_bucket{le="+Inf"} 6 +inference_extension_prefix_indexer_hit_bytes_sum 86 +inference_extension_prefix_indexer_hit_bytes_count 6 diff --git a/pkg/epp/metrics/testdata/prefix_indexer_hit_ratio_metric b/pkg/epp/metrics/testdata/prefix_indexer_hit_ratio_metric new file mode 100644 index 000000000..e94827cb6 --- /dev/null +++ b/pkg/epp/metrics/testdata/prefix_indexer_hit_ratio_metric @@ -0,0 +1,16 @@ +# HELP inference_extension_prefix_indexer_hit_ratio [ALPHA] Ratio of prefix length matched to total prefix length in the cache lookup. +# TYPE inference_extension_prefix_indexer_hit_ratio histogram +inference_extension_prefix_indexer_hit_ratio_bucket{le="0"} 2 +inference_extension_prefix_indexer_hit_ratio_bucket{le="0.1"} 2 +inference_extension_prefix_indexer_hit_ratio_bucket{le="0.2"} 2 +inference_extension_prefix_indexer_hit_ratio_bucket{le="0.3"} 2 +inference_extension_prefix_indexer_hit_ratio_bucket{le="0.4"} 2 +inference_extension_prefix_indexer_hit_ratio_bucket{le="0.5"} 4 +inference_extension_prefix_indexer_hit_ratio_bucket{le="0.6"} 4 +inference_extension_prefix_indexer_hit_ratio_bucket{le="0.7"} 5 +inference_extension_prefix_indexer_hit_ratio_bucket{le="0.8"} 5 +inference_extension_prefix_indexer_hit_ratio_bucket{le="0.9"} 5 +inference_extension_prefix_indexer_hit_ratio_bucket{le="1"} 6 +inference_extension_prefix_indexer_hit_ratio_bucket{le="+Inf"} 6 +inference_extension_prefix_indexer_hit_ratio_sum 2.7 +inference_extension_prefix_indexer_hit_ratio_count 6 diff --git a/pkg/epp/metrics/testdata/prefix_indexer_size_metric b/pkg/epp/metrics/testdata/prefix_indexer_size_metric new file mode 100644 index 000000000..9799b1729 --- /dev/null +++ b/pkg/epp/metrics/testdata/prefix_indexer_size_metric @@ -0,0 +1,3 @@ +# HELP inference_extension_prefix_indexer_size [ALPHA] Size of the prefix indexer. +# TYPE inference_extension_prefix_indexer_size gauge +inference_extension_prefix_indexer_size{} 4096 diff --git a/pkg/epp/scheduling/config.go b/pkg/epp/scheduling/config.go index a4f4c2950..e321ca2bf 100644 --- a/pkg/epp/scheduling/config.go +++ b/pkg/epp/scheduling/config.go @@ -18,18 +18,23 @@ package scheduling import ( "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/prefix" ) // NewSchedulerConfig creates a new SchedulerConfig object with the given plugins. func NewSchedulerConfig(preSchedulePlugins []plugins.PreSchedule, filters []plugins.Filter, scorers map[plugins.Scorer]int, - picker plugins.Picker, postSchedulePlugins []plugins.PostSchedule) *SchedulerConfig { - return &SchedulerConfig{ + picker plugins.Picker, postSchedulePlugins []plugins.PostSchedule, opts ...ConfigOption) *SchedulerConfig { + config := &SchedulerConfig{ preSchedulePlugins: preSchedulePlugins, filters: filters, scorers: scorers, picker: picker, postSchedulePlugins: postSchedulePlugins, } + for _, opt := range opts { + opt(config) + } + return config } // SchedulerConfig provides a configuration for the scheduler which influence routing decisions. @@ -40,3 +45,16 @@ type SchedulerConfig struct { picker plugins.Picker postSchedulePlugins []plugins.PostSchedule } + +type ConfigOption func(*SchedulerConfig) + +// TODO(https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/813): Replace this +// with a more generic way to add plugins. +func AddPrefixPlugin(prefixConfig prefix.Config, weight int) ConfigOption { + return func(cfg *SchedulerConfig) { + prefixPlugin := prefix.New(prefixConfig) + cfg.preSchedulePlugins = append(cfg.preSchedulePlugins, prefixPlugin) + cfg.postSchedulePlugins = append(cfg.postSchedulePlugins, prefixPlugin) + cfg.scorers[prefixPlugin] = weight + } +} diff --git a/pkg/epp/scheduling/plugins/prefix/indexer.go b/pkg/epp/scheduling/plugins/prefix/indexer.go new file mode 100644 index 000000000..2017ba175 --- /dev/null +++ b/pkg/epp/scheduling/plugins/prefix/indexer.go @@ -0,0 +1,173 @@ +/* +Copyright 2025 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 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 prefix + +import ( + "context" + "sync" + "time" + "unsafe" + + "container/list" + + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" +) + +func newIndexer(maxCacheSize int) *indexer { + t := &indexer{ + maxCacheSize: maxCacheSize, + table: make(map[BlockHash]map[ServerID]*list.Element), + ll: list.New(), + } + go t.ReportCacheSize(time.Second) + return t +} + +// An indexer maintains an LRU cache of prompt prefix hashes and the server(s) that might have that +// prefix cached . +type indexer struct { + mu sync.RWMutex + maxCacheSize int + table map[BlockHash]map[ServerID]*list.Element // from any prefix cache to the cache entry to find the server + ll *list.List // LinkedList to keep track of the order of entries +} + +// value is the value stored in the linked list. +type value struct { + server ServerID + hash BlockHash +} + +// Get returns the set of servers that have the given prefix hash cached. +func (i *indexer) Get(hash BlockHash) map[ServerID]bool { + i.mu.RLock() + defer i.mu.RUnlock() + res := map[ServerID]bool{} + for server := range i.table[hash] { + res[server] = true + } + return res +} + +// Add adds a list of prefix hashes of a single request to the server the request was sent to. +// The intuition is that this server is likely to have the prefix cached, so next time a request +// sharing the longest prefix should be sent to the same server to take advantage of the cache hit. +func (i *indexer) Add(hashes []BlockHash, server ServerID) { + i.mu.Lock() + defer i.mu.Unlock() + for _, hash := range hashes { + i.add(hash, server) + } +} + +func (i *indexer) check(hash BlockHash, server ServerID) (*list.Element, bool) { + servers, ok := i.table[hash] + if !ok { + return nil, false + } + e, ok := servers[server] + return e, ok +} + +func (i *indexer) add(hash BlockHash, server ServerID) { + e, exists := i.check(hash, server) + if exists { + i.ll.MoveToBack(e) + } else { + i.create(hash, server) + } +} + +func (i *indexer) create(hash BlockHash, server ServerID) { + for i.ll.Len() >= i.maxCacheSize { + // Evict the least recently used entry if we've exceeded the max cache size + i.evict() + } + + if _, ok := i.table[hash]; !ok { + i.table[hash] = make(map[ServerID]*list.Element) + } + v := &value{ + server: server, + hash: hash, + } + e := i.ll.PushBack(v) + i.table[hash][server] = e +} + +// evict removes the least recently used entry from the cache +func (i *indexer) evict() { + oldestNode := i.ll.Front() + if oldestNode == nil { + return + } + i.ll.Remove(oldestNode) + + v := oldestNode.Value.(*value) + hash := v.hash + server := v.server + // Remove from the hash map + serverMap := i.table[hash] + delete(serverMap, server) + + // If this was the last server for this hash, remove the hash entry entirely + if len(serverMap) == 0 { + delete(i.table, hash) + } + + log.FromContext(context.TODO()).V(logutil.TRACE).Info("Evicted LRU entry", "hash", hash, "server", server) +} + +// ReportCacheSize starts a goroutine that periodically reports the cache size metric +func (i *indexer) ReportCacheSize(interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + i.mu.RLock() + metrics.RecordPrefixCacheSize(int64(i.ll.Len())) + log.FromContext(context.TODO()).V(logutil.TRACE).Info("LRU", "# entries", i.ll.Len(), "estimated size MB", i.ll.Len()*i.estimateEntrySize()/1000000) + i.mu.RUnlock() + } +} + +// estimateEntrySize estimates the memory size of a cache entry in bytes. +func (i *indexer) estimateEntrySize() int { + size := 0 + + // Estimate the size of a node in the linked list. + // First get the size of the node struct via unsafe.Sizeof. + // The prev and next pointers are 8 bytes each on a 64-bit system. + // The BlockHash is a uint64, which is 8 bytes. + // The ServerID is a NamespacedName, which contains two strings (Name and Namespace). + // The headers for the strings are 16 bytes each (8 bytes for the pointer and 8 bytes for the length). + // So unsafe.Sizeof(node{}) should return 2*8 + 8 + 2*16 = 48 bytes. + size += int(unsafe.Sizeof(value{})) + // Size of the Name and Namespace strings in ServerID, assuming 63 bytes each (max length for Kubernetes NamespacedName). + size += 2 * 63 + + // Estimate the size of an entry in the hash map. Note the overhead of the map headers and buckets are ignored. + size += 8 // Size of the BlockHash (uint64). + size += 2 * 16 // Size of the ServerID string headers (NamespacedName). + size += 2 * 63 // Size of the Name and Namespace strings in ServerID. + size += 8 // Size of the pointer to the node in the hash map. + + // Based on the above estimates, the estimated size of an entry is: + // (48 + 2*63) + (8 + 2*16 + 2*63 + 8) = 348 bytes. + return size +} diff --git a/pkg/epp/scheduling/plugins/prefix/indexer_test.go b/pkg/epp/scheduling/plugins/prefix/indexer_test.go new file mode 100644 index 000000000..596625d10 --- /dev/null +++ b/pkg/epp/scheduling/plugins/prefix/indexer_test.go @@ -0,0 +1,45 @@ +/* +Copyright 2025 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 prefix + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIndexer_AddAndGet(t *testing.T) { + cache := newIndexer(2) + + hash1 := BlockHash(1) + server := ServerID{Namespace: "default", Name: "server1"} + + // Add an entry to the cache + cache.Add([]BlockHash{hash1}, server) + + // Retrieve the entry + assert.Equal(t, 1, cache.ll.Len(), "Cache size should be 1 after adding an entry") + servers := cache.Get(hash1) + assert.Contains(t, servers, server, "Cache should contain the added server") + + // Add another entry to the cache, the cache size should be incremented to 2. + cache.Add([]BlockHash{BlockHash(2)}, server) + assert.Equal(t, 2, cache.ll.Len(), "Cache size should be 2 after adding an entry") + + // Add another entry to the cache, which should evict the first one due to max size. + cache.Add([]BlockHash{BlockHash(3)}, server) + assert.Equal(t, 2, cache.ll.Len(), "Cache size should still be 2 after adding an entry") +} diff --git a/pkg/epp/scheduling/plugins/prefix/plugin.go b/pkg/epp/scheduling/plugins/prefix/plugin.go new file mode 100644 index 000000000..6d7f03c10 --- /dev/null +++ b/pkg/epp/scheduling/plugins/prefix/plugin.go @@ -0,0 +1,204 @@ +/* +Copyright 2025 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 prefix + +import ( + "encoding/binary" + "fmt" + + "github.com/cespare/xxhash/v2" + k8stypes "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" +) + +const ( + DefaultScorerWeight = 1 + // Attempt to return DefaultNumServersToMatch servers with their longest prefix match length. + // Why not just return the server with longest prefix match? + // It may not be the optimal choice, e.g., it may have a high queue depth. + // We optimistically search more than one to give more candidates for the scheduler to choose. + DefaultNumServersToMatch = 2 + // vLLM default token block size is 16, and a good guess of average characters per token is 4. + DefaultHashBlockSize = 64 + // The maximum number of blocks to match. Two long requests with the same prefix up to this + // limit will be indistinguishable. + // This parameter provides a trade-off between cache size, prefix matching speed and matching + // accuracy. Use a small value if most requests are short to reduce cache size and speed up the + // matching process. Use a large value if most requests are long to increase the matching accuracy. + DefaultMaxPrefixBlocks = 128 + // The indexer is an approximation to the actual prefix cache state on the model servers. + // A small capacity ensures a high accuracy of cache hit on the model server, but it will + // increase the chance of false negatives. A high capacity does the opposite. + // To properly size this, consider the sum of the total number of cache entries on all model + // servers. Consider the llama3 8B model on 3 H100 80GB GPUs. The size of the model weight is + // about 16GB. Assume 50% of the remaining HBM is used for caching prefixes, we have 32GB. Each + // token is about 128KB in size, so we can cache 250K tokens. Using the default block size of 16 + // in vLLM, we will have 250K / 16 = 15.6K blocks. In total we have 15.6K * 3 = 46.8K blocks, or + // roughly 50K. + // How much memory space does it require to hold the 50K block hashes? + // According to the estimates in indexer.estimateEntrySize(), the size of each entry is + // approximately 348 bytes. So in total we have 50K * 348 = 17.4MB. + DefaultLRUIndexerCapacity = 50000 +) + +type Config struct { + // The input prompt is broken into sizes of HashBlockSize to calculate block hashes . Requests + // with length shorter than the block size will be ignored. + HashBlockSize int + // MaxPrefixBlocksToMatch is the maximum number of prefix blocks to match. Input beyond this limit will + // be ignored. + MaxPrefixBlocksToMatch int + // Max (approximate) size of the LRU indexer in number of entries. + LRUIndexerCapacity int +} + +type Plugin struct { + Config + indexer Indexer +} + +type Indexer interface { + Get(hash BlockHash) map[ServerID]bool + Add(hashes []BlockHash, server ServerID) +} + +// This is the state of this plugin to be used during a scheduling cycle. +type SchedulingContextState struct { + // PrefixHashes is a list of prefix hashes of the request prompt broken into blocks. + PrefixHashes []BlockHash + // A map of server to its longest prefix cache match length. + PrefixCacheServers map[ServerID]int +} + +// BlockHash is a hash of the block of request body. +type BlockHash uint64 + +type ServerID k8stypes.NamespacedName + +func (s ServerID) String() string { + return k8stypes.NamespacedName(s).String() +} + +func New(config Config) *Plugin { + m := &Plugin{ + Config: config, + indexer: newIndexer(config.LRUIndexerCapacity), + } + return m +} + +func (m *Plugin) Name() string { + return "prefixCache" +} + +func (m *Plugin) PreSchedule(ctx *types.SchedulingContext) { + hashes := hashPrompt(ctx, m.HashBlockSize, m.MaxPrefixBlocksToMatch) + state := SchedulingContextState{ + PrefixHashes: hashes, + PrefixCacheServers: m.matchLongestPrefix(ctx, hashes, DefaultNumServersToMatch), + } + ctx.SetPluginState(types.PluginName(m.Name()), state) + ctx.Logger.V(logutil.DEBUG).Info(fmt.Sprintf("PreSchedule, cached servers: %+v", state.PrefixCacheServers), "hashes", state.PrefixHashes) +} + +// If a request was routed to a server, record it in the cache: +func (m *Plugin) PostSchedule(ctx *types.SchedulingContext, res *types.Result) { + targetPod := res.TargetPod.GetPod() + state := ctx.GetPluginState(types.PluginName(m.Name())).(SchedulingContextState) + m.indexer.Add(state.PrefixHashes, ServerID(targetPod.NamespacedName)) + total := len(state.PrefixHashes) + matchLen := state.PrefixCacheServers[ServerID(targetPod.NamespacedName)] + metrics.RecordPrefixCacheMatch(matchLen*m.HashBlockSize, total*m.HashBlockSize) +} + +func (m *Plugin) Score(ctx *types.SchedulingContext, pods []types.Pod) map[types.Pod]float64 { + state := ctx.GetPluginState(types.PluginName(m.Name())).(SchedulingContextState) + total := len(state.PrefixHashes) + podScoreFunc := func(pod types.Pod) float64 { + if total == 0 { + return 0 + } + matchLen := state.PrefixCacheServers[ServerID(pod.GetPod().NamespacedName)] + return float64(matchLen) / float64(total) + } + + scores := make(map[types.Pod]float64, len(pods)) + for _, pod := range pods { + scores[pod] = podScoreFunc(pod) + } + return scores +} + +// matchLongestPrefix returns a map of servers and length of prefix that each server caches. +func (m *Plugin) matchLongestPrefix(ctx *types.SchedulingContext, hashes []BlockHash, numServers int) map[ServerID]int { + if numServers > len(ctx.PodsSnapshot) { + numServers = len(ctx.PodsSnapshot) + } + res := make(map[ServerID]int) + // Use a greedy strategy to search from the longest prefix. + // NOTE: It's possible to further optimize this with a binary search. + for i := len(hashes) - 1; i >= 0 && len(res) < numServers; i-- { + hash := hashes[i] + cachedServers := m.indexer.Get(hash) + if len(cachedServers) > 0 { + ctx.Logger.V(logutil.DEBUG).Info("Found cached servers", "cachedServers", cachedServers, "total # blocks", len(hashes), "longest prefix", i) + for server := range cachedServers { + // Update servers with their longest prefix match. + // If we already found this server with longer prefix match, don't update it. + if _, ok := res[server]; !ok { + res[server] = i + 1 + } + } + } + } + return res +} + +// hashPrompt divides the prompt into blocks and calculate the prefix cache for each block. +// hash(0) is the hash of the model name, since different models generally don't share prefix cache. +// For block i, hash(i) = hash(block i content, hash(i-1)). +func hashPrompt(ctx *types.SchedulingContext, cacheBlockSize int, maxPrefixBlocks int) []BlockHash { + prompt := []byte(ctx.Req.Prompt) + if len(prompt) < cacheBlockSize { + ctx.Logger.V(logutil.DEBUG).Info("Request body too small for prefix cache", "size", len(prompt), "block size", cacheBlockSize) + return nil + } + if len(prompt) > cacheBlockSize*maxPrefixBlocks { + ctx.Logger.V(logutil.DEBUG).Info("Truncating input", "size", len(prompt), "max prefix blocks", maxPrefixBlocks, "block size", cacheBlockSize) + prompt = prompt[:maxPrefixBlocks*cacheBlockSize] + } + // Split the body into blocks of size cacheBlockSize. The +1 is to account for the model. + // If the last block is smaller than cacheBlockSize, it will be ignored. + res := make([]BlockHash, 0, 1+len(prompt)/cacheBlockSize) + // Add the model to the first block hash so that different models have different hashes even with the same body. + res = append(res, BlockHash(xxhash.Sum64String(ctx.Req.ResolvedTargetModel))) + for i := 0; i+cacheBlockSize <= len(prompt); i += cacheBlockSize { + block := prompt[i : i+cacheBlockSize] + prevBlockHash := res[len(res)-1] + block = append(block, toBytes(prevBlockHash)...) + res = append(res, BlockHash(xxhash.Sum64(block))) + } + return res +} + +func toBytes(i BlockHash) []byte { + bytes := make([]byte, 8) + binary.LittleEndian.PutUint64(bytes, uint64(i)) + return bytes +} diff --git a/pkg/epp/scheduling/plugins/prefix/plugin_test.go b/pkg/epp/scheduling/plugins/prefix/plugin_test.go new file mode 100644 index 000000000..9aa1dbf1c --- /dev/null +++ b/pkg/epp/scheduling/plugins/prefix/plugin_test.go @@ -0,0 +1,137 @@ +package prefix + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + k8stypes "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" +) + +func TestPrefixPlugin(t *testing.T) { + config := Config{ + HashBlockSize: 4, + MaxPrefixBlocksToMatch: DefaultMaxPrefixBlocks, + LRUIndexerCapacity: DefaultLRUIndexerCapacity, + } + plugin := New(config) + + pod1 := &types.PodMetrics{Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}} + pod2 := &types.PodMetrics{Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}} + pods := []types.Pod{pod1, pod2} + + // First request. + req1 := &types.LLMRequest{ + Model: "test-model1", + ResolvedTargetModel: "test-model1", + Prompt: "aaaaaa", + } + ctx := types.NewSchedulingContext(context.Background(), req1, pods) + plugin.PreSchedule(ctx) + state := ctx.GetPluginState(types.PluginName(plugin.Name())).(SchedulingContextState) + t.Logf("Hashes %+v, cached servers: %+v", state.PrefixHashes, state.PrefixCacheServers) + // Input size is 6, hash block size is 4, the last 2 characters are ignored. + // Total hashes = 2 (the first one is for the model) + assert.Equal(t, 2, len(state.PrefixHashes), "number of hashes is incorrect") + assert.Equal(t, 0, len(state.PrefixCacheServers), "there shouldn't be any cached servers") + + // Updated to use the new Score method signature + scores := plugin.Score(ctx, pods) + assert.Equal(t, float64(0), scores[pod1], "score for pod1") + assert.Equal(t, float64(0), scores[pod2], "score for pod2") + + // Simulate pod1 was picked. + plugin.PostSchedule(ctx, &types.Result{TargetPod: pod1}) + + // Second request doesn't share any prefix with first one. It should be added to the cache but + // the pod score should be 0. + req2 := &types.LLMRequest{ + Model: "test-model2", + ResolvedTargetModel: "test-model2", + Prompt: "bbbbbb", + } + ctx = types.NewSchedulingContext(context.Background(), req2, pods) + plugin.PreSchedule(ctx) + state = ctx.GetPluginState(types.PluginName(plugin.Name())).(SchedulingContextState) + t.Logf("Hashes %+v, cached servers: %+v", state.PrefixHashes, state.PrefixCacheServers) + // Input size is 6, hash block size is 4, the last 2 characters are ignored. + // Total hashes = 2 (the first one is for the model) + assert.Equal(t, 2, len(state.PrefixHashes), "number of hashes is incorrect") + assert.Equal(t, 0, len(state.PrefixCacheServers), "there shouldn't be any cached servers") + + // Updated to use the new Score method signature + scores = plugin.Score(ctx, pods) + assert.Equal(t, float64(0), scores[pod1], "score for pod1") + assert.Equal(t, float64(0), scores[pod2], "score for pod2") + + // Simulate pod2 was picked. + plugin.PostSchedule(ctx, &types.Result{TargetPod: pod2}) + + // Third request shares partial prefix with first one. + req3 := &types.LLMRequest{ + Model: "test-model1", + ResolvedTargetModel: "test-model1", + Prompt: "aaaabbbb", + } + ctx = types.NewSchedulingContext(context.Background(), req3, pods) + plugin.PreSchedule(ctx) + state = ctx.GetPluginState(types.PluginName(plugin.Name())).(SchedulingContextState) + t.Logf("Hashes %+v, cached servers: %+v", state.PrefixHashes, state.PrefixCacheServers) + // Input size is 8, hash block size is 4, so 2 hashes will be calculated. + // Total hashes = 3 (the first one is for the model) + assert.Equal(t, 3, len(state.PrefixHashes), "number of hashes is incorrect") + assert.Equal(t, 1, len(state.PrefixCacheServers), "pod1 should have cached the aaaa prefix") + + // Updated to use the new Score method signature + scores = plugin.Score(ctx, pods) + assert.Equal(t, float64(2)/float64(3), scores[pod1], "score should be 2/3 - the model and the first prefix block match") + assert.Equal(t, float64(0), scores[pod2], "score for pod2") + + plugin.PostSchedule(ctx, &types.Result{TargetPod: pod1}) + + // 4th request is same as req3 except the model is different, still no match. + req4 := &types.LLMRequest{ + Model: "test-model-new", + ResolvedTargetModel: "test-model-new", + Prompt: "aaaabbbb", + } + ctx = types.NewSchedulingContext(context.Background(), req4, pods) + plugin.PreSchedule(ctx) + state = ctx.GetPluginState(types.PluginName(plugin.Name())).(SchedulingContextState) + t.Logf("Hashes %+v, cached servers: %+v", state.PrefixHashes, state.PrefixCacheServers) + // Input size is 8, hash block size is 4, so 2 hashes will be calculated. + // Total hashes = 3 (the first one is for the model) + assert.Equal(t, 3, len(state.PrefixHashes), "number of hashes is incorrect") + assert.Equal(t, 0, len(state.PrefixCacheServers), "pod1 should have cached the aaaa prefix") + + // Updated to use the new Score method signature + scores = plugin.Score(ctx, pods) + assert.Equal(t, float64(0), scores[pod1], "score for pod1") + assert.Equal(t, float64(0), scores[pod2], "score for pod2") + + plugin.PostSchedule(ctx, &types.Result{TargetPod: pod1}) + + // 5th request shares partial prefix with 3rd one. + req5 := &types.LLMRequest{ + Model: "test-model1", + ResolvedTargetModel: "test-model1", + Prompt: "aaaabbbbcccc", + } + ctx = types.NewSchedulingContext(context.Background(), req5, pods) + plugin.PreSchedule(ctx) + state = ctx.GetPluginState(types.PluginName(plugin.Name())).(SchedulingContextState) + t.Logf("Hashes %+v, cached servers: %+v", state.PrefixHashes, state.PrefixCacheServers) + // Input size is 12, hash block size is 4, so 3 hashes will be calculated. + // Total hashes = 4 (the first one is for the model) + assert.Equal(t, 4, len(state.PrefixHashes), "number of hashes is incorrect") + assert.Equal(t, 1, len(state.PrefixCacheServers), "pod1 should have cached the aaaa prefix") + + // Updated to use the new Score method signature + scores = plugin.Score(ctx, pods) + assert.Equal(t, 0.75, scores[pod1], "score should be 0.75 - the model and the first 2 prefix blocks match") + assert.Equal(t, float64(0), scores[pod2], "score for pod2") + + plugin.PostSchedule(ctx, &types.Result{TargetPod: pod1}) +} diff --git a/pkg/epp/scheduling/plugins/scorer/kvcache.go b/pkg/epp/scheduling/plugins/scorer/kvcache.go index 0877691d1..dbb6079dc 100644 --- a/pkg/epp/scheduling/plugins/scorer/kvcache.go +++ b/pkg/epp/scheduling/plugins/scorer/kvcache.go @@ -20,6 +20,10 @@ import ( "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" ) +const ( + DefaultKVCacheScorerWeight = 1 +) + type KVCacheScorer struct{} func (ss *KVCacheScorer) Name() string { diff --git a/pkg/epp/scheduling/plugins/scorer/queue.go b/pkg/epp/scheduling/plugins/scorer/queue.go index 3df9d4140..bbe6b6961 100644 --- a/pkg/epp/scheduling/plugins/scorer/queue.go +++ b/pkg/epp/scheduling/plugins/scorer/queue.go @@ -22,6 +22,10 @@ import ( "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" ) +const ( + DefaultQueueScorerWeight = 1 +) + type QueueScorer struct{} func (q *QueueScorer) Name() string { diff --git a/pkg/epp/scheduling/types/types.go b/pkg/epp/scheduling/types/types.go index 795ef65d2..daf27bf83 100644 --- a/pkg/epp/scheduling/types/types.go +++ b/pkg/epp/scheduling/types/types.go @@ -19,6 +19,7 @@ package types import ( "context" "fmt" + "sync" "github.com/go-logr/logr" "sigs.k8s.io/controller-runtime/pkg/log" @@ -62,8 +63,26 @@ type SchedulingContext struct { Logger logr.Logger Req *LLMRequest PodsSnapshot []Pod + // PluginState can be used by plugins to store state during a scheduling cycle, to communicate + // between different extension points. + PluginState map[PluginName]any + pluginStateMu *sync.RWMutex } +func (sc *SchedulingContext) GetPluginState(pluginName PluginName) any { + sc.pluginStateMu.RLock() + defer sc.pluginStateMu.RUnlock() + return sc.PluginState[pluginName] +} + +func (sc *SchedulingContext) SetPluginState(pluginName PluginName, state any) { + sc.pluginStateMu.Lock() + defer sc.pluginStateMu.Unlock() + sc.PluginState[pluginName] = state +} + +type PluginName string + func (pm *PodMetrics) String() string { if pm == nil { return "" @@ -87,10 +106,12 @@ type PodMetrics struct { func NewSchedulingContext(ctx context.Context, req *LLMRequest, pods []Pod) *SchedulingContext { logger := log.FromContext(ctx).WithValues("request", req) return &SchedulingContext{ - Context: ctx, - Logger: logger, - Req: req, - PodsSnapshot: pods, + Context: ctx, + Logger: logger, + Req: req, + PodsSnapshot: pods, + PluginState: make(map[PluginName]any), + pluginStateMu: &sync.RWMutex{}, } } From 62f226c325a96c65e2a96450f925ad74c159a36d Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Sun, 11 May 2025 16:47:14 +0300 Subject: [PATCH 11/53] merge functions in env utils (#819) Signed-off-by: Nir Rozenbaum --- pkg/epp/util/env/env.go | 50 +++++++++++++---------------------------- 1 file changed, 16 insertions(+), 34 deletions(-) diff --git a/pkg/epp/util/env/env.go b/pkg/epp/util/env/env.go index b4edcad42..7ea7c3c0c 100644 --- a/pkg/epp/util/env/env.go +++ b/pkg/epp/util/env/env.go @@ -10,43 +10,27 @@ import ( "github.com/go-logr/logr" ) -// parseEnvWithValue attempts to parse a string value using the provided -// parser. -// It logs success or failure and returns the parsed value or the default -// value. -// This helper is used when the environment variable is confirmed to exist. -func parseEnvWithValue[T any](key string, valueStr string, defaultVal T, - parser func(string) (T, error), logger logr.Logger) T { - parsedVal, err := parser(valueStr) - if err != nil { - logger.Info(fmt.Sprintf("Failed to parse environment variable as %s, using default value", reflect.TypeOf(defaultVal)), - "key", key, "rawValue", valueStr, "error", err, - "defaultValue", defaultVal) +// getEnvWithParser retrieves an environment variable. If set, it uses the provided parser to parse it. +// It logs success or failure and returns the parsed value or the default value in case of a failure. +func getEnvWithParser[T any](key string, defaultVal T, parser func(string) (T, error), logger logr.Logger) T { + valueStr, exists := os.LookupEnv(key) + if !exists { + logger.Info("Environment variable not set, using default value", "key", key, "defaultValue", defaultVal) return defaultVal } - logger.Info("Successfully loaded environment variable", - "key", key, "value", parsedVal) - return parsedVal -} - -// getEnvWithParser retrieves an environment variable. If set, it uses the -// provided parser to convert it. Otherwise, it returns the default value. -// It delegates to parseEnvWithValue for the actual parsing and detailed -// logging once the variable is confirmed to exist. -func getEnvWithParser[T any](key string, defaultVal T, - parser func(string) (T, error), logger logr.Logger) T { - valueStr, exists := os.LookupEnv(key) - if !exists { - logger.Info("Environment variable not set, using default value", - "key", key, "defaultValue", defaultVal) + parsedValue, err := parser(valueStr) + if err != nil { + logger.Info(fmt.Sprintf("Failed to parse environment variable as %s, using default value", reflect.TypeOf(defaultVal)), + "key", key, "rawValue", valueStr, "error", err, "defaultValue", defaultVal) return defaultVal } - return parseEnvWithValue(key, valueStr, defaultVal, parser, logger) + + logger.Info("Successfully loaded environment variable", "key", key, "value", parsedValue) + return parsedValue } -// GetEnvFloat gets a float64 from an environment variable with a default -// value. +// GetEnvFloat gets a float64 from an environment variable with a default value. func GetEnvFloat(key string, defaultVal float64, logger logr.Logger) float64 { parser := func(s string) (float64, error) { return strconv.ParseFloat(s, 64) } return getEnvWithParser(key, defaultVal, parser, logger) @@ -57,14 +41,12 @@ func GetEnvInt(key string, defaultVal int, logger logr.Logger) int { return getEnvWithParser(key, defaultVal, strconv.Atoi, logger) } -// GetEnvDuration gets a time.Duration from an environment variable with a -// default value. +// GetEnvDuration gets a time.Duration from an environment variable with a default value. func GetEnvDuration(key string, defaultVal time.Duration, logger logr.Logger) time.Duration { return getEnvWithParser(key, defaultVal, time.ParseDuration, logger) } -// GetEnvString gets a string from an environment variable with a default -// value. +// GetEnvString gets a string from an environment variable with a default value. func GetEnvString(key string, defaultVal string, logger logr.Logger) string { parser := func(s string) (string, error) { return s, nil } return getEnvWithParser(key, defaultVal, parser, logger) From bc29bd0138e93e853fac976cee6d2c8086c8be3c Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Sun, 11 May 2025 19:27:14 +0300 Subject: [PATCH 12/53] generalize scheduling cycle state concept (#818) * generalize scheduling cycle state concept Signed-off-by: Nir Rozenbaum * typo Signed-off-by: Nir Rozenbaum * make linter happy Signed-off-by: Nir Rozenbaum * make prefix state struct internal to package instead of public Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum --- pkg/epp/scheduling/plugins/prefix/plugin.go | 68 +++++++++++--- .../scheduling/plugins/prefix/plugin_test.go | 15 ++- pkg/epp/scheduling/types/cycle_state.go | 92 +++++++++++++++++++ .../scheduling/types/scheduling_context.go | 46 ++++++++++ pkg/epp/scheduling/types/types.go | 42 --------- 5 files changed, 203 insertions(+), 60 deletions(-) create mode 100644 pkg/epp/scheduling/types/cycle_state.go create mode 100644 pkg/epp/scheduling/types/scheduling_context.go diff --git a/pkg/epp/scheduling/plugins/prefix/plugin.go b/pkg/epp/scheduling/plugins/prefix/plugin.go index 6d7f03c10..106b887da 100644 --- a/pkg/epp/scheduling/plugins/prefix/plugin.go +++ b/pkg/epp/scheduling/plugins/prefix/plugin.go @@ -78,21 +78,37 @@ type Indexer interface { Add(hashes []BlockHash, server ServerID) } +// BlockHash is a hash of the block of request body. +type BlockHash uint64 + +type ServerID k8stypes.NamespacedName + +func (s ServerID) String() string { + return k8stypes.NamespacedName(s).String() +} + +var _ types.StateData = &schedulingContextState{} + // This is the state of this plugin to be used during a scheduling cycle. -type SchedulingContextState struct { +type schedulingContextState struct { // PrefixHashes is a list of prefix hashes of the request prompt broken into blocks. PrefixHashes []BlockHash // A map of server to its longest prefix cache match length. PrefixCacheServers map[ServerID]int } -// BlockHash is a hash of the block of request body. -type BlockHash uint64 - -type ServerID k8stypes.NamespacedName +func (s *schedulingContextState) Clone() types.StateData { + prefixHashes := make([]BlockHash, len(s.PrefixHashes)) + copy(prefixHashes, s.PrefixHashes) + prefixCacheServers := make(map[ServerID]int, len(s.PrefixCacheServers)) + for key, value := range s.PrefixCacheServers { + prefixCacheServers[key] = value + } -func (s ServerID) String() string { - return k8stypes.NamespacedName(s).String() + return &schedulingContextState{ + PrefixHashes: prefixHashes, + PrefixCacheServers: prefixCacheServers, + } } func New(config Config) *Plugin { @@ -104,23 +120,28 @@ func New(config Config) *Plugin { } func (m *Plugin) Name() string { - return "prefixCache" + return "prefix-cache" } func (m *Plugin) PreSchedule(ctx *types.SchedulingContext) { hashes := hashPrompt(ctx, m.HashBlockSize, m.MaxPrefixBlocksToMatch) - state := SchedulingContextState{ + state := &schedulingContextState{ PrefixHashes: hashes, PrefixCacheServers: m.matchLongestPrefix(ctx, hashes, DefaultNumServersToMatch), } - ctx.SetPluginState(types.PluginName(m.Name()), state) + + ctx.CycleState.Write(types.StateKey(m.Name()), state) ctx.Logger.V(logutil.DEBUG).Info(fmt.Sprintf("PreSchedule, cached servers: %+v", state.PrefixCacheServers), "hashes", state.PrefixHashes) } // If a request was routed to a server, record it in the cache: func (m *Plugin) PostSchedule(ctx *types.SchedulingContext, res *types.Result) { targetPod := res.TargetPod.GetPod() - state := ctx.GetPluginState(types.PluginName(m.Name())).(SchedulingContextState) + state, err := m.getPrefixState(ctx.CycleState) + if err != nil { + ctx.Logger.Error(err, "failed to read prefix plugin cycle state") + return + } m.indexer.Add(state.PrefixHashes, ServerID(targetPod.NamespacedName)) total := len(state.PrefixHashes) matchLen := state.PrefixCacheServers[ServerID(targetPod.NamespacedName)] @@ -128,7 +149,14 @@ func (m *Plugin) PostSchedule(ctx *types.SchedulingContext, res *types.Result) { } func (m *Plugin) Score(ctx *types.SchedulingContext, pods []types.Pod) map[types.Pod]float64 { - state := ctx.GetPluginState(types.PluginName(m.Name())).(SchedulingContextState) + scores := make(map[types.Pod]float64, len(pods)) + + state, err := m.getPrefixState(ctx.CycleState) + if err != nil { + ctx.Logger.Error(err, "failed to read prefix plugin cycle state") + return scores + } + total := len(state.PrefixHashes) podScoreFunc := func(pod types.Pod) float64 { if total == 0 { @@ -138,7 +166,6 @@ func (m *Plugin) Score(ctx *types.SchedulingContext, pods []types.Pod) map[types return float64(matchLen) / float64(total) } - scores := make(map[types.Pod]float64, len(pods)) for _, pod := range pods { scores[pod] = podScoreFunc(pod) } @@ -170,6 +197,21 @@ func (m *Plugin) matchLongestPrefix(ctx *types.SchedulingContext, hashes []Block return res } +func (m *Plugin) getPrefixState(cycleState *types.CycleState) (*schedulingContextState, error) { + prefixStateKey := types.StateKey(m.Name()) + state, err := cycleState.Read(prefixStateKey) + if err != nil { + return nil, fmt.Errorf("failed reading %q from CycleState: %w", prefixStateKey, err) + } + + prefixSchedulingState, ok := state.(*schedulingContextState) + if !ok { + return nil, fmt.Errorf("invalid Prefix state, got type %T", state) + } + + return prefixSchedulingState, nil +} + // hashPrompt divides the prompt into blocks and calculate the prefix cache for each block. // hash(0) is the hash of the model name, since different models generally don't share prefix cache. // For block i, hash(i) = hash(block i content, hash(i-1)). diff --git a/pkg/epp/scheduling/plugins/prefix/plugin_test.go b/pkg/epp/scheduling/plugins/prefix/plugin_test.go index 9aa1dbf1c..f14454927 100644 --- a/pkg/epp/scheduling/plugins/prefix/plugin_test.go +++ b/pkg/epp/scheduling/plugins/prefix/plugin_test.go @@ -30,7 +30,8 @@ func TestPrefixPlugin(t *testing.T) { } ctx := types.NewSchedulingContext(context.Background(), req1, pods) plugin.PreSchedule(ctx) - state := ctx.GetPluginState(types.PluginName(plugin.Name())).(SchedulingContextState) + state, err := plugin.getPrefixState(ctx.CycleState) + assert.NoError(t, err) t.Logf("Hashes %+v, cached servers: %+v", state.PrefixHashes, state.PrefixCacheServers) // Input size is 6, hash block size is 4, the last 2 characters are ignored. // Total hashes = 2 (the first one is for the model) @@ -54,7 +55,8 @@ func TestPrefixPlugin(t *testing.T) { } ctx = types.NewSchedulingContext(context.Background(), req2, pods) plugin.PreSchedule(ctx) - state = ctx.GetPluginState(types.PluginName(plugin.Name())).(SchedulingContextState) + state, err = plugin.getPrefixState(ctx.CycleState) + assert.NoError(t, err) t.Logf("Hashes %+v, cached servers: %+v", state.PrefixHashes, state.PrefixCacheServers) // Input size is 6, hash block size is 4, the last 2 characters are ignored. // Total hashes = 2 (the first one is for the model) @@ -77,7 +79,8 @@ func TestPrefixPlugin(t *testing.T) { } ctx = types.NewSchedulingContext(context.Background(), req3, pods) plugin.PreSchedule(ctx) - state = ctx.GetPluginState(types.PluginName(plugin.Name())).(SchedulingContextState) + state, err = plugin.getPrefixState(ctx.CycleState) + assert.NoError(t, err) t.Logf("Hashes %+v, cached servers: %+v", state.PrefixHashes, state.PrefixCacheServers) // Input size is 8, hash block size is 4, so 2 hashes will be calculated. // Total hashes = 3 (the first one is for the model) @@ -99,7 +102,8 @@ func TestPrefixPlugin(t *testing.T) { } ctx = types.NewSchedulingContext(context.Background(), req4, pods) plugin.PreSchedule(ctx) - state = ctx.GetPluginState(types.PluginName(plugin.Name())).(SchedulingContextState) + state, err = plugin.getPrefixState(ctx.CycleState) + assert.NoError(t, err) t.Logf("Hashes %+v, cached servers: %+v", state.PrefixHashes, state.PrefixCacheServers) // Input size is 8, hash block size is 4, so 2 hashes will be calculated. // Total hashes = 3 (the first one is for the model) @@ -121,7 +125,8 @@ func TestPrefixPlugin(t *testing.T) { } ctx = types.NewSchedulingContext(context.Background(), req5, pods) plugin.PreSchedule(ctx) - state = ctx.GetPluginState(types.PluginName(plugin.Name())).(SchedulingContextState) + state, err = plugin.getPrefixState(ctx.CycleState) + assert.NoError(t, err) t.Logf("Hashes %+v, cached servers: %+v", state.PrefixHashes, state.PrefixCacheServers) // Input size is 12, hash block size is 4, so 3 hashes will be calculated. // Total hashes = 4 (the first one is for the model) diff --git a/pkg/epp/scheduling/types/cycle_state.go b/pkg/epp/scheduling/types/cycle_state.go new file mode 100644 index 000000000..9f0a67f6e --- /dev/null +++ b/pkg/epp/scheduling/types/cycle_state.go @@ -0,0 +1,92 @@ +/* +Copyright 2025 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 types + +import ( + "errors" + "sync" +) + +var ( + // ErrNotFound is the not found error message. + ErrNotFound = errors.New("not found") +) + +// StateData is a generic type for arbitrary data stored in CycleState. +type StateData interface { + // Clone is an interface to make a copy of StateData. + Clone() StateData +} + +// StateKey is the type of keys stored in CycleState. +type StateKey string + +// NewCycleState initializes a new CycleState and returns its pointer. +func NewCycleState() *CycleState { + return &CycleState{} +} + +// CycleState provides a mechanism for plugins to store and retrieve arbitrary data. +// StateData stored by one plugin can be read, altered, or deleted by another plugin. +// CycleState does not provide any data protection, as all plugins are assumed to be +// trusted. +// Note: CycleState uses a sync.Map to back the storage, because it is thread safe. It's aimed to optimize for the "write once and read many times" scenarios. +type CycleState struct { + // key: StateKey, value: StateData + storage sync.Map +} + +// Clone creates a copy of CycleState and returns its pointer. Clone returns +// nil if the context being cloned is nil. +func (c *CycleState) Clone() *CycleState { + if c == nil { + return nil + } + copy := NewCycleState() + // Safe copy storage in case of overwriting. + c.storage.Range(func(k, v interface{}) bool { + copy.storage.Store(k, v.(StateData).Clone()) + return true + }) + + return copy +} + +// Read retrieves data with the given "key" from CycleState. If the key is not +// present, ErrNotFound is returned. +// +// See CycleState for notes on concurrency. +func (c *CycleState) Read(key StateKey) (StateData, error) { + if v, ok := c.storage.Load(key); ok { + return v.(StateData), nil + } + return nil, ErrNotFound +} + +// Write stores the given "val" in CycleState with the given "key". +// +// See CycleState for notes on concurrency. +func (c *CycleState) Write(key StateKey, val StateData) { + c.storage.Store(key, val) +} + +// Delete deletes data with the given key from CycleState. +// +// See CycleState for notes on concurrency. +func (c *CycleState) Delete(key StateKey) { + c.storage.Delete(key) +} diff --git a/pkg/epp/scheduling/types/scheduling_context.go b/pkg/epp/scheduling/types/scheduling_context.go new file mode 100644 index 000000000..42d70c5da --- /dev/null +++ b/pkg/epp/scheduling/types/scheduling_context.go @@ -0,0 +1,46 @@ +/* +Copyright 2025 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 types + +import ( + "context" + + "github.com/go-logr/logr" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +func NewSchedulingContext(ctx context.Context, req *LLMRequest, pods []Pod) *SchedulingContext { + logger := log.FromContext(ctx).WithValues("request", req) + return &SchedulingContext{ + Context: ctx, + Logger: logger, + Req: req, + PodsSnapshot: pods, + CycleState: NewCycleState(), + } +} + +// SchedulingContext holds contextual information during a scheduling operation. +type SchedulingContext struct { + context.Context + Logger logr.Logger + Req *LLMRequest + PodsSnapshot []Pod + // CycleState can be used by plugins to store state during a scheduling cycle, to communicate + // between different extension points. + CycleState *CycleState +} diff --git a/pkg/epp/scheduling/types/types.go b/pkg/epp/scheduling/types/types.go index daf27bf83..9d3deb670 100644 --- a/pkg/epp/scheduling/types/types.go +++ b/pkg/epp/scheduling/types/types.go @@ -17,12 +17,8 @@ limitations under the License. package types import ( - "context" "fmt" - "sync" - "github.com/go-logr/logr" - "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" ) @@ -57,32 +53,6 @@ type ScoredPod struct { Score float64 } -// SchedulingContext holds contextual information during a scheduling operation. -type SchedulingContext struct { - context.Context - Logger logr.Logger - Req *LLMRequest - PodsSnapshot []Pod - // PluginState can be used by plugins to store state during a scheduling cycle, to communicate - // between different extension points. - PluginState map[PluginName]any - pluginStateMu *sync.RWMutex -} - -func (sc *SchedulingContext) GetPluginState(pluginName PluginName) any { - sc.pluginStateMu.RLock() - defer sc.pluginStateMu.RUnlock() - return sc.PluginState[pluginName] -} - -func (sc *SchedulingContext) SetPluginState(pluginName PluginName, state any) { - sc.pluginStateMu.Lock() - defer sc.pluginStateMu.Unlock() - sc.PluginState[pluginName] = state -} - -type PluginName string - func (pm *PodMetrics) String() string { if pm == nil { return "" @@ -103,18 +73,6 @@ type PodMetrics struct { *backendmetrics.Metrics } -func NewSchedulingContext(ctx context.Context, req *LLMRequest, pods []Pod) *SchedulingContext { - logger := log.FromContext(ctx).WithValues("request", req) - return &SchedulingContext{ - Context: ctx, - Logger: logger, - Req: req, - PodsSnapshot: pods, - PluginState: make(map[PluginName]any), - pluginStateMu: &sync.RWMutex{}, - } -} - func ToSchedulerPodMetrics(pods []backendmetrics.PodMetrics) []Pod { pm := make([]Pod, 0, len(pods)) for _, pod := range pods { From 8df511a86db9e8f1db0d1a3479e778faa0edbdb0 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Sun, 11 May 2025 20:31:17 +0300 Subject: [PATCH 13/53] remove Model field from LLMRequest (#782) * remove Model field from LLMRequest Signed-off-by: Nir Rozenbaum * rebase handling Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum --- pkg/epp/requestcontrol/director.go | 17 +++++-------- .../scheduling/plugins/filter/filter_test.go | 3 +-- .../plugins/filter/lora_affinity_filter.go | 4 +-- pkg/epp/scheduling/plugins/prefix/plugin.go | 2 +- .../scheduling/plugins/prefix/plugin_test.go | 25 ++++++++----------- pkg/epp/scheduling/scheduler_test.go | 22 +++++++--------- pkg/epp/scheduling/types/types.go | 9 +++---- 7 files changed, 32 insertions(+), 50 deletions(-) diff --git a/pkg/epp/requestcontrol/director.go b/pkg/epp/requestcontrol/director.go index cafcc80b0..7bb933be2 100644 --- a/pkg/epp/requestcontrol/director.go +++ b/pkg/epp/requestcontrol/director.go @@ -79,14 +79,14 @@ func (d *Director) HandleRequest(ctx context.Context, reqCtx *handlers.RequestCo if reqCtx.ResolvedTargetModel == "" { return reqCtx, errutil.Error{Code: errutil.BadConfiguration, Msg: fmt.Sprintf("error getting target model name for model %v", modelObj.Name)} } + reqCtx.Request.Body["model"] = reqCtx.ResolvedTargetModel // Update target model in the body. } llmReq := &schedulingtypes.LLMRequest{ - Model: reqCtx.Model, - ResolvedTargetModel: reqCtx.ResolvedTargetModel, - Critical: modelObj.Spec.Criticality != nil && *modelObj.Spec.Criticality == v1alpha2.Critical, - Prompt: prompt, - Headers: reqCtx.Request.Headers, + TargetModel: reqCtx.ResolvedTargetModel, + Critical: modelObj.Spec.Criticality != nil && *modelObj.Spec.Criticality == v1alpha2.Critical, + Prompt: prompt, + Headers: reqCtx.Request.Headers, } logger.V(logutil.DEBUG).Info("LLM request assembled", "request", llmReq) results, err := d.Dispatch(ctx, llmReq) @@ -129,13 +129,8 @@ func (d *Director) PostDispatch(ctx context.Context, reqCtx *handlers.RequestCon } endpoint := targetPod.Address + ":" + strconv.Itoa(int(pool.Spec.TargetPortNumber)) - logger.V(logutil.DEFAULT).Info("Request handled", - "model", reqCtx.Model, "targetModel", reqCtx.ResolvedTargetModel, "endpoint", targetPod) + logger.V(logutil.DEFAULT).Info("Request handled", "model", reqCtx.Model, "targetModel", reqCtx.ResolvedTargetModel, "endpoint", targetPod) - // Update target models in the body. - if reqCtx.Model != reqCtx.ResolvedTargetModel { - reqCtx.Request.Body["model"] = reqCtx.ResolvedTargetModel - } reqCtx.TargetPod = targetPod.NamespacedName.String() reqCtx.TargetEndpoint = endpoint diff --git a/pkg/epp/scheduling/plugins/filter/filter_test.go b/pkg/epp/scheduling/plugins/filter/filter_test.go index d78452a62..06fcd2ded 100644 --- a/pkg/epp/scheduling/plugins/filter/filter_test.go +++ b/pkg/epp/scheduling/plugins/filter/filter_test.go @@ -204,8 +204,7 @@ func TestLoRASoftAffinityDistribution(t *testing.T) { // Create a test request and pods req := &types.LLMRequest{ - Model: testAffinityModel, - ResolvedTargetModel: testAffinityModel, + TargetModel: testAffinityModel, } // Test setup: One affinity pod and one available pod diff --git a/pkg/epp/scheduling/plugins/filter/lora_affinity_filter.go b/pkg/epp/scheduling/plugins/filter/lora_affinity_filter.go index a1c200412..bc744a8ef 100644 --- a/pkg/epp/scheduling/plugins/filter/lora_affinity_filter.go +++ b/pkg/epp/scheduling/plugins/filter/lora_affinity_filter.go @@ -59,8 +59,8 @@ func (f *LoraAffinityFilter) Filter(ctx *types.SchedulingContext, pods []types.P // Categorize pods based on affinity and availability for _, pod := range pods { - _, active := pod.GetMetrics().ActiveModels[ctx.Req.ResolvedTargetModel] - _, waiting := pod.GetMetrics().WaitingModels[ctx.Req.ResolvedTargetModel] + _, active := pod.GetMetrics().ActiveModels[ctx.Req.TargetModel] + _, waiting := pod.GetMetrics().WaitingModels[ctx.Req.TargetModel] if active || waiting { filtered_affinity = append(filtered_affinity, pod) diff --git a/pkg/epp/scheduling/plugins/prefix/plugin.go b/pkg/epp/scheduling/plugins/prefix/plugin.go index 106b887da..5cb2d4ce4 100644 --- a/pkg/epp/scheduling/plugins/prefix/plugin.go +++ b/pkg/epp/scheduling/plugins/prefix/plugin.go @@ -229,7 +229,7 @@ func hashPrompt(ctx *types.SchedulingContext, cacheBlockSize int, maxPrefixBlock // If the last block is smaller than cacheBlockSize, it will be ignored. res := make([]BlockHash, 0, 1+len(prompt)/cacheBlockSize) // Add the model to the first block hash so that different models have different hashes even with the same body. - res = append(res, BlockHash(xxhash.Sum64String(ctx.Req.ResolvedTargetModel))) + res = append(res, BlockHash(xxhash.Sum64String(ctx.Req.TargetModel))) for i := 0; i+cacheBlockSize <= len(prompt); i += cacheBlockSize { block := prompt[i : i+cacheBlockSize] prevBlockHash := res[len(res)-1] diff --git a/pkg/epp/scheduling/plugins/prefix/plugin_test.go b/pkg/epp/scheduling/plugins/prefix/plugin_test.go index f14454927..34d133a76 100644 --- a/pkg/epp/scheduling/plugins/prefix/plugin_test.go +++ b/pkg/epp/scheduling/plugins/prefix/plugin_test.go @@ -24,9 +24,8 @@ func TestPrefixPlugin(t *testing.T) { // First request. req1 := &types.LLMRequest{ - Model: "test-model1", - ResolvedTargetModel: "test-model1", - Prompt: "aaaaaa", + TargetModel: "test-model1", + Prompt: "aaaaaa", } ctx := types.NewSchedulingContext(context.Background(), req1, pods) plugin.PreSchedule(ctx) @@ -49,9 +48,8 @@ func TestPrefixPlugin(t *testing.T) { // Second request doesn't share any prefix with first one. It should be added to the cache but // the pod score should be 0. req2 := &types.LLMRequest{ - Model: "test-model2", - ResolvedTargetModel: "test-model2", - Prompt: "bbbbbb", + TargetModel: "test-model2", + Prompt: "bbbbbb", } ctx = types.NewSchedulingContext(context.Background(), req2, pods) plugin.PreSchedule(ctx) @@ -73,9 +71,8 @@ func TestPrefixPlugin(t *testing.T) { // Third request shares partial prefix with first one. req3 := &types.LLMRequest{ - Model: "test-model1", - ResolvedTargetModel: "test-model1", - Prompt: "aaaabbbb", + TargetModel: "test-model1", + Prompt: "aaaabbbb", } ctx = types.NewSchedulingContext(context.Background(), req3, pods) plugin.PreSchedule(ctx) @@ -96,9 +93,8 @@ func TestPrefixPlugin(t *testing.T) { // 4th request is same as req3 except the model is different, still no match. req4 := &types.LLMRequest{ - Model: "test-model-new", - ResolvedTargetModel: "test-model-new", - Prompt: "aaaabbbb", + TargetModel: "test-model-new", + Prompt: "aaaabbbb", } ctx = types.NewSchedulingContext(context.Background(), req4, pods) plugin.PreSchedule(ctx) @@ -119,9 +115,8 @@ func TestPrefixPlugin(t *testing.T) { // 5th request shares partial prefix with 3rd one. req5 := &types.LLMRequest{ - Model: "test-model1", - ResolvedTargetModel: "test-model1", - Prompt: "aaaabbbbcccc", + TargetModel: "test-model1", + Prompt: "aaaabbbbcccc", } ctx = types.NewSchedulingContext(context.Background(), req5, pods) plugin.PreSchedule(ctx) diff --git a/pkg/epp/scheduling/scheduler_test.go b/pkg/epp/scheduling/scheduler_test.go index 07f67d6a4..7679cc63a 100644 --- a/pkg/epp/scheduling/scheduler_test.go +++ b/pkg/epp/scheduling/scheduler_test.go @@ -40,9 +40,8 @@ func TestSchedule(t *testing.T) { { name: "no pods in datastore", req: &types.LLMRequest{ - Model: "any-model", - ResolvedTargetModel: "any-model", - Critical: true, + TargetModel: "any-model", + Critical: true, }, input: []*backendmetrics.FakePodMetrics{}, err: true, @@ -50,9 +49,8 @@ func TestSchedule(t *testing.T) { { name: "critical request", req: &types.LLMRequest{ - Model: "critical", - ResolvedTargetModel: "critical", - Critical: true, + TargetModel: "critical", + Critical: true, }, // pod2 will be picked because it has relatively low queue size, with the requested // model being active, and has low KV cache. @@ -114,9 +112,8 @@ func TestSchedule(t *testing.T) { { name: "sheddable request, accepted", req: &types.LLMRequest{ - Model: "sheddable", - ResolvedTargetModel: "sheddable", - Critical: false, + TargetModel: "sheddable", + Critical: false, }, // pod1 will be picked because it has capacity for the sheddable request. input: []*backendmetrics.FakePodMetrics{ @@ -177,9 +174,8 @@ func TestSchedule(t *testing.T) { { name: "sheddable request, dropped", req: &types.LLMRequest{ - Model: "sheddable", - ResolvedTargetModel: "sheddable", - Critical: false, + TargetModel: "sheddable", + Critical: false, }, // All pods have higher KV cache thant the threshold, so the sheddable request will be // dropped. @@ -356,7 +352,7 @@ func TestSchedulePlugins(t *testing.T) { // Initialize the scheduler scheduler := NewSchedulerWithConfig(&fakeDataStore{pods: test.input}, &test.config) - req := &types.LLMRequest{Model: "test-model"} + req := &types.LLMRequest{TargetModel: "test-model"} got, err := scheduler.Schedule(context.Background(), req) // Validate error state diff --git a/pkg/epp/scheduling/types/types.go b/pkg/epp/scheduling/types/types.go index 9d3deb670..adf663bc6 100644 --- a/pkg/epp/scheduling/types/types.go +++ b/pkg/epp/scheduling/types/types.go @@ -25,10 +25,8 @@ import ( // LLMRequest is a structured representation of the fields we parse out of the LLMRequest body. type LLMRequest struct { - // Model is the name of the model that the user specified in the request body. - Model string - // ResolvedTargetModel is the final target model after traffic split. - ResolvedTargetModel string + // TargetModel is the final target model after traffic split. + TargetModel string // Critical is a boolean that specifies if a request is critical or not. Critical bool // Prompt is the prompt that was sent in the request body. @@ -38,8 +36,7 @@ type LLMRequest struct { } func (r *LLMRequest) String() string { - return fmt.Sprintf("Model: %s, ResolvedTargetModel: %s, Critical: %t, PromptLength: %d, Headers: %v", - r.Model, r.ResolvedTargetModel, r.Critical, len(r.Prompt), r.Headers) + return fmt.Sprintf("TargetModel: %s, Critical: %t, PromptLength: %d, Headers: %v", r.TargetModel, r.Critical, len(r.Prompt), r.Headers) } type Pod interface { From 80ce38530dac1f1c6a35e1b51a4a4a04da9f3588 Mon Sep 17 00:00:00 2001 From: Shmuel Kallner Date: Mon, 12 May 2025 15:33:16 +0300 Subject: [PATCH 14/53] feat: Add support to invoke PostResponse plugins (#800) * Added the LLMResponse struct and RequestId to LLMRequest Signed-off-by: Shmuel Kallner * Updates due to NewSchedulerContext API change Signed-off-by: Shmuel Kallner * Populate the RequestId field of LLMRequest Signed-off-by: Shmuel Kallner * Updates to tests Signed-off-by: Shmuel Kallner * Added PostResponse plugins to scheduler config Signed-off-by: Shmuel Kallner * Added scheduler.OnResponse to handle responses Signed-off-by: Shmuel Kallner * Added dispatcher.HandleResponse to handle responses Signed-off-by: Shmuel Kallner * Refactored server response header handling to invoke PostResponse plugins Signed-off-by: Shmuel Kallner * Added simple test for PostResponse plugins Signed-off-by: Shmuel Kallner * Setup the logger in the SchedulerContext appropriately for reponses Signed-off-by: Shmuel Kallner * Updates due to rebase issues * merge functions in env utils (#819) Signed-off-by: Nir Rozenbaum * generalize scheduling cycle state concept (#818) * generalize scheduling cycle state concept Signed-off-by: Nir Rozenbaum * typo Signed-off-by: Nir Rozenbaum * make linter happy Signed-off-by: Nir Rozenbaum * make prefix state struct internal to package instead of public Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum * remove Model field from LLMRequest (#782) * remove Model field from LLMRequest Signed-off-by: Nir Rozenbaum * rebase handling Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum * Added the LLMResponse struct and RequestId to LLMRequest Signed-off-by: Shmuel Kallner * Insure that wanted response header messages have all of the response headers in them Signed-off-by: Shmuel Kallner --------- Signed-off-by: Shmuel Kallner Signed-off-by: Nir Rozenbaum Co-authored-by: Nir Rozenbaum --- cmd/epp/main.go | 1 + pkg/epp/handlers/response.go | 59 +++++++++++++- pkg/epp/handlers/server.go | 34 ++++---- pkg/epp/requestcontrol/director.go | 17 ++++ pkg/epp/scheduling/config.go | 4 +- .../scheduling/plugins/filter/filter_test.go | 6 +- .../scheduling/plugins/prefix/plugin_test.go | 10 +-- .../scheduling/plugins/scorer/kvcache_test.go | 2 +- .../scheduling/plugins/scorer/queue_test.go | 2 +- pkg/epp/scheduling/scheduler.go | 33 +++++++- pkg/epp/scheduling/scheduler_test.go | 80 ++++++++++++++++++- .../scheduling/types/scheduling_context.go | 5 +- pkg/epp/scheduling/types/types.go | 16 ++++ test/integration/epp/hermetic_test.go | 24 ++++++ 14 files changed, 258 insertions(+), 35 deletions(-) diff --git a/cmd/epp/main.go b/cmd/epp/main.go index e674f1c20..bda7cc207 100644 --- a/cmd/epp/main.go +++ b/cmd/epp/main.go @@ -211,6 +211,7 @@ func run() error { scorers, picker.NewMaxScorePicker(), []plugins.PostSchedule{}, + []plugins.PostResponse{}, schedConfigOpts...) scheduler = scheduling.NewSchedulerWithConfig(datastore, schedulerConfig) } diff --git a/pkg/epp/handlers/response.go b/pkg/epp/handlers/response.go index 04c7a5e97..bbc46c930 100644 --- a/pkg/epp/handlers/response.go +++ b/pkg/epp/handlers/response.go @@ -21,6 +21,7 @@ import ( "encoding/json" "strings" + configPb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" @@ -98,6 +99,58 @@ func (s *StreamingServer) HandleResponseBodyModelStreaming( } } +func (s *StreamingServer) HandleResponseHeaders(ctx context.Context, reqCtx *RequestContext, resp *extProcPb.ProcessingRequest_ResponseHeaders) (*RequestContext, error) { + for _, header := range resp.ResponseHeaders.Headers.Headers { + if header.RawValue != nil { + reqCtx.Response.Headers[header.Key] = string(header.RawValue) + } else { + reqCtx.Response.Headers[header.Key] = header.Value + } + } + + reqCtx, err := s.director.HandleResponse(ctx, reqCtx) + + return reqCtx, err +} + +func (s *StreamingServer) generateResponseHeaderResponse(reqCtx *RequestContext) *extProcPb.ProcessingResponse { + return &extProcPb.ProcessingResponse{ + Response: &extProcPb.ProcessingResponse_ResponseHeaders{ + ResponseHeaders: &extProcPb.HeadersResponse{ + Response: &extProcPb.CommonResponse{ + HeaderMutation: &extProcPb.HeaderMutation{ + SetHeaders: s.generateResponseHeaders(reqCtx), + }, + }, + }, + }, + } +} + +func (s *StreamingServer) generateResponseHeaders(reqCtx *RequestContext) []*configPb.HeaderValueOption { + // can likely refactor these two bespoke headers to be updated in PostDispatch, to centralize logic. + headers := []*configPb.HeaderValueOption{ + { + Header: &configPb.HeaderValue{ + // This is for debugging purpose only. + Key: "x-went-into-resp-headers", + RawValue: []byte("true"), + }, + }, + } + + // include all headers + for key, value := range reqCtx.Response.Headers { + headers = append(headers, &configPb.HeaderValueOption{ + Header: &configPb.HeaderValue{ + Key: key, + RawValue: []byte(value), + }, + }) + } + return headers +} + // Example message if "stream_options": {"include_usage": "true"} is included in the request: // data: {"id":"...","object":"text_completion","created":1739400043,"model":"food-review-0","choices":[], // "usage":{"prompt_tokens":7,"total_tokens":17,"completion_tokens":10}} @@ -112,8 +165,8 @@ func (s *StreamingServer) HandleResponseBodyModelStreaming( func parseRespForUsage( ctx context.Context, responseText string, -) Response { - response := Response{} +) ResponseBody { + response := ResponseBody{} logger := log.FromContext(ctx) lines := strings.Split(responseText, "\n") @@ -136,7 +189,7 @@ func parseRespForUsage( return response } -type Response struct { +type ResponseBody struct { Usage Usage `json:"usage"` } diff --git a/pkg/epp/handlers/server.go b/pkg/epp/handlers/server.go index ae9c6f4be..be85ba6bf 100644 --- a/pkg/epp/handlers/server.go +++ b/pkg/epp/handlers/server.go @@ -23,7 +23,6 @@ import ( "strings" "time" - configPb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" envoyTypePb "github.com/envoyproxy/go-control-plane/envoy/type/v3" "github.com/go-logr/logr" @@ -49,6 +48,7 @@ func NewStreamingServer(destinationEndpointHintMetadataNamespace, destinationEnd type Director interface { HandleRequest(ctx context.Context, reqCtx *RequestContext) (*RequestContext, error) + HandleResponse(ctx context.Context, reqCtx *RequestContext) (*RequestContext, error) GetRandomPod() *backend.Pod } @@ -91,6 +91,8 @@ type RequestContext struct { RequestState StreamRequestState modelServerStreaming bool + Response *Response + reqHeaderResp *extProcPb.ProcessingResponse reqBodyResp *extProcPb.ProcessingResponse reqTrailerResp *extProcPb.ProcessingResponse @@ -104,6 +106,9 @@ type Request struct { Headers map[string]string Body map[string]interface{} } +type Response struct { + Headers map[string]string +} type StreamRequestState int const ( @@ -131,6 +136,9 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) Headers: make(map[string]string), Body: make(map[string]interface{}), }, + Response: &Response{ + Headers: make(map[string]string), + }, } var body []byte @@ -229,25 +237,13 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) } } reqCtx.RequestState = ResponseRecieved - reqCtx.respHeaderResp = &extProcPb.ProcessingResponse{ - Response: &extProcPb.ProcessingResponse_ResponseHeaders{ - ResponseHeaders: &extProcPb.HeadersResponse{ - Response: &extProcPb.CommonResponse{ - HeaderMutation: &extProcPb.HeaderMutation{ - SetHeaders: []*configPb.HeaderValueOption{ - { - Header: &configPb.HeaderValue{ - // This is for debugging purpose only. - Key: "x-went-into-resp-headers", - RawValue: []byte("true"), - }, - }, - }, - }, - }, - }, - }, + + var responseErr error + reqCtx, responseErr = s.HandleResponseHeaders(ctx, reqCtx, v) + if responseErr != nil { + logger.V(logutil.DEFAULT).Error(responseErr, "Failed to process response headers", "request", req) } + reqCtx.respHeaderResp = s.generateResponseHeaderResponse(reqCtx) case *extProcPb.ProcessingRequest_ResponseBody: if reqCtx.modelServerStreaming { diff --git a/pkg/epp/requestcontrol/director.go b/pkg/epp/requestcontrol/director.go index 7bb933be2..bfcf2ec6d 100644 --- a/pkg/epp/requestcontrol/director.go +++ b/pkg/epp/requestcontrol/director.go @@ -31,10 +31,12 @@ import ( schedulingtypes "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/error" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" + requtil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/request" ) type Scheduler interface { Schedule(ctx context.Context, b *schedulingtypes.LLMRequest) (result *schedulingtypes.Result, err error) + OnResponse(ctx context.Context, resp *schedulingtypes.LLMResponse, targetPodName string) } type Director struct { @@ -84,6 +86,7 @@ func (d *Director) HandleRequest(ctx context.Context, reqCtx *handlers.RequestCo llmReq := &schedulingtypes.LLMRequest{ TargetModel: reqCtx.ResolvedTargetModel, + RequestId: reqCtx.Request.Headers[requtil.RequestIdHeaderKey], Critical: modelObj.Spec.Criticality != nil && *modelObj.Spec.Criticality == v1alpha2.Critical, Prompt: prompt, Headers: reqCtx.Request.Headers, @@ -137,6 +140,20 @@ func (d *Director) PostDispatch(ctx context.Context, reqCtx *handlers.RequestCon return reqCtx, nil } +func (d *Director) HandleResponse(ctx context.Context, reqCtx *handlers.RequestContext) (*handlers.RequestContext, error) { + logger := log.FromContext(ctx) + + llmResp := &schedulingtypes.LLMResponse{ + RequestId: reqCtx.Request.Headers[requtil.RequestIdHeaderKey], + Headers: reqCtx.Response.Headers, + } + logger.V(logutil.DEBUG).Info("LLM response assembled", "response", llmResp) + + d.scheduler.OnResponse(ctx, llmResp, reqCtx.TargetPod) + + return reqCtx, nil +} + func (d *Director) GetRandomPod() *backend.Pod { pods := d.datastore.PodGetAll() if len(pods) == 0 { diff --git a/pkg/epp/scheduling/config.go b/pkg/epp/scheduling/config.go index e321ca2bf..02922894d 100644 --- a/pkg/epp/scheduling/config.go +++ b/pkg/epp/scheduling/config.go @@ -23,13 +23,14 @@ import ( // NewSchedulerConfig creates a new SchedulerConfig object with the given plugins. func NewSchedulerConfig(preSchedulePlugins []plugins.PreSchedule, filters []plugins.Filter, scorers map[plugins.Scorer]int, - picker plugins.Picker, postSchedulePlugins []plugins.PostSchedule, opts ...ConfigOption) *SchedulerConfig { + picker plugins.Picker, postSchedulePlugins []plugins.PostSchedule, postResponsePlugins []plugins.PostResponse, opts ...ConfigOption) *SchedulerConfig { config := &SchedulerConfig{ preSchedulePlugins: preSchedulePlugins, filters: filters, scorers: scorers, picker: picker, postSchedulePlugins: postSchedulePlugins, + postResponsePlugins: postResponsePlugins, } for _, opt := range opts { opt(config) @@ -44,6 +45,7 @@ type SchedulerConfig struct { scorers map[plugins.Scorer]int // map from scorer to weight picker plugins.Picker postSchedulePlugins []plugins.PostSchedule + postResponsePlugins []plugins.PostResponse } type ConfigOption func(*SchedulerConfig) diff --git a/pkg/epp/scheduling/plugins/filter/filter_test.go b/pkg/epp/scheduling/plugins/filter/filter_test.go index 06fcd2ded..1737f6190 100644 --- a/pkg/epp/scheduling/plugins/filter/filter_test.go +++ b/pkg/epp/scheduling/plugins/filter/filter_test.go @@ -21,6 +21,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/google/uuid" k8stypes "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" @@ -170,7 +171,7 @@ func TestFilter(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - ctx := types.NewSchedulingContext(context.Background(), test.req, test.input) + ctx := types.NewSchedulingContext(context.Background(), test.req, nil, test.input) got := test.filter.Filter(ctx, test.input) if diff := cmp.Diff(test.output, got); diff != "" { @@ -205,6 +206,7 @@ func TestLoRASoftAffinityDistribution(t *testing.T) { // Create a test request and pods req := &types.LLMRequest{ TargetModel: testAffinityModel, + RequestId: uuid.NewString(), } // Test setup: One affinity pod and one available pod @@ -226,7 +228,7 @@ func TestLoRASoftAffinityDistribution(t *testing.T) { }, }, } - ctx := types.NewSchedulingContext(context.Background(), req, pods) + ctx := types.NewSchedulingContext(context.Background(), req, nil, pods) // Run the filter function multiple times and count the results affinityCount := 0 diff --git a/pkg/epp/scheduling/plugins/prefix/plugin_test.go b/pkg/epp/scheduling/plugins/prefix/plugin_test.go index 34d133a76..7e4e218ec 100644 --- a/pkg/epp/scheduling/plugins/prefix/plugin_test.go +++ b/pkg/epp/scheduling/plugins/prefix/plugin_test.go @@ -27,7 +27,7 @@ func TestPrefixPlugin(t *testing.T) { TargetModel: "test-model1", Prompt: "aaaaaa", } - ctx := types.NewSchedulingContext(context.Background(), req1, pods) + ctx := types.NewSchedulingContext(context.Background(), req1, nil, pods) plugin.PreSchedule(ctx) state, err := plugin.getPrefixState(ctx.CycleState) assert.NoError(t, err) @@ -51,7 +51,7 @@ func TestPrefixPlugin(t *testing.T) { TargetModel: "test-model2", Prompt: "bbbbbb", } - ctx = types.NewSchedulingContext(context.Background(), req2, pods) + ctx = types.NewSchedulingContext(context.Background(), req2, nil, pods) plugin.PreSchedule(ctx) state, err = plugin.getPrefixState(ctx.CycleState) assert.NoError(t, err) @@ -74,7 +74,7 @@ func TestPrefixPlugin(t *testing.T) { TargetModel: "test-model1", Prompt: "aaaabbbb", } - ctx = types.NewSchedulingContext(context.Background(), req3, pods) + ctx = types.NewSchedulingContext(context.Background(), req3, nil, pods) plugin.PreSchedule(ctx) state, err = plugin.getPrefixState(ctx.CycleState) assert.NoError(t, err) @@ -96,7 +96,7 @@ func TestPrefixPlugin(t *testing.T) { TargetModel: "test-model-new", Prompt: "aaaabbbb", } - ctx = types.NewSchedulingContext(context.Background(), req4, pods) + ctx = types.NewSchedulingContext(context.Background(), req4, nil, pods) plugin.PreSchedule(ctx) state, err = plugin.getPrefixState(ctx.CycleState) assert.NoError(t, err) @@ -118,7 +118,7 @@ func TestPrefixPlugin(t *testing.T) { TargetModel: "test-model1", Prompt: "aaaabbbbcccc", } - ctx = types.NewSchedulingContext(context.Background(), req5, pods) + ctx = types.NewSchedulingContext(context.Background(), req5, nil, pods) plugin.PreSchedule(ctx) state, err = plugin.getPrefixState(ctx.CycleState) assert.NoError(t, err) diff --git a/pkg/epp/scheduling/plugins/scorer/kvcache_test.go b/pkg/epp/scheduling/plugins/scorer/kvcache_test.go index 257a58c17..68be8a213 100644 --- a/pkg/epp/scheduling/plugins/scorer/kvcache_test.go +++ b/pkg/epp/scheduling/plugins/scorer/kvcache_test.go @@ -82,7 +82,7 @@ func TestKvCacheScorer(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ctx := types.NewSchedulingContext(context.Background(), &types.LLMRequest{}, tt.pods) + ctx := types.NewSchedulingContext(context.Background(), &types.LLMRequest{}, nil, tt.pods) scorer := &KVCacheScorer{} scores := scorer.Score(ctx, tt.pods) diff --git a/pkg/epp/scheduling/plugins/scorer/queue_test.go b/pkg/epp/scheduling/plugins/scorer/queue_test.go index 907681b25..d60eab66a 100644 --- a/pkg/epp/scheduling/plugins/scorer/queue_test.go +++ b/pkg/epp/scheduling/plugins/scorer/queue_test.go @@ -73,7 +73,7 @@ func TestQueueScorer(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ctx := types.NewSchedulingContext(context.Background(), &types.LLMRequest{}, tt.pods) + ctx := types.NewSchedulingContext(context.Background(), &types.LLMRequest{}, nil, tt.pods) scores := scorer.Score(ctx, tt.pods) for i, pod := range tt.pods { diff --git a/pkg/epp/scheduling/scheduler.go b/pkg/epp/scheduling/scheduler.go index 2e85619af..29ffee897 100644 --- a/pkg/epp/scheduling/scheduler.go +++ b/pkg/epp/scheduling/scheduler.go @@ -84,6 +84,7 @@ func NewSchedulerWithConfig(datastore Datastore, config *SchedulerConfig) *Sched scorers: config.scorers, picker: config.picker, postSchedulePlugins: config.postSchedulePlugins, + postResponsePlugins: config.postResponsePlugins, } } @@ -94,6 +95,7 @@ type Scheduler struct { scorers map[plugins.Scorer]int // map from scorer to its weight picker plugins.Picker postSchedulePlugins []plugins.PostSchedule + postResponsePlugins []plugins.PostResponse } type Datastore interface { @@ -113,7 +115,7 @@ func (s *Scheduler) Schedule(ctx context.Context, req *types.LLMRequest) (*types // Snapshot pod metrics from the datastore to: // 1. Reduce concurrent access to the datastore. // 2. Ensure consistent data during the scheduling operation of a request. - sCtx := types.NewSchedulingContext(ctx, req, types.ToSchedulerPodMetrics(s.datastore.PodGetAll())) + sCtx := types.NewSchedulingContext(ctx, req, nil, types.ToSchedulerPodMetrics(s.datastore.PodGetAll())) loggerDebug.Info(fmt.Sprintf("Scheduling a request, Metrics: %+v", sCtx.PodsSnapshot)) s.runPreSchedulePlugins(sCtx) @@ -211,3 +213,32 @@ func (s *Scheduler) runPostSchedulePlugins(ctx *types.SchedulingContext, res *ty metrics.RecordSchedulerPluginProcessingLatency(plugins.PostSchedulePluginType, plugin.Name(), time.Since(before)) } } + +// OnResponse is invoked during the processing of a response from an inference pod. It will invoke +// any defined plugins that process the response. +func (s *Scheduler) OnResponse(ctx context.Context, resp *types.LLMResponse, targetPodName string) { + // Snapshot pod metrics from the datastore to: + // 1. Reduce concurrent access to the datastore. + // 2. Ensure consistent data during the scheduling operation of a request. + pods := types.ToSchedulerPodMetrics(s.datastore.PodGetAll()) + var targetPod types.Pod + for _, pod := range pods { + if pod.GetPod().NamespacedName.String() == targetPodName { + targetPod = pod + break + } + } + + sCtx := types.NewSchedulingContext(ctx, nil, resp, pods) + + s.runPostResponsePlugins(sCtx, targetPod) +} + +func (s *Scheduler) runPostResponsePlugins(ctx *types.SchedulingContext, targetPod types.Pod) { + for _, plugin := range s.postResponsePlugins { + ctx.Logger.V(logutil.DEBUG).Info("Running post-response plugin", "plugin", plugin.Name()) + before := time.Now() + plugin.PostResponse(ctx, targetPod) + metrics.RecordSchedulerPluginProcessingLatency(plugins.PostResponsePluginType, plugin.Name(), time.Since(before)) + } +} diff --git a/pkg/epp/scheduling/scheduler_test.go b/pkg/epp/scheduling/scheduler_test.go index 7679cc63a..8936cc6c0 100644 --- a/pkg/epp/scheduling/scheduler_test.go +++ b/pkg/epp/scheduling/scheduler_test.go @@ -21,6 +21,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/google/uuid" k8stypes "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" // Import config for thresholds @@ -41,6 +42,7 @@ func TestSchedule(t *testing.T) { name: "no pods in datastore", req: &types.LLMRequest{ TargetModel: "any-model", + RequestId: uuid.NewString(), Critical: true, }, input: []*backendmetrics.FakePodMetrics{}, @@ -50,6 +52,7 @@ func TestSchedule(t *testing.T) { name: "critical request", req: &types.LLMRequest{ TargetModel: "critical", + RequestId: uuid.NewString(), Critical: true, }, // pod2 will be picked because it has relatively low queue size, with the requested @@ -113,6 +116,7 @@ func TestSchedule(t *testing.T) { name: "sheddable request, accepted", req: &types.LLMRequest{ TargetModel: "sheddable", + RequestId: uuid.NewString(), Critical: false, }, // pod1 will be picked because it has capacity for the sheddable request. @@ -175,6 +179,7 @@ func TestSchedule(t *testing.T) { name: "sheddable request, dropped", req: &types.LLMRequest{ TargetModel: "sheddable", + RequestId: uuid.NewString(), Critical: false, }, // All pods have higher KV cache thant the threshold, so the sheddable request will be @@ -352,7 +357,10 @@ func TestSchedulePlugins(t *testing.T) { // Initialize the scheduler scheduler := NewSchedulerWithConfig(&fakeDataStore{pods: test.input}, &test.config) - req := &types.LLMRequest{TargetModel: "test-model"} + req := &types.LLMRequest{ + TargetModel: "test-model", + RequestId: uuid.NewString(), + } got, err := scheduler.Schedule(context.Background(), req) // Validate error state @@ -419,6 +427,59 @@ func TestSchedulePlugins(t *testing.T) { } } +func TestPostResponse(t *testing.T) { + pr1 := &testPostResponse{ + NameRes: "pr1", + ExtraHeaders: map[string]string{"x-session-id": "qwer-asdf-zxcv"}, + ReceivedResponseHeaders: make(map[string]string), + } + + targetPod := k8stypes.NamespacedName{Name: "pod2"} + + tests := []struct { + name string + config SchedulerConfig + input []*backendmetrics.FakePodMetrics + responseHeaders map[string]string + wantUpdatedHeaders map[string]string + }{ + { + name: "Simple postResponse test", + config: SchedulerConfig{ + postResponsePlugins: []plugins.PostResponse{pr1}, + }, + input: []*backendmetrics.FakePodMetrics{ + {Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, + {Pod: &backend.Pod{NamespacedName: targetPod}}, + }, + responseHeaders: map[string]string{"Content-type": "application/json", "Content-Length": "1234"}, + wantUpdatedHeaders: map[string]string{"x-session-id": "qwer-asdf-zxcv", "Content-type": "application/json", "Content-Length": "1234"}, + }, + } + + for _, test := range tests { + scheduler := NewSchedulerWithConfig(&fakeDataStore{pods: test.input}, &test.config) + + headers := map[string]string{} + for k, v := range test.responseHeaders { + headers[k] = v + } + resp := &types.LLMResponse{ + Headers: headers, + } + + scheduler.OnResponse(context.Background(), resp, targetPod.String()) + + if diff := cmp.Diff(test.responseHeaders, pr1.ReceivedResponseHeaders); diff != "" { + t.Errorf("Unexpected output (-responseHeaders +ReceivedResponseHeaders): %v", diff) + } + + if diff := cmp.Diff(test.wantUpdatedHeaders, resp.Headers); diff != "" { + t.Errorf("Unexpected output (-wantUpdatedHeaders +resp.Headers): %v", diff) + } + } +} + type fakeDataStore struct { pods []*backendmetrics.FakePodMetrics } @@ -491,6 +552,23 @@ func (tp *TestPlugin) reset() { tp.NumOfPickerCandidates = 0 } +type testPostResponse struct { + NameRes string + ReceivedResponseHeaders map[string]string + ExtraHeaders map[string]string +} + +func (pr *testPostResponse) Name() string { return pr.NameRes } + +func (pr *testPostResponse) PostResponse(ctx *types.SchedulingContext, pod types.Pod) { + for key, value := range ctx.Resp.Headers { + pr.ReceivedResponseHeaders[key] = value + } + for key, value := range pr.ExtraHeaders { + ctx.Resp.Headers[key] = value + } +} + func findPods(ctx *types.SchedulingContext, names ...k8stypes.NamespacedName) []types.Pod { res := []types.Pod{} for _, pod := range ctx.PodsSnapshot { diff --git a/pkg/epp/scheduling/types/scheduling_context.go b/pkg/epp/scheduling/types/scheduling_context.go index 42d70c5da..37621806d 100644 --- a/pkg/epp/scheduling/types/scheduling_context.go +++ b/pkg/epp/scheduling/types/scheduling_context.go @@ -23,12 +23,14 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" ) -func NewSchedulingContext(ctx context.Context, req *LLMRequest, pods []Pod) *SchedulingContext { +func NewSchedulingContext(ctx context.Context, req *LLMRequest, resp *LLMResponse, pods []Pod) *SchedulingContext { + logger := log.FromContext(ctx).WithValues("request", req) return &SchedulingContext{ Context: ctx, Logger: logger, Req: req, + Resp: resp, PodsSnapshot: pods, CycleState: NewCycleState(), } @@ -39,6 +41,7 @@ type SchedulingContext struct { context.Context Logger logr.Logger Req *LLMRequest + Resp *LLMResponse PodsSnapshot []Pod // CycleState can be used by plugins to store state during a scheduling cycle, to communicate // between different extension points. diff --git a/pkg/epp/scheduling/types/types.go b/pkg/epp/scheduling/types/types.go index adf663bc6..a112794d1 100644 --- a/pkg/epp/scheduling/types/types.go +++ b/pkg/epp/scheduling/types/types.go @@ -27,6 +27,8 @@ import ( type LLMRequest struct { // TargetModel is the final target model after traffic split. TargetModel string + // RequestId is the Envoy generated Id for the request being processed + RequestId string // Critical is a boolean that specifies if a request is critical or not. Critical bool // Prompt is the prompt that was sent in the request body. @@ -39,6 +41,20 @@ func (r *LLMRequest) String() string { return fmt.Sprintf("TargetModel: %s, Critical: %t, PromptLength: %d, Headers: %v", r.TargetModel, r.Critical, len(r.Prompt), r.Headers) } +// LLMResponse contains information from the response received to be passed to plugins +type LLMResponse struct { + // RequestId is the Envoy generated Id for the request being processed + RequestId string + // Headers is a map of the response headers. Nil during body processing + Headers map[string]string + // Body Is the body of the response or nil during header processing + Body string + // IsStreaming indicates whether or not the response is being streamed by the model + IsStreaming bool + // EndOfStream when true indicates that this invocation contains the last chunk of the response + EndOfStream bool +} + type Pod interface { GetPod() *backend.Pod GetMetrics() *backendmetrics.Metrics diff --git a/test/integration/epp/hermetic_test.go b/test/integration/epp/hermetic_test.go index 244fadf9c..938787635 100644 --- a/test/integration/epp/hermetic_test.go +++ b/test/integration/epp/hermetic_test.go @@ -814,6 +814,12 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { RawValue: []byte("true"), }, }, + { + Header: &configPb.HeaderValue{ + Key: "content-type", + RawValue: []uint8("application/json"), + }, + }, }, }, }, @@ -913,6 +919,12 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { RawValue: []byte("true"), }, }, + { + Header: &configPb.HeaderValue{ + Key: "content-type", + RawValue: []uint8("application/json"), + }, + }, }, }, }, @@ -1050,6 +1062,18 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { RawValue: []byte("true"), }, }, + { + Header: &configPb.HeaderValue{ + Key: "content-type", + RawValue: []byte("text/event-stream"), + }, + }, + { + Header: &configPb.HeaderValue{ + Key: "status", + RawValue: []byte("200"), + }, + }, }, }, }, From baf3d7d18bf7ef4ba698d2d69cb238e9db3731f7 Mon Sep 17 00:00:00 2001 From: Cong Liu Date: Mon, 12 May 2025 10:05:15 -0700 Subject: [PATCH 15/53] Add prefix aware request scheduling proposal (#602) * Add prefex aware routing proposal * Update, add a diagram * Add future work * Update to PR number, clarify terminologies --- .../README.md | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 docs/proposals/0602-prefix-cache-aware-routing-proposal/README.md diff --git a/docs/proposals/0602-prefix-cache-aware-routing-proposal/README.md b/docs/proposals/0602-prefix-cache-aware-routing-proposal/README.md new file mode 100644 index 000000000..468e3be8e --- /dev/null +++ b/docs/proposals/0602-prefix-cache-aware-routing-proposal/README.md @@ -0,0 +1,124 @@ +# Prefix Cache Aware Request Scheduling + +## Overview + +Prefix caching is a well-known technique in LLM inference to save duplicate tensor computation for prompts with the same prefix tokens, and is available in many model servers or model as a service providers. Leveraging prefix caching can significantly boost system performance, especially the time to first token (TTFT). Given that EPP has a global view of requests and model servers in the `InferencePool`, it can schedule requests intelligently to maximize the global prefix cache hit rate. + +### Goals + +Implement a prefix aware scheduling algorithm on EPP to maximize the cache hit rate on the model servers. + +### Non-goals + +* Change how model server manages prefix caches, or add any prefix cache APIs. +* Coordinate cache beyond accelerator HBM cache, such as remote caches. + +## Terminology + +In the gateway-api-inference-extension project, we use the term "request scheduling" to mean the process of estimating the cost of a request and placing it to the best backend server. This is different from "model routing" which oftentimes means picking the right model server endpoint based on cost, availability, etc. However, we acknowledge that various other projects uses the term "routing" or "router" to mean what we call "request scheduling". In this doc, we use "scheduling" when referring to the inference extension, and "routing" or "router" when referring to other projects, respecting the terminology of those projects. + +## Existing Solutions + +[vLLM](https://docs.vllm.ai/en/latest/features/automatic_prefix_caching.html) has the automatic prefix cache (APC) feature by caching in the accelerator HBM, and uses an LRU cache eviction strategy. + +[vLLM production stack](https://github.com/vllm-project/production-stack/issues/59) is exploring a prefix aware router to exploit the APC feature of the vLLM. The WIP [PR](https://github.com/vllm-project/production-stack/issues/59#issuecomment-2677268482) implements two strategies: a HashTrie based matching and a SimHash based consistent hashing. The HashTrie solution is showing better cache hit rate. + +[SGLang](https://github.com/sgl-project/sglang/blob/4d2a88bdffe91168dfc73ef7e3bc9100ba96686b/sgl-router/src/router.rs#L61) has a cache aware routing strategy which builds a radix tree based on request history. + +[AIBrix](https://aibrix.readthedocs.io/latest/features/distributed-kv-cache.html) uses a distributed prefix cache pool and has a customized vLLM to support loading cache from the pool. At request routing, it has a [Prefix Router](https://github.com/vllm-project/aibrix/blob/6feec99d77c84e371da9c535054c2b8aa8912704/pkg/plugins/gateway/algorithms/prefix_cache.go#L64) that maximizes prefix cache hit on model server HBM. It currently implements a hash based (similar to vLLM) and radix tree based (similar to SGLang) matching strategy. + +[KubeAI](https://www.kubeai.org/blog/2025/02/26/llm-load-balancing-at-scale-chwbl/) uses a Consistent Hashing with Bounded Loads (CHWBL) algorithm which hashes request prefixes up to a configurable length (and therefore will lose some accuracy), and use an "overflow" strategy when the server is hot loaded. + +## Design Options + +### Session affinity + +Session affinity is based on client attributes such as IP address. It works well for use cases such as multi-turn conversations, where requests from the same client tend to share the same prefixes. This, of course, highly depends on the nature of the use case. + +Pros: + +* Easy to implement/understand + +Cons: + +* Limited use case +* Does not exploit prefix cache between different clients +* Using client IP isn't always reliable, will likely need client to provide "session info" for good affinity + +### Prefix affinity consistent hashing + +This goes a step beyond the session affinity by using a prefix aware hash function to schedule requests with similar prefixes to the same or similar servers. A naive hash function can be just taking the hash of the first N characters/tokens of the request, and therefore all requests with the same first N characters/tokens will be scheduled to the same server. The [vLLM production stack](https://github.com/vllm-project/production-stack/issues/59) is exploring this strategy using simhash, and preliminary experiments showed mixed results. KubeAI uses a simple strategy to only hash request prefix up to a configurable `prefixCharLength`. Its effectiveness is likely highly dependent on the input length distribution. + +Pros: + +* (Compared to session affinity) Is aware of prefix and not limited to per-client affinity +* Small memory overhead (just need to store the ring of the servers) + +Cons: + +* Highly depends on the effectiveness of the prefix aware hash function. +* Consistent hashing can be challenging to reason about. + +### Report prefix cache indexes on the EPP + +If the EPP knows what prefixes are currently cached on each model server replica, it can make the optimal decision. A potential solution is to have the model server (or with a sidecar) report the kv cache indexes to the EPP. + +Pros: + +* Best cache hit rate in theory + +Cons: + +* Requires API changes on the model servers to report the cache indexes. +* Reporting the cache indexes in real time requires non-trivial network bandwidth. + +### Approximate prefix index on the EPP + +This builds on the intuition that if `requestA=prefix+XX` was scheduled to server 1, then scheduling `requestB=prefix+YY` to the same server will likely hit its prefix cache. Therefore the EPP can build an approximate index table of the prefix caches on all the backend servers, by mimicking a similar cache eviction strategy of the model server (e.g., LRU). + +Pros: + +* (Compared to the session affinity strategy) Broader application to most use cases and doesn't require any client integration. +* (Compared to the consistent hashing strategy) Easy to implement and explain and is more effective. + +Cons: + +* Relies on knowledge of the cache eviction strategy of the model server, and may need careful tuning for different environments (e.g., model server with different total kv cache space may have different characteristics of cache eviction). +* Complexity in managing cache state (eviction, memory limit) +* An in memory cache is preferred for high performance. However, that means cache need to be rebuilt for restarts. Moreover, cache hit performance decreases with multiple active EPP replicas. + +## Proposal + +Based on the above discussion, I propose implementing "Approximate prefix cache on the EPP" solution, which has the advantage of fast time to market, automatic prefix cache (without needing client integration), decent performance with the cost of degraded performance when sharded. + +A request is broken down into N chunks of the same number of characters (we don’t necessarily need to tokenize). For each chunk we will calculate a hash based on the **content of the chunk + hash of the prefix**: `hash(chunk i) = hash(chunk i content + hash(chunk i-1))`. This gives us a nice property that if we find a match of a chunk hash, then we know all its prefix chunk hashes match as well. This is very similar to how vLLM does it. + +When we schedule a request `r1` with `N` chunks to a server `s1`, we update the approximate cache index table like so: + +``` +hash(chunk 1): append s1 +hash(chunk 2): append s1 +… +hash(chunk N): append s1 +``` + +This means all these N chunks are cached on server `s1`. + +When the EPP receives a new request `r2`, we calculate its chunk hashes, and look up the table to find a server with longest prefix matching. + + + +[Image source](https://docs.google.com/drawings/d/1KL5DKh42Z_XzvcnejUcRymu99_HwW9y8U29IrPzRCss/edit?usp=sharing) + + +## How does prefix cache affinity work with LoRA affinity and load-aware scheduling + +1. Prefix cache needs to be LoRA aware, as different adapters don’t share the same kv cache. Therefore when finding prefix matches, we only match for the same model/adapter. +2. Prefix affinity needs to be aware of the server load and avoid overloading servers. We can calculate a combined weighted score of servers depending on: prefix cache hit ratio, queue length and k-v cache utilization to achieve a good balance between prefix cache affinity and load balancing. + +## Future work + +The main drawback of the proposed solution is the degraded performance when EPP is sharded, as the in memory cache index table loses a global view of all requests. To mitigate this issue, we can consider: + +* Establish a "prefix cache index reporting" protocol with model servers, and use a combination of the approximate cache index with reported indexes. This can potentially work better than a solution purely based on reported indexes, as discussed in [`Solution 3`](https://github.com/kubernetes-sigs/gateway-api-inference-extension/discussions/678). +* When scheduling a request with low or no prefix cache in the EPP in memory index table, use the consistent hashing strategy to improve the predictability of two EPPs picking the same server, instead of random picking. \ No newline at end of file From 7207ed69c4bbeb310046d85723fe9c4692af4601 Mon Sep 17 00:00:00 2001 From: Daneyon Hansen Date: Mon, 12 May 2025 15:21:15 -0700 Subject: [PATCH 16/53] Docs: Bumps Kgateway to v2.0.2 (#823) Signed-off-by: Daneyon Hansen --- config/manifests/gateway/kgateway/httproute.yaml | 1 - site-src/guides/index.md | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/config/manifests/gateway/kgateway/httproute.yaml b/config/manifests/gateway/kgateway/httproute.yaml index 03967729d..18e90ced6 100644 --- a/config/manifests/gateway/kgateway/httproute.yaml +++ b/config/manifests/gateway/kgateway/httproute.yaml @@ -12,7 +12,6 @@ spec: - group: inference.networking.x-k8s.io kind: InferencePool name: vllm-llama3-8b-instruct - port: 8000 # Remove when https://github.com/kgateway-dev/kgateway/issues/10987 is fixed. matches: - path: type: PathPrefix diff --git a/site-src/guides/index.md b/site-src/guides/index.md index 89811263f..99df01668 100644 --- a/site-src/guides/index.md +++ b/site-src/guides/index.md @@ -200,7 +200,7 @@ This quickstart guide is intended for engineers familiar with k8s and model serv 2. Set the Kgateway version and install the Kgateway CRDs. ```bash - KGTW_VERSION=v2.0.0 + KGTW_VERSION=v2.0.2 helm upgrade -i --create-namespace --namespace kgateway-system --version $KGTW_VERSION kgateway-crds oci://cr.kgateway.dev/kgateway-dev/charts/kgateway-crds ``` From 519bee8879bc428c4232226df82d603c31086b50 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Tue, 13 May 2025 01:37:15 +0300 Subject: [PATCH 17/53] renamed Metrics to MetricsState and move to a separate file (#822) Signed-off-by: Nir Rozenbaum --- pkg/epp/backend/metrics/fake.go | 10 +-- pkg/epp/backend/metrics/metrics.go | 6 +- pkg/epp/backend/metrics/metrics_state.go | 80 +++++++++++++++++++ pkg/epp/backend/metrics/metrics_test.go | 22 ++--- pkg/epp/backend/metrics/pod_metrics.go | 6 +- pkg/epp/backend/metrics/pod_metrics_test.go | 10 +-- pkg/epp/backend/metrics/types.go | 59 +------------- pkg/epp/datastore/datastore_test.go | 22 ++--- .../metrics/collectors/inference_pool_test.go | 4 +- .../scheduling/plugins/filter/filter_test.go | 32 ++++---- .../scheduling/plugins/scorer/kvcache_test.go | 18 ++--- .../scheduling/plugins/scorer/queue_test.go | 14 ++-- pkg/epp/scheduling/scheduler_test.go | 22 ++--- pkg/epp/scheduling/types/types.go | 10 +-- test/integration/epp/hermetic_test.go | 26 +++--- 15 files changed, 183 insertions(+), 158 deletions(-) create mode 100644 pkg/epp/backend/metrics/metrics_state.go diff --git a/pkg/epp/backend/metrics/fake.go b/pkg/epp/backend/metrics/fake.go index 58d050260..5599d4ec0 100644 --- a/pkg/epp/backend/metrics/fake.go +++ b/pkg/epp/backend/metrics/fake.go @@ -31,7 +31,7 @@ import ( // FakePodMetrics is an implementation of PodMetrics that doesn't run the async refresh loop. type FakePodMetrics struct { Pod *backend.Pod - Metrics *Metrics + Metrics *MetricsState } func (fpm *FakePodMetrics) String() string { @@ -41,7 +41,7 @@ func (fpm *FakePodMetrics) String() string { func (fpm *FakePodMetrics) GetPod() *backend.Pod { return fpm.Pod } -func (fpm *FakePodMetrics) GetMetrics() *Metrics { +func (fpm *FakePodMetrics) GetMetrics() *MetricsState { return fpm.Metrics } func (fpm *FakePodMetrics) UpdatePod(pod *corev1.Pod) { @@ -53,10 +53,10 @@ type FakePodMetricsClient struct { errMu sync.RWMutex Err map[types.NamespacedName]error resMu sync.RWMutex - Res map[types.NamespacedName]*Metrics + Res map[types.NamespacedName]*MetricsState } -func (f *FakePodMetricsClient) FetchMetrics(ctx context.Context, pod *backend.Pod, existing *Metrics, port int32) (*Metrics, error) { +func (f *FakePodMetricsClient) FetchMetrics(ctx context.Context, pod *backend.Pod, existing *MetricsState, port int32) (*MetricsState, error) { f.errMu.RLock() err, ok := f.Err[pod.NamespacedName] f.errMu.RUnlock() @@ -73,7 +73,7 @@ func (f *FakePodMetricsClient) FetchMetrics(ctx context.Context, pod *backend.Po return res.Clone(), nil } -func (f *FakePodMetricsClient) SetRes(new map[types.NamespacedName]*Metrics) { +func (f *FakePodMetricsClient) SetRes(new map[types.NamespacedName]*MetricsState) { f.resMu.Lock() defer f.resMu.Unlock() f.Res = new diff --git a/pkg/epp/backend/metrics/metrics.go b/pkg/epp/backend/metrics/metrics.go index 4cf561790..8899e00ce 100644 --- a/pkg/epp/backend/metrics/metrics.go +++ b/pkg/epp/backend/metrics/metrics.go @@ -41,7 +41,7 @@ type PodMetricsClientImpl struct { } // FetchMetrics fetches metrics from a given pod, clones the existing metrics object and returns an updated one. -func (p *PodMetricsClientImpl) FetchMetrics(ctx context.Context, pod *backend.Pod, existing *Metrics, port int32) (*Metrics, error) { +func (p *PodMetricsClientImpl) FetchMetrics(ctx context.Context, pod *backend.Pod, existing *MetricsState, port int32) (*MetricsState, error) { // Currently the metrics endpoint is hard-coded, which works with vLLM. // TODO(https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/16): Consume this from InferencePool config. url := "http://" + pod.Address + ":" + strconv.Itoa(int(port)) + "/metrics" @@ -73,8 +73,8 @@ func (p *PodMetricsClientImpl) FetchMetrics(ctx context.Context, pod *backend.Po // promToPodMetrics updates internal pod metrics with scraped Prometheus metrics. func (p *PodMetricsClientImpl) promToPodMetrics( metricFamilies map[string]*dto.MetricFamily, - existing *Metrics, -) (*Metrics, error) { + existing *MetricsState, +) (*MetricsState, error) { var errs error updated := existing.Clone() diff --git a/pkg/epp/backend/metrics/metrics_state.go b/pkg/epp/backend/metrics/metrics_state.go new file mode 100644 index 000000000..3be7d535a --- /dev/null +++ b/pkg/epp/backend/metrics/metrics_state.go @@ -0,0 +1,80 @@ +/* +Copyright 2025 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 metrics + +import ( + "fmt" + "time" +) + +// newMetricsState initializes a new MetricsState and returns its pointer. +func newMetricsState() *MetricsState { + return &MetricsState{ + ActiveModels: make(map[string]int), + WaitingModels: make(map[string]int), + } +} + +// MetricsState holds the latest state of the metrics that were scraped from a pod. +type MetricsState struct { + // ActiveModels is a set of models(including LoRA adapters) that are currently cached to GPU. + ActiveModels map[string]int + WaitingModels map[string]int + // MaxActiveModels is the maximum number of models that can be loaded to GPU. + MaxActiveModels int + RunningQueueSize int + WaitingQueueSize int + KVCacheUsagePercent float64 + KvCacheMaxTokenCapacity int + + // UpdateTime record the last time when the metrics were updated. + UpdateTime time.Time +} + +// String returns a string with all MetricState information +func (s *MetricsState) String() string { + if s == nil { + return "" + } + return fmt.Sprintf("%+v", *s) +} + +// Clone creates a copy of MetricsState and returns its pointer. +// Clone returns nil if the object being cloned is nil. +func (s *MetricsState) Clone() *MetricsState { + if s == nil { + return nil + } + activeModels := make(map[string]int, len(s.ActiveModels)) + for key, value := range s.ActiveModels { + activeModels[key] = value + } + waitingModels := make(map[string]int, len(s.WaitingModels)) + for key, value := range s.WaitingModels { + waitingModels[key] = value + } + return &MetricsState{ + ActiveModels: activeModels, + WaitingModels: waitingModels, + MaxActiveModels: s.MaxActiveModels, + RunningQueueSize: s.RunningQueueSize, + WaitingQueueSize: s.WaitingQueueSize, + KVCacheUsagePercent: s.KVCacheUsagePercent, + KvCacheMaxTokenCapacity: s.KvCacheMaxTokenCapacity, + UpdateTime: s.UpdateTime, + } +} diff --git a/pkg/epp/backend/metrics/metrics_test.go b/pkg/epp/backend/metrics/metrics_test.go index 531270100..bfc3e01fa 100644 --- a/pkg/epp/backend/metrics/metrics_test.go +++ b/pkg/epp/backend/metrics/metrics_test.go @@ -377,8 +377,8 @@ func TestPromToPodMetrics(t *testing.T) { name string metricFamilies map[string]*dto.MetricFamily mapping *MetricMapping - existingMetrics *Metrics - expectedMetrics *Metrics + existingMetrics *MetricsState + expectedMetrics *MetricsState expectedErr error // Count of expected errors }{ { @@ -401,8 +401,8 @@ func TestPromToPodMetrics(t *testing.T) { KVCacheUtilization: &MetricSpec{MetricName: "vllm_usage"}, LoraRequestInfo: &MetricSpec{MetricName: "vllm:lora_requests_info"}, }, - existingMetrics: &Metrics{}, - expectedMetrics: &Metrics{ + existingMetrics: &MetricsState{}, + expectedMetrics: &MetricsState{ WaitingQueueSize: 7, KVCacheUsagePercent: 0.8, ActiveModels: map[string]int{"lora1": 0, "lora2": 0}, @@ -418,8 +418,8 @@ func TestPromToPodMetrics(t *testing.T) { KVCacheUtilization: &MetricSpec{MetricName: "vllm_usage"}, LoraRequestInfo: &MetricSpec{MetricName: "vllm:lora_requests_info"}, }, - existingMetrics: &Metrics{ActiveModels: map[string]int{}, WaitingModels: map[string]int{}}, - expectedMetrics: &Metrics{ActiveModels: map[string]int{}, WaitingModels: map[string]int{}}, + existingMetrics: &MetricsState{ActiveModels: map[string]int{}, WaitingModels: map[string]int{}}, + expectedMetrics: &MetricsState{ActiveModels: map[string]int{}, WaitingModels: map[string]int{}}, expectedErr: multierr.Combine(errors.New("metric family \"vllm_waiting\" not found"), errors.New("metric family \"vllm_usage\" not found"), errors.New("metric family \"vllm:lora_requests_info\" not found")), }, { @@ -437,8 +437,8 @@ func TestPromToPodMetrics(t *testing.T) { KVCacheUtilization: &MetricSpec{MetricName: "vllm_usage"}, LoraRequestInfo: &MetricSpec{MetricName: "vllm:lora_requests_info"}, }, - existingMetrics: &Metrics{}, - expectedMetrics: &Metrics{ + existingMetrics: &MetricsState{}, + expectedMetrics: &MetricsState{ WaitingQueueSize: 0, KVCacheUsagePercent: 0.8, ActiveModels: map[string]int{"lora1": 0, "lora2": 0}, @@ -457,8 +457,8 @@ func TestPromToPodMetrics(t *testing.T) { mapping: &MetricMapping{ LoraRequestInfo: &MetricSpec{MetricName: "vllm:lora_requests_info"}, }, - existingMetrics: &Metrics{}, - expectedMetrics: &Metrics{ + existingMetrics: &MetricsState{}, + expectedMetrics: &MetricsState{ ActiveModels: map[string]int{"lora1": 0}, WaitingModels: map[string]int{}, MaxActiveModels: 0, // Should still default to 0. @@ -494,7 +494,7 @@ func TestFetchMetrics(t *testing.T) { Name: "pod", }, } - existing := &Metrics{} + existing := &MetricsState{} p := &PodMetricsClientImpl{} // No MetricMapping needed for this basic test _, err := p.FetchMetrics(ctx, pod, existing, 9999) // Use a port that's unlikely to be in use. diff --git a/pkg/epp/backend/metrics/pod_metrics.go b/pkg/epp/backend/metrics/pod_metrics.go index 8660bc3c9..f885dbf9f 100644 --- a/pkg/epp/backend/metrics/pod_metrics.go +++ b/pkg/epp/backend/metrics/pod_metrics.go @@ -37,7 +37,7 @@ const ( type podMetrics struct { pod atomic.Pointer[backend.Pod] - metrics atomic.Pointer[Metrics] + metrics atomic.Pointer[MetricsState] pmc PodMetricsClient ds Datastore interval time.Duration @@ -49,7 +49,7 @@ type podMetrics struct { } type PodMetricsClient interface { - FetchMetrics(ctx context.Context, pod *backend.Pod, existing *Metrics, port int32) (*Metrics, error) + FetchMetrics(ctx context.Context, pod *backend.Pod, existing *MetricsState, port int32) (*MetricsState, error) } func (pm *podMetrics) String() string { @@ -60,7 +60,7 @@ func (pm *podMetrics) GetPod() *backend.Pod { return pm.pod.Load() } -func (pm *podMetrics) GetMetrics() *Metrics { +func (pm *podMetrics) GetMetrics() *MetricsState { return pm.metrics.Load() } diff --git a/pkg/epp/backend/metrics/pod_metrics_test.go b/pkg/epp/backend/metrics/pod_metrics_test.go index e79c1bf0b..c654d068d 100644 --- a/pkg/epp/backend/metrics/pod_metrics_test.go +++ b/pkg/epp/backend/metrics/pod_metrics_test.go @@ -36,7 +36,7 @@ var ( Namespace: "default", }, } - initial = &Metrics{ + initial = &MetricsState{ WaitingQueueSize: 0, KVCacheUsagePercent: 0.2, MaxActiveModels: 2, @@ -46,7 +46,7 @@ var ( }, WaitingModels: map[string]int{}, } - updated = &Metrics{ + updated = &MetricsState{ WaitingQueueSize: 9999, KVCacheUsagePercent: 0.99, MaxActiveModels: 99, @@ -69,16 +69,16 @@ func TestMetricsRefresh(t *testing.T) { namespacedName := types.NamespacedName{Name: pod1.Name, Namespace: pod1.Namespace} // Use SetRes to simulate an update of metrics from the pod. // Verify that the metrics are updated. - pmc.SetRes(map[types.NamespacedName]*Metrics{namespacedName: initial}) + pmc.SetRes(map[types.NamespacedName]*MetricsState{namespacedName: initial}) condition := func(collect *assert.CollectT) { - assert.True(collect, cmp.Equal(pm.GetMetrics(), initial, cmpopts.IgnoreFields(Metrics{}, "UpdateTime"))) + assert.True(collect, cmp.Equal(pm.GetMetrics(), initial, cmpopts.IgnoreFields(MetricsState{}, "UpdateTime"))) } assert.EventuallyWithT(t, condition, time.Second, time.Millisecond) // Stop the loop, and simulate metric update again, this time the PodMetrics won't get the // new update. pm.StopRefreshLoop() - pmc.SetRes(map[types.NamespacedName]*Metrics{namespacedName: updated}) + pmc.SetRes(map[types.NamespacedName]*MetricsState{namespacedName: updated}) // Still expect the same condition (no metrics update). assert.EventuallyWithT(t, condition, time.Second, time.Millisecond) } diff --git a/pkg/epp/backend/metrics/types.go b/pkg/epp/backend/metrics/types.go index 4932e3ac5..92478db17 100644 --- a/pkg/epp/backend/metrics/types.go +++ b/pkg/epp/backend/metrics/types.go @@ -19,7 +19,6 @@ package metrics import ( "context" - "fmt" "sync" "time" @@ -51,7 +50,7 @@ func (f *PodMetricsFactory) NewPodMetrics(parentCtx context.Context, in *corev1. logger: log.FromContext(parentCtx).WithValues("pod", pod.NamespacedName), } pm.pod.Store(pod) - pm.metrics.Store(newMetrics()) + pm.metrics.Store(newMetricsState()) pm.startRefreshLoop(parentCtx) return pm @@ -59,62 +58,8 @@ func (f *PodMetricsFactory) NewPodMetrics(parentCtx context.Context, in *corev1. type PodMetrics interface { GetPod() *backend.Pod - GetMetrics() *Metrics + GetMetrics() *MetricsState UpdatePod(*corev1.Pod) StopRefreshLoop() String() string } - -type Metrics struct { - // ActiveModels is a set of models(including LoRA adapters) that are currently cached to GPU. - ActiveModels map[string]int - WaitingModels map[string]int - // MaxActiveModels is the maximum number of models that can be loaded to GPU. - MaxActiveModels int - RunningQueueSize int - WaitingQueueSize int - KVCacheUsagePercent float64 - KvCacheMaxTokenCapacity int - - // UpdateTime record the last time when the metrics were updated. - UpdateTime time.Time -} - -func newMetrics() *Metrics { - return &Metrics{ - ActiveModels: make(map[string]int), - WaitingModels: make(map[string]int), - } -} - -func (m *Metrics) String() string { - if m == nil { - return "" - } - return fmt.Sprintf("%+v", *m) -} - -func (m *Metrics) Clone() *Metrics { - if m == nil { - return nil - } - cm := make(map[string]int, len(m.ActiveModels)) - for k, v := range m.ActiveModels { - cm[k] = v - } - wm := make(map[string]int, len(m.WaitingModels)) - for k, v := range m.WaitingModels { - wm[k] = v - } - clone := &Metrics{ - ActiveModels: cm, - WaitingModels: wm, - MaxActiveModels: m.MaxActiveModels, - RunningQueueSize: m.RunningQueueSize, - WaitingQueueSize: m.WaitingQueueSize, - KVCacheUsagePercent: m.KVCacheUsagePercent, - KvCacheMaxTokenCapacity: m.KvCacheMaxTokenCapacity, - UpdateTime: m.UpdateTime, - } - return clone -} diff --git a/pkg/epp/datastore/datastore_test.go b/pkg/epp/datastore/datastore_test.go index b6466e6b2..cf1f610cb 100644 --- a/pkg/epp/datastore/datastore_test.go +++ b/pkg/epp/datastore/datastore_test.go @@ -237,7 +237,7 @@ var ( Name: "pod1", }, } - pod1Metrics = &backendmetrics.Metrics{ + pod1Metrics = &backendmetrics.MetricsState{ WaitingQueueSize: 0, KVCacheUsagePercent: 0.2, MaxActiveModels: 2, @@ -252,7 +252,7 @@ var ( Name: "pod2", }, } - pod2Metrics = &backendmetrics.Metrics{ + pod2Metrics = &backendmetrics.MetricsState{ WaitingQueueSize: 1, KVCacheUsagePercent: 0.2, MaxActiveModels: 2, @@ -276,29 +276,29 @@ func TestMetrics(t *testing.T) { name string pmc backendmetrics.PodMetricsClient storePods []*corev1.Pod - want []*backendmetrics.Metrics + want []*backendmetrics.MetricsState }{ { name: "Probing metrics success", pmc: &backendmetrics.FakePodMetricsClient{ - Res: map[types.NamespacedName]*backendmetrics.Metrics{ + Res: map[types.NamespacedName]*backendmetrics.MetricsState{ pod1NamespacedName: pod1Metrics, pod2NamespacedName: pod2Metrics, }, }, storePods: []*corev1.Pod{pod1, pod2}, - want: []*backendmetrics.Metrics{pod1Metrics, pod2Metrics}, + want: []*backendmetrics.MetricsState{pod1Metrics, pod2Metrics}, }, { name: "Only pods in are probed", pmc: &backendmetrics.FakePodMetricsClient{ - Res: map[types.NamespacedName]*backendmetrics.Metrics{ + Res: map[types.NamespacedName]*backendmetrics.MetricsState{ pod1NamespacedName: pod1Metrics, pod2NamespacedName: pod2Metrics, }, }, storePods: []*corev1.Pod{pod1}, - want: []*backendmetrics.Metrics{pod1Metrics}, + want: []*backendmetrics.MetricsState{pod1Metrics}, }, { name: "Probing metrics error", @@ -306,12 +306,12 @@ func TestMetrics(t *testing.T) { Err: map[types.NamespacedName]error{ pod2NamespacedName: errors.New("injected error"), }, - Res: map[types.NamespacedName]*backendmetrics.Metrics{ + Res: map[types.NamespacedName]*backendmetrics.MetricsState{ pod1NamespacedName: pod1Metrics, }, }, storePods: []*corev1.Pod{pod1, pod2}, - want: []*backendmetrics.Metrics{ + want: []*backendmetrics.MetricsState{ pod1Metrics, // Failed to fetch pod2 metrics so it remains the default values. { @@ -343,11 +343,11 @@ func TestMetrics(t *testing.T) { } assert.EventuallyWithT(t, func(t *assert.CollectT) { got := ds.PodGetAll() - metrics := []*backendmetrics.Metrics{} + metrics := []*backendmetrics.MetricsState{} for _, one := range got { metrics = append(metrics, one.GetMetrics()) } - diff := cmp.Diff(test.want, metrics, cmpopts.IgnoreFields(backendmetrics.Metrics{}, "UpdateTime"), cmpopts.SortSlices(func(a, b *backendmetrics.Metrics) bool { + diff := cmp.Diff(test.want, metrics, cmpopts.IgnoreFields(backendmetrics.MetricsState{}, "UpdateTime"), cmpopts.SortSlices(func(a, b *backendmetrics.MetricsState) bool { return a.String() < b.String() })) assert.Equal(t, "", diff, "Unexpected diff (+got/-want)") diff --git a/pkg/epp/metrics/collectors/inference_pool_test.go b/pkg/epp/metrics/collectors/inference_pool_test.go index bcf91f5c0..b7ddf019d 100644 --- a/pkg/epp/metrics/collectors/inference_pool_test.go +++ b/pkg/epp/metrics/collectors/inference_pool_test.go @@ -40,7 +40,7 @@ var ( }, } pod1NamespacedName = types.NamespacedName{Name: pod1.Name, Namespace: pod1.Namespace} - pod1Metrics = &backendmetrics.Metrics{ + pod1Metrics = &backendmetrics.MetricsState{ WaitingQueueSize: 100, KVCacheUsagePercent: 0.2, MaxActiveModels: 2, @@ -62,7 +62,7 @@ func TestNoMetricsCollected(t *testing.T) { func TestMetricsCollected(t *testing.T) { pmc := &backendmetrics.FakePodMetricsClient{ - Res: map[types.NamespacedName]*backendmetrics.Metrics{ + Res: map[types.NamespacedName]*backendmetrics.MetricsState{ pod1NamespacedName: pod1Metrics, }, } diff --git a/pkg/epp/scheduling/plugins/filter/filter_test.go b/pkg/epp/scheduling/plugins/filter/filter_test.go index 1737f6190..3f844740a 100644 --- a/pkg/epp/scheduling/plugins/filter/filter_test.go +++ b/pkg/epp/scheduling/plugins/filter/filter_test.go @@ -64,29 +64,29 @@ func TestFilter(t *testing.T) { filter: NewLeastQueueFilter(), input: []types.Pod{ &types.PodMetrics{ - Metrics: &backendmetrics.Metrics{ + MetricsState: &backendmetrics.MetricsState{ WaitingQueueSize: 0, }, }, &types.PodMetrics{ - Metrics: &backendmetrics.Metrics{ + MetricsState: &backendmetrics.MetricsState{ WaitingQueueSize: 3, }, }, &types.PodMetrics{ - Metrics: &backendmetrics.Metrics{ + MetricsState: &backendmetrics.MetricsState{ WaitingQueueSize: 10, }, }, }, output: []types.Pod{ &types.PodMetrics{ - Metrics: &backendmetrics.Metrics{ + MetricsState: &backendmetrics.MetricsState{ WaitingQueueSize: 0, }, }, &types.PodMetrics{ - Metrics: &backendmetrics.Metrics{ + MetricsState: &backendmetrics.MetricsState{ WaitingQueueSize: 3, }, }, @@ -103,29 +103,29 @@ func TestFilter(t *testing.T) { filter: NewLeastKVCacheFilter(), input: []types.Pod{ &types.PodMetrics{ - Metrics: &backendmetrics.Metrics{ + MetricsState: &backendmetrics.MetricsState{ KVCacheUsagePercent: 0, }, }, &types.PodMetrics{ - Metrics: &backendmetrics.Metrics{ + MetricsState: &backendmetrics.MetricsState{ KVCacheUsagePercent: 0.3, }, }, &types.PodMetrics{ - Metrics: &backendmetrics.Metrics{ + MetricsState: &backendmetrics.MetricsState{ KVCacheUsagePercent: 1.0, }, }, }, output: []types.Pod{ &types.PodMetrics{ - Metrics: &backendmetrics.Metrics{ + MetricsState: &backendmetrics.MetricsState{ KVCacheUsagePercent: 0, }, }, &types.PodMetrics{ - Metrics: &backendmetrics.Metrics{ + MetricsState: &backendmetrics.MetricsState{ KVCacheUsagePercent: 0.3, }, }, @@ -138,21 +138,21 @@ func TestFilter(t *testing.T) { input: []types.Pod{ &types.PodMetrics{ // This pod should be returned. - Metrics: &backendmetrics.Metrics{ + MetricsState: &backendmetrics.MetricsState{ WaitingQueueSize: 0, KVCacheUsagePercent: 0, }, }, &types.PodMetrics{ // Queue is non zero, despite low kv cache, should not return. - Metrics: &backendmetrics.Metrics{ + MetricsState: &backendmetrics.MetricsState{ WaitingQueueSize: 1, KVCacheUsagePercent: 0.3, }, }, &types.PodMetrics{ // High kv cache despite zero queue, should not return - Metrics: &backendmetrics.Metrics{ + MetricsState: &backendmetrics.MetricsState{ WaitingQueueSize: 0, KVCacheUsagePercent: 1.0, }, @@ -160,7 +160,7 @@ func TestFilter(t *testing.T) { }, output: []types.Pod{ &types.PodMetrics{ - Metrics: &backendmetrics.Metrics{ + MetricsState: &backendmetrics.MetricsState{ WaitingQueueSize: 0, KVCacheUsagePercent: 0, }, @@ -213,7 +213,7 @@ func TestLoRASoftAffinityDistribution(t *testing.T) { pods := []types.Pod{ &types.PodMetrics{ Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "affinity-pod"}}, - Metrics: &backendmetrics.Metrics{ + MetricsState: &backendmetrics.MetricsState{ MaxActiveModels: 2, ActiveModels: map[string]int{ testAffinityModel: 1, @@ -222,7 +222,7 @@ func TestLoRASoftAffinityDistribution(t *testing.T) { }, &types.PodMetrics{ Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "available-pod"}}, - Metrics: &backendmetrics.Metrics{ + MetricsState: &backendmetrics.MetricsState{ MaxActiveModels: 2, ActiveModels: map[string]int{}, }, diff --git a/pkg/epp/scheduling/plugins/scorer/kvcache_test.go b/pkg/epp/scheduling/plugins/scorer/kvcache_test.go index 68be8a213..54cbaf25d 100644 --- a/pkg/epp/scheduling/plugins/scorer/kvcache_test.go +++ b/pkg/epp/scheduling/plugins/scorer/kvcache_test.go @@ -35,9 +35,9 @@ func TestKvCacheScorer(t *testing.T) { { name: "Different KV cache utilization", pods: []types.Pod{ - &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{KVCacheUsagePercent: 0.8}}, - &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{KVCacheUsagePercent: 0.5}}, - &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{KVCacheUsagePercent: 0.0}}, + &types.PodMetrics{Pod: &backend.Pod{}, MetricsState: &backendmetrics.MetricsState{KVCacheUsagePercent: 0.8}}, + &types.PodMetrics{Pod: &backend.Pod{}, MetricsState: &backendmetrics.MetricsState{KVCacheUsagePercent: 0.5}}, + &types.PodMetrics{Pod: &backend.Pod{}, MetricsState: &backendmetrics.MetricsState{KVCacheUsagePercent: 0.0}}, }, expectedScoresPod: map[int]float64{ 0: 0.2, // Highest KV cache usage (0.8) gets lowest score (1-0.8=0.2) @@ -48,8 +48,8 @@ func TestKvCacheScorer(t *testing.T) { { name: "Same KV cache utilization", pods: []types.Pod{ - &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{KVCacheUsagePercent: 0.6}}, - &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{KVCacheUsagePercent: 0.6}}, + &types.PodMetrics{Pod: &backend.Pod{}, MetricsState: &backendmetrics.MetricsState{KVCacheUsagePercent: 0.6}}, + &types.PodMetrics{Pod: &backend.Pod{}, MetricsState: &backendmetrics.MetricsState{KVCacheUsagePercent: 0.6}}, }, expectedScoresPod: map[int]float64{ 0: 0.4, // Both get same score (1-0.6=0.4) @@ -59,8 +59,8 @@ func TestKvCacheScorer(t *testing.T) { { name: "Zero KV cache utilization", pods: []types.Pod{ - &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{KVCacheUsagePercent: 0.0}}, - &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{KVCacheUsagePercent: 0.0}}, + &types.PodMetrics{Pod: &backend.Pod{}, MetricsState: &backendmetrics.MetricsState{KVCacheUsagePercent: 0.0}}, + &types.PodMetrics{Pod: &backend.Pod{}, MetricsState: &backendmetrics.MetricsState{KVCacheUsagePercent: 0.0}}, }, expectedScoresPod: map[int]float64{ 0: 1.0, // No KV cache usage gets highest score @@ -70,8 +70,8 @@ func TestKvCacheScorer(t *testing.T) { { name: "Full KV cache utilization", pods: []types.Pod{ - &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{KVCacheUsagePercent: 1.0}}, - &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{KVCacheUsagePercent: 0.5}}, + &types.PodMetrics{Pod: &backend.Pod{}, MetricsState: &backendmetrics.MetricsState{KVCacheUsagePercent: 1.0}}, + &types.PodMetrics{Pod: &backend.Pod{}, MetricsState: &backendmetrics.MetricsState{KVCacheUsagePercent: 0.5}}, }, expectedScoresPod: map[int]float64{ 0: 0.0, // Full KV cache (1.0) gets lowest score (1-1=0) diff --git a/pkg/epp/scheduling/plugins/scorer/queue_test.go b/pkg/epp/scheduling/plugins/scorer/queue_test.go index d60eab66a..b89bf71b5 100644 --- a/pkg/epp/scheduling/plugins/scorer/queue_test.go +++ b/pkg/epp/scheduling/plugins/scorer/queue_test.go @@ -35,9 +35,9 @@ func TestQueueScorer(t *testing.T) { { name: "Different queue sizes", pods: []types.Pod{ - &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{WaitingQueueSize: 10}}, - &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{WaitingQueueSize: 5}}, - &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{WaitingQueueSize: 0}}, + &types.PodMetrics{Pod: &backend.Pod{}, MetricsState: &backendmetrics.MetricsState{WaitingQueueSize: 10}}, + &types.PodMetrics{Pod: &backend.Pod{}, MetricsState: &backendmetrics.MetricsState{WaitingQueueSize: 5}}, + &types.PodMetrics{Pod: &backend.Pod{}, MetricsState: &backendmetrics.MetricsState{WaitingQueueSize: 0}}, }, expectedScoresPod: map[int]float64{ 0: 0.0, // Longest queue (10) gets lowest score @@ -48,8 +48,8 @@ func TestQueueScorer(t *testing.T) { { name: "Same queue sizes", pods: []types.Pod{ - &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{WaitingQueueSize: 5}}, - &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{WaitingQueueSize: 5}}, + &types.PodMetrics{Pod: &backend.Pod{}, MetricsState: &backendmetrics.MetricsState{WaitingQueueSize: 5}}, + &types.PodMetrics{Pod: &backend.Pod{}, MetricsState: &backendmetrics.MetricsState{WaitingQueueSize: 5}}, }, expectedScoresPod: map[int]float64{ 0: 1.0, // When all pods have the same queue size, they get the same neutral score @@ -59,8 +59,8 @@ func TestQueueScorer(t *testing.T) { { name: "Zero queue sizes", pods: []types.Pod{ - &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{WaitingQueueSize: 0}}, - &types.PodMetrics{Pod: &backend.Pod{}, Metrics: &backendmetrics.Metrics{WaitingQueueSize: 0}}, + &types.PodMetrics{Pod: &backend.Pod{}, MetricsState: &backendmetrics.MetricsState{WaitingQueueSize: 0}}, + &types.PodMetrics{Pod: &backend.Pod{}, MetricsState: &backendmetrics.MetricsState{WaitingQueueSize: 0}}, }, expectedScoresPod: map[int]float64{ 0: 1.0, diff --git a/pkg/epp/scheduling/scheduler_test.go b/pkg/epp/scheduling/scheduler_test.go index 8936cc6c0..933122e61 100644 --- a/pkg/epp/scheduling/scheduler_test.go +++ b/pkg/epp/scheduling/scheduler_test.go @@ -60,7 +60,7 @@ func TestSchedule(t *testing.T) { input: []*backendmetrics.FakePodMetrics{ { Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}, - Metrics: &backendmetrics.Metrics{ + Metrics: &backendmetrics.MetricsState{ WaitingQueueSize: 0, KVCacheUsagePercent: 0.2, MaxActiveModels: 2, @@ -72,7 +72,7 @@ func TestSchedule(t *testing.T) { }, { Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}, - Metrics: &backendmetrics.Metrics{ + Metrics: &backendmetrics.MetricsState{ WaitingQueueSize: 3, KVCacheUsagePercent: 0.1, MaxActiveModels: 2, @@ -84,7 +84,7 @@ func TestSchedule(t *testing.T) { }, { Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}, - Metrics: &backendmetrics.Metrics{ + Metrics: &backendmetrics.MetricsState{ WaitingQueueSize: 10, KVCacheUsagePercent: 0.2, MaxActiveModels: 2, @@ -98,7 +98,7 @@ func TestSchedule(t *testing.T) { TargetPod: &types.ScoredPod{ Pod: &types.PodMetrics{ Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}, Labels: make(map[string]string)}, - Metrics: &backendmetrics.Metrics{ + MetricsState: &backendmetrics.MetricsState{ WaitingQueueSize: 3, KVCacheUsagePercent: 0.1, MaxActiveModels: 2, @@ -123,7 +123,7 @@ func TestSchedule(t *testing.T) { input: []*backendmetrics.FakePodMetrics{ { Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}, - Metrics: &backendmetrics.Metrics{ + Metrics: &backendmetrics.MetricsState{ WaitingQueueSize: 0, KVCacheUsagePercent: 0.2, MaxActiveModels: 2, @@ -135,7 +135,7 @@ func TestSchedule(t *testing.T) { }, { Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}, - Metrics: &backendmetrics.Metrics{ + Metrics: &backendmetrics.MetricsState{ WaitingQueueSize: 3, KVCacheUsagePercent: 0.1, MaxActiveModels: 2, @@ -147,7 +147,7 @@ func TestSchedule(t *testing.T) { }, { Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}, - Metrics: &backendmetrics.Metrics{ + Metrics: &backendmetrics.MetricsState{ WaitingQueueSize: 10, KVCacheUsagePercent: 0.2, MaxActiveModels: 2, @@ -161,7 +161,7 @@ func TestSchedule(t *testing.T) { TargetPod: &types.ScoredPod{ Pod: &types.PodMetrics{ Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}, Labels: make(map[string]string)}, - Metrics: &backendmetrics.Metrics{ + MetricsState: &backendmetrics.MetricsState{ WaitingQueueSize: 0, KVCacheUsagePercent: 0.2, MaxActiveModels: 2, @@ -187,7 +187,7 @@ func TestSchedule(t *testing.T) { input: []*backendmetrics.FakePodMetrics{ { Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}, - Metrics: &backendmetrics.Metrics{ + Metrics: &backendmetrics.MetricsState{ WaitingQueueSize: 10, KVCacheUsagePercent: 0.9, MaxActiveModels: 2, @@ -199,7 +199,7 @@ func TestSchedule(t *testing.T) { }, { Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}, - Metrics: &backendmetrics.Metrics{ + Metrics: &backendmetrics.MetricsState{ WaitingQueueSize: 3, KVCacheUsagePercent: 0.85, MaxActiveModels: 2, @@ -211,7 +211,7 @@ func TestSchedule(t *testing.T) { }, { Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}, - Metrics: &backendmetrics.Metrics{ + Metrics: &backendmetrics.MetricsState{ WaitingQueueSize: 10, KVCacheUsagePercent: 0.85, MaxActiveModels: 2, diff --git a/pkg/epp/scheduling/types/types.go b/pkg/epp/scheduling/types/types.go index a112794d1..c7f6fa53d 100644 --- a/pkg/epp/scheduling/types/types.go +++ b/pkg/epp/scheduling/types/types.go @@ -57,7 +57,7 @@ type LLMResponse struct { type Pod interface { GetPod() *backend.Pod - GetMetrics() *backendmetrics.Metrics + GetMetrics() *backendmetrics.MetricsState String() string } @@ -77,19 +77,19 @@ func (pm *PodMetrics) GetPod() *backend.Pod { return pm.Pod } -func (pm *PodMetrics) GetMetrics() *backendmetrics.Metrics { - return pm.Metrics +func (pm *PodMetrics) GetMetrics() *backendmetrics.MetricsState { + return pm.MetricsState } type PodMetrics struct { *backend.Pod - *backendmetrics.Metrics + *backendmetrics.MetricsState } func ToSchedulerPodMetrics(pods []backendmetrics.PodMetrics) []Pod { pm := make([]Pod, 0, len(pods)) for _, pod := range pods { - pm = append(pm, &PodMetrics{Pod: pod.GetPod().Clone(), Metrics: pod.GetMetrics().Clone()}) + pm = append(pm, &PodMetrics{Pod: pod.GetPod().Clone(), MetricsState: pod.GetMetrics().Clone()}) } return pm } diff --git a/test/integration/epp/hermetic_test.go b/test/integration/epp/hermetic_test.go index 938787635..923392530 100644 --- a/test/integration/epp/hermetic_test.go +++ b/test/integration/epp/hermetic_test.go @@ -98,7 +98,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { tests := []struct { name string requests []*extProcPb.ProcessingRequest - pods map[*backend.Pod]*backendmetrics.Metrics + pods map[*backend.Pod]*backendmetrics.MetricsState wantResponses []*extProcPb.ProcessingResponse wantMetrics map[string]string wantErr bool @@ -109,7 +109,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { name: "select lower queue and kv cache, no active lora", requests: integrationutils.GenerateStreamedRequestSet(logger, "test1", "my-model"), // pod-1 will be picked because it has relatively low queue size and low KV cache. - pods: map[*backend.Pod]*backendmetrics.Metrics{ + pods: map[*backend.Pod]*backendmetrics.MetricsState{ fakePod(0): { WaitingQueueSize: 3, KVCacheUsagePercent: 0.2, @@ -190,7 +190,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { requests: integrationutils.GenerateStreamedRequestSet(logger, "test2", "sql-lora"), // pod-1 will be picked because it has relatively low queue size, with the requested // model being active, and has low KV cache. - pods: map[*backend.Pod]*backendmetrics.Metrics{ + pods: map[*backend.Pod]*backendmetrics.MetricsState{ fakePod(0): { WaitingQueueSize: 0, KVCacheUsagePercent: 0.2, @@ -281,7 +281,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { // pod-2 will be picked despite it NOT having the requested model being active // as it's above the affinity for queue size. Also is critical, so we should // still honor request despite all queues > 5 - pods: map[*backend.Pod]*backendmetrics.Metrics{ + pods: map[*backend.Pod]*backendmetrics.MetricsState{ fakePod(0): { WaitingQueueSize: 10, KVCacheUsagePercent: 0.2, @@ -370,7 +370,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { requests: integrationutils.GenerateStreamedRequestSet(logger, "test4", "sql-lora-sheddable"), // no pods will be picked as all models are either above kv threshold, // queue threshold, or both. - pods: map[*backend.Pod]*backendmetrics.Metrics{ + pods: map[*backend.Pod]*backendmetrics.MetricsState{ fakePod(0): { WaitingQueueSize: 6, KVCacheUsagePercent: 0.2, @@ -418,7 +418,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { name: "noncritical, but one server has capacity, do not shed", requests: integrationutils.GenerateStreamedRequestSet(logger, "test5", "sql-lora-sheddable"), // pod 0 will be picked as all other models are above threshold - pods: map[*backend.Pod]*backendmetrics.Metrics{ + pods: map[*backend.Pod]*backendmetrics.MetricsState{ fakePod(0): { WaitingQueueSize: 4, KVCacheUsagePercent: 0.2, @@ -535,7 +535,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { // // pod 0 will be picked as all other models are above threshold - pods: map[*backend.Pod]*backendmetrics.Metrics{ + pods: map[*backend.Pod]*backendmetrics.MetricsState{ fakePod(0): { WaitingQueueSize: 4, KVCacheUsagePercent: 0.2, @@ -652,7 +652,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { // // pod 0 will be picked as all other models are above threshold - pods: map[*backend.Pod]*backendmetrics.Metrics{ + pods: map[*backend.Pod]*backendmetrics.MetricsState{ fakePod(0): { WaitingQueueSize: 4, KVCacheUsagePercent: 0.2, @@ -770,7 +770,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { // // pod 0 will be picked as all other models are above threshold - pods: map[*backend.Pod]*backendmetrics.Metrics{ + pods: map[*backend.Pod]*backendmetrics.MetricsState{ fakePod(0): { WaitingQueueSize: 4, KVCacheUsagePercent: 0.2, @@ -875,7 +875,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { // // pod 0 will be picked as all other models are above threshold - pods: map[*backend.Pod]*backendmetrics.Metrics{ + pods: map[*backend.Pod]*backendmetrics.MetricsState{ fakePod(0): { WaitingQueueSize: 4, KVCacheUsagePercent: 0.2, @@ -1241,7 +1241,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { DynamicMetadata: makeMetadata("192.168.1.1:8000"), }, }, - pods: map[*backend.Pod]*backendmetrics.Metrics{ + pods: map[*backend.Pod]*backendmetrics.MetricsState{ fakePod(0): { WaitingQueueSize: 4, KVCacheUsagePercent: 0.2, @@ -1287,9 +1287,9 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { } } -func setUpHermeticServer(t *testing.T, podAndMetrics map[*backend.Pod]*backendmetrics.Metrics, streamed bool) (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { +func setUpHermeticServer(t *testing.T, podAndMetrics map[*backend.Pod]*backendmetrics.MetricsState, streamed bool) (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { // Reconfigure the TestPodMetricsClient. - res := map[types.NamespacedName]*backendmetrics.Metrics{} + res := map[types.NamespacedName]*backendmetrics.MetricsState{} for pod, metrics := range podAndMetrics { res[pod.NamespacedName] = metrics } From 8687febb3a1a4e0082243ff27ff6d09f1f1b0203 Mon Sep 17 00:00:00 2001 From: Jeff Luo Date: Mon, 12 May 2025 18:59:15 -0400 Subject: [PATCH 18/53] feat: Add build reference to the info metrics (#817) The reference will be from `_PULL_BASE_REF` variable from the cloud build: https://docs.prow.k8s.io/docs/jobs/ The change also fixes the commit label by using the right variable added in https://github.com/kubernetes/test-infra/pull/34755/files. --- Dockerfile | 3 ++- Makefile | 3 +++ cloudbuild.yaml | 4 +++- pkg/epp/metrics/metrics.go | 9 +++++---- site-src/guides/metrics.md | 4 ++-- 5 files changed, 15 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index acf05b7fa..b33f129a4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,7 @@ ENV CGO_ENABLED=0 ENV GOOS=linux ENV GOARCH=amd64 ARG COMMIT_SHA=unknown +ARG BUILD_REF # Dependencies WORKDIR /src @@ -21,7 +22,7 @@ COPY pkg/epp ./pkg/epp COPY internal ./internal COPY api ./api WORKDIR /src/cmd -RUN go build -ldflags="-X sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics.CommitSHA=${COMMIT_SHA}" -o /epp +RUN go build -ldflags="-X sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics.CommitSHA=${COMMIT_SHA} -X sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics.BuildRef=${BUILD_REF}" -o /epp ## Multistage deploy FROM ${BASE_IMAGE} diff --git a/Makefile b/Makefile index 884d42294..1849dc7d6 100644 --- a/Makefile +++ b/Makefile @@ -51,10 +51,12 @@ ifdef GO_VERSION BUILDER_IMAGE = golang:$(GO_VERSION) endif +BUILD_REF ?= $(shell git describe --abbrev=0 2>/dev/null) ifdef EXTRA_TAG IMAGE_EXTRA_TAG ?= $(IMAGE_REPO):$(EXTRA_TAG) SYNCER_IMAGE_EXTRA_TAG ?= $(SYNCER_IMAGE_REPO):$(EXTRA_TAG) BBR_IMAGE_EXTRA_TAG ?= $(BBR_IMAGE_REPO):$(EXTRA_TAG) +BUILD_REF = $(EXTRA_TAG) endif ifdef IMAGE_EXTRA_TAG IMAGE_BUILD_EXTRA_OPTS += -t $(IMAGE_EXTRA_TAG) @@ -177,6 +179,7 @@ image-build: ## Build the EPP image using Docker Buildx. --build-arg BASE_IMAGE=$(BASE_IMAGE) \ --build-arg BUILDER_IMAGE=$(BUILDER_IMAGE) \ --build-arg COMMIT_SHA=${GIT_COMMIT_SHA} \ + --build-arg BUILD_REF=${BUILD_REF} \ $(PUSH) \ $(LOAD) \ $(IMAGE_BUILD_EXTRA_OPTS) ./ diff --git a/cloudbuild.yaml b/cloudbuild.yaml index f05c8c00d..366667489 100644 --- a/cloudbuild.yaml +++ b/cloudbuild.yaml @@ -12,7 +12,7 @@ steps: - GIT_TAG=$_GIT_TAG - EXTRA_TAG=$_PULL_BASE_REF - DOCKER_BUILDX_CMD=/buildx-entrypoint - - GIT_COMMIT_SHA=$COMMIT_SHA + - GIT_COMMIT_SHA=$_PULL_BASE_SHA - name: gcr.io/k8s-staging-test-infra/gcb-docker-gcloud:v20240718-5ef92b5c36 entrypoint: make args: @@ -44,5 +44,7 @@ substitutions: # _PULL_BASE_REF will contain the ref that was pushed to trigger this build - # a branch like 'main' or 'release-0.2', or a tag like 'v0.2'. _PULL_BASE_REF: 'main' + # _PULL_BASE_SHA will contain the Git SHA of the commit that was pushed to trigger this build. + _PULL_BASE_SHA: 'abcdef' options: substitution_option: ALLOW_LOOSE diff --git a/pkg/epp/metrics/metrics.go b/pkg/epp/metrics/metrics.go index 84f0f1f9a..a0d521400 100644 --- a/pkg/epp/metrics/metrics.go +++ b/pkg/epp/metrics/metrics.go @@ -36,6 +36,9 @@ const ( var ( // The git hash of the latest commit in the build. CommitSHA string + + // The build ref from the _PULL_BASE_REF from cloud build trigger. + BuildRef string ) var ( @@ -251,7 +254,7 @@ var ( Help: "General information of the current build of Inference Extension.", StabilityLevel: compbasemetrics.ALPHA, }, - []string{"commit"}, + []string{"commit", "build_ref"}, ) ) @@ -409,7 +412,5 @@ func RecordPrefixCacheMatch(matchedLength, totalLength int) { } func RecordInferenceExtensionInfo() { - if CommitSHA != "" { - InferenceExtensionInfo.WithLabelValues(CommitSHA).Set(1) - } + InferenceExtensionInfo.WithLabelValues(CommitSHA, BuildRef).Set(1) } diff --git a/site-src/guides/metrics.md b/site-src/guides/metrics.md index bf225711c..856c24722 100644 --- a/site-src/guides/metrics.md +++ b/site-src/guides/metrics.md @@ -34,9 +34,9 @@ curl -i ${IP}:${PORT}/v1/completions -H 'Content-Type: application/json' -d '{ | inference_model_running_requests | Gauge | Number of running requests for each model. | `model_name`=<model-name> | ALPHA | | inference_pool_average_kv_cache_utilization | Gauge | The average kv cache utilization for an inference server pool. | `name`=<inference-pool-name> | ALPHA | | inference_pool_average_queue_size | Gauge | The average number of requests pending in the model server queue. | `name`=<inference-pool-name> | ALPHA | -| inference_pool_per_pod_queue_size | Gauge | The total number of queue for each model server pod under the inference pool | `model_server_pod`=<model-server-pod-name> `name`=<inference-pool-name> | ALPHA | +| inference_pool_per_pod_queue_size | Gauge | The total number of queue for each model server pod under the inference pool | `model_server_pod`=<model-server-pod-name>
`name`=<inference-pool-name> | ALPHA | | inference_pool_ready_pods | Gauge | The number of ready pods for an inference server pool. | `name`=<inference-pool-name> | ALPHA | -| inference_extension_info | Gauge | The general information of the current build. | `commit`=<hash-of-the-build> | ALPHA | +| inference_extension_info | Gauge | The general information of the current build. | `commit`=<hash-of-the-build>
`build_ref`=<ref-to-the-build> | ALPHA | ## Scrape Metrics From 3d99aa1b779b4cc71e318a214954db20522baaf9 Mon Sep 17 00:00:00 2001 From: Luke Van Drie Date: Mon, 12 May 2025 17:23:15 -0700 Subject: [PATCH 19/53] Introduce SaturationDetector component (#808) This commit adds a new `SaturationDetector` component responsible for determining if backend model servers are saturated. It bases its decision on observed metrics like queue depth and KV cache utilization, using configurable thresholds. The detector is designed to be a self-contained unit that can be leveraged by other components for admission control and capacity assessment. This is the first step in a larger refactoring to externalize and centralize saturation detection logic. --- pkg/epp/common/config/defaults.go | 28 ++ pkg/epp/saturationdetector/config.go | 61 +++ .../saturationdetector/saturationdetector.go | 190 ++++++++++ .../saturationdetector_test.go | 351 ++++++++++++++++++ pkg/epp/scheduling/config/config.go | 13 +- 5 files changed, 636 insertions(+), 7 deletions(-) create mode 100644 pkg/epp/common/config/defaults.go create mode 100644 pkg/epp/saturationdetector/config.go create mode 100644 pkg/epp/saturationdetector/saturationdetector.go create mode 100644 pkg/epp/saturationdetector/saturationdetector_test.go diff --git a/pkg/epp/common/config/defaults.go b/pkg/epp/common/config/defaults.go new file mode 100644 index 000000000..89fd6f493 --- /dev/null +++ b/pkg/epp/common/config/defaults.go @@ -0,0 +1,28 @@ +/* +Copyright 2025 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 config holds common configuration default values used across +// different EPP components. +package config + +const ( + // DefaultKVCacheThreshold is the default KV cache utilization (0.0 to 1.0) + // threshold. + DefaultKVCacheThreshold = 0.8 + // DefaultQueueThresholdCritical is the default backend waiting queue size + // threshold. + DefaultQueueThresholdCritical = 5 +) diff --git a/pkg/epp/saturationdetector/config.go b/pkg/epp/saturationdetector/config.go new file mode 100644 index 000000000..6ca8d8b88 --- /dev/null +++ b/pkg/epp/saturationdetector/config.go @@ -0,0 +1,61 @@ +/* +Copyright 2025 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 saturationdetector + +import ( + "fmt" + "time" + + "sigs.k8s.io/controller-runtime/pkg/log" + commonconfig "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/common/config" + envutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/env" +) + +// Default configuration values +const ( + DefaultQueueDepthThreshold = commonconfig.DefaultQueueThresholdCritical + DefaultKVCacheUtilThreshold = commonconfig.DefaultKVCacheThreshold + // DefaultMetricsStalenessThreshold defines how old metrics can be before they + // are considered stale. + // Given the pod metrics refresh interval is 50ms, a threshold slightly above + // that should be fine. + DefaultMetricsStalenessThreshold = 200 * time.Millisecond +) + +// Environment variable names for SaturationDetector configuration +const ( + EnvSdQueueDepthThreshold = "SD_QUEUE_DEPTH_THRESHOLD" + EnvSdKVCacheUtilThreshold = "SD_KV_CACHE_UTIL_THRESHOLD" + EnvSdMetricsStalenessThreshold = "SD_METRICS_STALENESS_THRESHOLD" +) + +// LoadConfigFromEnv loads SaturationDetector Config from environment +// variables. +func LoadConfigFromEnv() *Config { + // Use a default logger for initial configuration loading. + logger := log.Log.WithName("saturation-detector-config") + + cfg := &Config{} + + cfg.QueueDepthThreshold = envutil.GetEnvInt(EnvSdQueueDepthThreshold, DefaultQueueDepthThreshold, logger) + cfg.KVCacheUtilThreshold = envutil.GetEnvFloat(EnvSdKVCacheUtilThreshold, DefaultKVCacheUtilThreshold, logger) + cfg.MetricsStalenessThreshold = envutil.GetEnvDuration(EnvSdMetricsStalenessThreshold, DefaultMetricsStalenessThreshold, logger) + + // NewDetector validates the config and assigns defaults. + logger.Info("SaturationDetector configuration loaded from env", + "config", fmt.Sprintf("%+v", cfg)) + return cfg +} diff --git a/pkg/epp/saturationdetector/saturationdetector.go b/pkg/epp/saturationdetector/saturationdetector.go new file mode 100644 index 000000000..ccd0ce598 --- /dev/null +++ b/pkg/epp/saturationdetector/saturationdetector.go @@ -0,0 +1,190 @@ +/* +Copyright 2025 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 saturationdetector implements a mechanism to determine if the +// backend model servers are considered saturated based on observed metrics. +// +// The current implementation provides a global saturation signal (IsSaturated) +// primarily based on backend queue depths and KV cache utilization, reflecting +// the saturation signals previously used by the Scheduler before the +// introduction of the FlowController. It fetches live metrics from the +// provided Datastore. +// +// TODO: Explore more advanced saturation signals in the future, such as: +// - Latency-objective-based saturation. +// - Predictive saturation based on trends. +// - Hysteresis bands or other smoothing techniques to prevent rapid +// oscillations of the saturation signal. +package saturationdetector + +import ( + "context" + "errors" + "time" + + "github.com/go-logr/logr" + "sigs.k8s.io/controller-runtime/pkg/log" + backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" +) + +// clock allows mocking time in tests. +type clock interface { + now() time.Time +} + +// realClock provides the real time. +type realClock struct{} + +func (c realClock) now() time.Time { return time.Now() } + +const ( + // loggerName is the name to use for loggers created by this package. + loggerName = "SaturationDetector" +) + +// ErrNilDatastore indicates NewSaturationDetector was called with a nil +// datastore. +var ErrNilDatastore = errors.New("datastore cannot be nil") + +// Config holds the configuration for the SaturationDetector. +type Config struct { + // QueueDepthThreshold defines the backend waiting queue size above which a + // pod is considered to have insufficient capacity for new requests. + QueueDepthThreshold int + // KVCacheUtilThreshold defines the KV cache utilization (0.0 to 1.0) above + // which a pod is considered to have insufficient capacity. + KVCacheUtilThreshold float64 + // MetricsStalenessThreshold defines how old a pod's metrics can be. + // If a pod's metrics are older than this, it might be excluded from + // "good capacity" considerations or treated as having no capacity for + // safety. + MetricsStalenessThreshold time.Duration +} + +// Datastore provides an interface to access backend pod metrics. +type Datastore interface { + PodGetAll() []backendmetrics.PodMetrics +} + +// Detector determines system saturation based on metrics from the Datastore. +// +// The Detector currently holds a direct dependency on a Datastore interface. +// This design choice was made to encapsulate the logic of fetching and +// interpreting metrics for saturation, thereby simplifying the dependencies +// for primary consumers like the FlowController--to be added soon--(which +// would otherwise need to manage Datastore interactions itself). +// This architectural decision may be revisited in the future if a more +// decoupled approach (e.g., passing metrics directly to IsSaturated) proves +// more beneficial. +type Detector struct { + datastore Datastore + config Config + clock clock +} + +// NewDetector creates a new SaturationDetector. +// The datastore is expected to provide access to live/recently-updated pod +// metrics. +// The config provides the thresholds for determining saturation. +func NewDetector(config Config, datastore Datastore, logger logr.Logger) (*Detector, error) { + if datastore == nil { + return nil, ErrNilDatastore + } + if config.MetricsStalenessThreshold <= 0 { + config.MetricsStalenessThreshold = DefaultMetricsStalenessThreshold + } + logger.WithName(loggerName).V(logutil.DEFAULT).Info("Creating new SaturationDetector", + "queueDepthThreshold", config.QueueDepthThreshold, + "kvCacheUtilThreshold", config.KVCacheUtilThreshold, + "metricsStalenessThreshold", config.MetricsStalenessThreshold.String()) + return &Detector{ + datastore: datastore, + config: config, + clock: realClock{}, + }, nil +} + +// IsSaturated checks if the system is currently considered saturated. +// The system is saturated if NO pod currently has "good capacity". +// "Good capacity" means: +// 1. Metrics are fresh (not stale). +// 2. WaitingQueueSize <= QueueDepthThreshold. +// 3. KVCacheUsagePercent <= KVCacheUtilThreshold. +// +// If no pods are found in the datastore, the system is considered saturated +// (no capacity). +func (d *Detector) IsSaturated(ctx context.Context) bool { + logger := log.FromContext(ctx).WithName(loggerName) + allPodsMetrics := d.datastore.PodGetAll() + if len(allPodsMetrics) == 0 { + logger.V(logutil.VERBOSE).Info("No pods found in datastore; system is considered SATURATED (no capacity).") + // If there are no pods, there is no capacity to serve requests. + // Treat this as a saturated state to enable FlowController queuing. + return true + } + + now := d.clock.now() + foundPodWithGoodCapacity := false + for _, podMetric := range allPodsMetrics { + metrics := podMetric.GetMetrics() + podNn := "unknown-pod" + if podMetric.GetPod() != nil { + podNn = podMetric.GetPod().NamespacedName.String() + } + + if metrics == nil { + logger.V(logutil.VERBOSE).Info("Pod has nil metrics, skipping for saturation check", + "pod", podNn) + continue + } + + // 1. Check for metric staleness + if now.Sub(metrics.UpdateTime) > d.config.MetricsStalenessThreshold { + logger.V(logutil.VERBOSE).Info("Pod metrics are stale, considered as not having good capacity", + "pod", podNn, + "updateTime", metrics.UpdateTime, + "stalenessThreshold", d.config.MetricsStalenessThreshold) + continue + } + + // 2. Check queue depth + isQueueGood := metrics.WaitingQueueSize <= d.config.QueueDepthThreshold + + // 3. Check KV cache utilization + isKVCacheGood := metrics.KVCacheUsagePercent <= d.config.KVCacheUtilThreshold + + if isQueueGood && isKVCacheGood { + logger.V(logutil.VERBOSE).Info("Found pod with good capacity", + "pod", podNn, + "waitingQueue", metrics.WaitingQueueSize, + "queueThreshold", d.config.QueueDepthThreshold, + "kvCacheUtil", metrics.KVCacheUsagePercent, + "kvCacheThreshold", d.config.KVCacheUtilThreshold) + foundPodWithGoodCapacity = true + // Found at least one pod with good capacity, so system is NOT saturated. + break + } + } + + if !foundPodWithGoodCapacity { + logger.V(logutil.VERBOSE).Info("No pods found with good capacity; system is considered SATURATED.") + return true + } + + logger.V(logutil.VERBOSE).Info("System is considered NOT saturated (at least one pod has good capacity).") + return false +} diff --git a/pkg/epp/saturationdetector/saturationdetector_test.go b/pkg/epp/saturationdetector/saturationdetector_test.go new file mode 100644 index 000000000..d9810c9a1 --- /dev/null +++ b/pkg/epp/saturationdetector/saturationdetector_test.go @@ -0,0 +1,351 @@ +/* +Copyright 2025 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 saturationdetector + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/go-logr/logr" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" + backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" +) + +// --- Mock Implementations --- + +type mockDatastore struct { + pods []*backendmetrics.FakePodMetrics +} + +// PodGetAll returns all pod metrics from the fake datastore. +func (fds *mockDatastore) PodGetAll() []backendmetrics.PodMetrics { + pm := make([]backendmetrics.PodMetrics, 0, len(fds.pods)) + for _, pod := range fds.pods { + pm = append(pm, pod) + } + return pm +} + +// mockClock allows controlling time in tests. +type mockClock struct { + mu sync.RWMutex + time time.Time +} + +func newMockClock(t time.Time) *mockClock { + return &mockClock{time: t} +} + +func (c *mockClock) now() time.Time { + c.mu.RLock() + defer c.mu.RUnlock() + return c.time +} + +func newMockPodMetrics(name string, metrics *backendmetrics.MetricsState) *backendmetrics.FakePodMetrics { + return &backendmetrics.FakePodMetrics{ + Pod: &backend.Pod{ + NamespacedName: types.NamespacedName{Name: name, Namespace: "ns1"}, + }, + Metrics: metrics, + } +} + +// --- Tests --- + +func TestNewDetector(t *testing.T) { + tests := []struct { + name string + config Config + datastore Datastore + expectError error + expectedStalenessThresh time.Duration + }{ + { + name: "Valid config", + config: Config{ + QueueDepthThreshold: 10, + KVCacheUtilThreshold: 0.8, + MetricsStalenessThreshold: 100 * time.Millisecond, + }, + datastore: &mockDatastore{}, + expectError: nil, + expectedStalenessThresh: 100 * time.Millisecond, + }, + { + name: "Nil datastore", + config: Config{}, + datastore: nil, + expectError: ErrNilDatastore, + expectedStalenessThresh: DefaultMetricsStalenessThreshold, // Default will be set if error didn't occur first + }, + { + name: "Zero staleness threshold uses default", + config: Config{ + QueueDepthThreshold: 5, + KVCacheUtilThreshold: 0.9, + MetricsStalenessThreshold: 0, // Should use default + }, + datastore: &mockDatastore{}, + expectError: nil, + expectedStalenessThresh: DefaultMetricsStalenessThreshold, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + detector, err := NewDetector(tt.config, tt.datastore, logr.Discard()) + + if !errors.Is(err, tt.expectError) { + t.Errorf("NewDetector() error = %v, wantErr %v", err, tt.expectError) + } + + if err == nil && detector != nil { + detector.clock = newMockClock(time.Now()) + if detector.config.MetricsStalenessThreshold != tt.expectedStalenessThresh { + t.Errorf("NewDetector() MetricsStalenessThreshold = %v, want %v", detector.config.MetricsStalenessThreshold, tt.expectedStalenessThresh) + } + if detector.config.QueueDepthThreshold != tt.config.QueueDepthThreshold { + t.Errorf("NewDetector() QueueDepthThreshold = %d, want %d", detector.config.QueueDepthThreshold, tt.config.QueueDepthThreshold) + } + if detector.config.KVCacheUtilThreshold != tt.config.KVCacheUtilThreshold { + t.Errorf("NewDetector() KVCacheUtilThreshold = %f, want %f", detector.config.KVCacheUtilThreshold, tt.config.KVCacheUtilThreshold) + } + } + }) + } +} + +func TestDetector_IsSaturated(t *testing.T) { + baseTime := time.Now() + defaultConfig := Config{ + QueueDepthThreshold: 5, + KVCacheUtilThreshold: 0.90, + MetricsStalenessThreshold: 100 * time.Millisecond, + } + + tests := []struct { + name string + config Config + pods []*backendmetrics.FakePodMetrics + expectedSaturat bool + }{ + { + name: "No pods in datastore", + config: defaultConfig, + pods: []*backendmetrics.FakePodMetrics{}, + expectedSaturat: true, // No capacity = saturated + }, + { + name: "Single pod with good capacity", + config: defaultConfig, + pods: []*backendmetrics.FakePodMetrics{ + newMockPodMetrics("pod1", &backendmetrics.MetricsState{ + UpdateTime: baseTime, + WaitingQueueSize: 2, + KVCacheUsagePercent: 0.5, + }), + }, + expectedSaturat: false, + }, + { + name: "Single pod with stale metrics", + config: defaultConfig, + pods: []*backendmetrics.FakePodMetrics{ + newMockPodMetrics("pod1", &backendmetrics.MetricsState{ + UpdateTime: baseTime.Add(-200 * time.Millisecond), // Stale + WaitingQueueSize: 1, + KVCacheUsagePercent: 0.1, + }), + }, + expectedSaturat: true, + }, + { + name: "Single pod with high queue depth", + config: defaultConfig, + pods: []*backendmetrics.FakePodMetrics{ + newMockPodMetrics("pod1", &backendmetrics.MetricsState{ + UpdateTime: baseTime, + WaitingQueueSize: 10, // Exceeds threshold 5 + KVCacheUsagePercent: 0.1, + }), + }, + expectedSaturat: true, + }, + { + name: "Single pod with high KV cache utilization", + config: defaultConfig, + pods: []*backendmetrics.FakePodMetrics{ + newMockPodMetrics("pod1", &backendmetrics.MetricsState{ + UpdateTime: baseTime, + WaitingQueueSize: 1, + KVCacheUsagePercent: 0.95, // Exceeds threshold 0.90 + }), + }, + expectedSaturat: true, + }, + { + name: "Single pod with nil metrics", + config: defaultConfig, + pods: []*backendmetrics.FakePodMetrics{ + newMockPodMetrics("pod1", nil), + }, + expectedSaturat: true, + }, + { + name: "Multiple pods, all good capacity", + config: defaultConfig, + pods: []*backendmetrics.FakePodMetrics{ + newMockPodMetrics("pod1", &backendmetrics.MetricsState{ + UpdateTime: baseTime, + WaitingQueueSize: 1, + KVCacheUsagePercent: 0.1, + }), + newMockPodMetrics("pod2", &backendmetrics.MetricsState{ + UpdateTime: baseTime.Add(-10 * time.Millisecond), + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.2, + }), + }, + expectedSaturat: false, + }, + { + name: "Multiple pods, one good, one bad (stale)", + config: defaultConfig, + pods: []*backendmetrics.FakePodMetrics{ + newMockPodMetrics("pod1", &backendmetrics.MetricsState{ + UpdateTime: baseTime, // Good + WaitingQueueSize: 1, + KVCacheUsagePercent: 0.1, + }), + newMockPodMetrics("pod2", &backendmetrics.MetricsState{ + UpdateTime: baseTime.Add(-300 * time.Millisecond), // Stale + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.2, + }), + }, + expectedSaturat: false, // One good pod is enough + }, + { + name: "Multiple pods, one good, one bad (high queue)", + config: defaultConfig, + pods: []*backendmetrics.FakePodMetrics{ + newMockPodMetrics("pod1", &backendmetrics.MetricsState{ + UpdateTime: baseTime, + WaitingQueueSize: 1, + KVCacheUsagePercent: 0.1, + }), + newMockPodMetrics("pod2", &backendmetrics.MetricsState{ + UpdateTime: baseTime, + WaitingQueueSize: 15, // Bad queue + KVCacheUsagePercent: 0.2, + }), + }, + expectedSaturat: false, + }, + { + name: "Multiple pods, all bad capacity", + config: defaultConfig, + pods: []*backendmetrics.FakePodMetrics{ + newMockPodMetrics("pod1", &backendmetrics.MetricsState{ + UpdateTime: baseTime.Add(-200 * time.Millisecond), // Stale + WaitingQueueSize: 1, + KVCacheUsagePercent: 0.1, + }), + newMockPodMetrics("pod2", &backendmetrics.MetricsState{ + UpdateTime: baseTime, + WaitingQueueSize: 20, // High queue + KVCacheUsagePercent: 0.2, + }), + newMockPodMetrics("pod3", &backendmetrics.MetricsState{ + UpdateTime: baseTime, + WaitingQueueSize: 1, + KVCacheUsagePercent: 0.99, // High KV + }), + }, + expectedSaturat: true, + }, + { + name: "Queue depth exactly at threshold", + config: defaultConfig, + pods: []*backendmetrics.FakePodMetrics{ + newMockPodMetrics("pod1", &backendmetrics.MetricsState{ + UpdateTime: baseTime, + WaitingQueueSize: defaultConfig.QueueDepthThreshold, // Exactly at threshold (good) + KVCacheUsagePercent: 0.1, + }), + }, + expectedSaturat: false, + }, + { + name: "KV cache exactly at threshold", + config: defaultConfig, + pods: []*backendmetrics.FakePodMetrics{ + newMockPodMetrics("pod1", &backendmetrics.MetricsState{ + UpdateTime: baseTime, + WaitingQueueSize: 1, + KVCacheUsagePercent: defaultConfig.KVCacheUtilThreshold, // Exactly at threshold (good) + }), + }, + expectedSaturat: false, + }, + { + name: "Metrics age exactly at staleness threshold", + config: defaultConfig, + pods: []*backendmetrics.FakePodMetrics{ + newMockPodMetrics("pod1", &backendmetrics.MetricsState{ + UpdateTime: baseTime.Add(-defaultConfig.MetricsStalenessThreshold), // Exactly at threshold (good) + WaitingQueueSize: 1, + KVCacheUsagePercent: 0.1, + }), + }, + expectedSaturat: false, + }, + { + name: "Metrics age just over staleness threshold", + config: defaultConfig, + pods: []*backendmetrics.FakePodMetrics{ + newMockPodMetrics("pod1", &backendmetrics.MetricsState{ + UpdateTime: baseTime.Add(-defaultConfig.MetricsStalenessThreshold - time.Nanosecond), // Just over (stale) + WaitingQueueSize: 1, + KVCacheUsagePercent: 0.1, + }), + }, + expectedSaturat: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockDS := &mockDatastore{pods: tt.pods} + + detector, err := NewDetector(tt.config, mockDS, logr.Discard()) + if err != nil { + t.Fatalf("NewDetector() failed: %v", err) + } + detector.clock = newMockClock(baseTime) + + if got := detector.IsSaturated(context.Background()); got != tt.expectedSaturat { + t.Errorf("IsSaturated() = %v, want %v", got, tt.expectedSaturat) + } + }) + } +} diff --git a/pkg/epp/scheduling/config/config.go b/pkg/epp/scheduling/config/config.go index e00b82aec..80efaaad6 100644 --- a/pkg/epp/scheduling/config/config.go +++ b/pkg/epp/scheduling/config/config.go @@ -18,6 +18,7 @@ package config import ( "sigs.k8s.io/controller-runtime/pkg/log" + commonconfig "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/common/config" envutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/env" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) @@ -31,11 +32,9 @@ type Config struct { } const ( - // Default values to use if environment variables are not set - defaultKVCacheThreshold = 0.8 - defaultQueueThresholdCritical = 5 - defaultQueueingThresholdLoRA = 128 - defaultLoraAffinityThreshold = 0.999 + // Default values for LoRA specific thresholds + defaultQueueingThresholdLoRA = 128 + defaultLoraAffinityThreshold = 0.999 ) // LoadConfig loads configuration from environment variables @@ -44,8 +43,8 @@ func LoadConfig() Config { baseLogger := log.Log.WithName("scheduling-config") config := Config{ - KVCacheThreshold: envutil.GetEnvFloat("KV_CACHE_THRESHOLD", defaultKVCacheThreshold, baseLogger), - QueueThresholdCritical: envutil.GetEnvInt("QUEUE_THRESHOLD_CRITICAL", defaultQueueThresholdCritical, baseLogger), + KVCacheThreshold: envutil.GetEnvFloat("KV_CACHE_THRESHOLD", commonconfig.DefaultKVCacheThreshold, baseLogger), + QueueThresholdCritical: envutil.GetEnvInt("QUEUE_THRESHOLD_CRITICAL", commonconfig.DefaultQueueThresholdCritical, baseLogger), QueueingThresholdLoRA: envutil.GetEnvInt("QUEUING_THRESHOLD_LORA", defaultQueueingThresholdLoRA, baseLogger), LoraAffinityThreshold: envutil.GetEnvFloat("LORA_AFFINITY_THRESHOLD", defaultLoraAffinityThreshold, baseLogger), } From 2b2b4a6423c49df3311bbbe4b3ec3cd002e5575d Mon Sep 17 00:00:00 2001 From: Hang Yin Date: Tue, 13 May 2025 11:13:15 +0800 Subject: [PATCH 20/53] support extracting prompt from chat completions API (#798) * support extracting prompt from chat completions API Signed-off-by: Hang Yin * typo fixes Signed-off-by: Hang Yin * fix tests * supply more tests and heading boilerplate Signed-off-by: Hang Yin --------- Signed-off-by: Hang Yin --- pkg/epp/requestcontrol/director.go | 6 +- pkg/epp/requestcontrol/director_test.go | 81 +++++++++- pkg/epp/util/request/body.go | 86 +++++++++++ pkg/epp/util/request/body_test.go | 191 ++++++++++++++++++++++++ 4 files changed, 359 insertions(+), 5 deletions(-) create mode 100644 pkg/epp/util/request/body.go create mode 100644 pkg/epp/util/request/body_test.go diff --git a/pkg/epp/requestcontrol/director.go b/pkg/epp/requestcontrol/director.go index bfcf2ec6d..85c8ee34f 100644 --- a/pkg/epp/requestcontrol/director.go +++ b/pkg/epp/requestcontrol/director.go @@ -62,9 +62,9 @@ func (d *Director) HandleRequest(ctx context.Context, reqCtx *handlers.RequestCo if !ok { return reqCtx, errutil.Error{Code: errutil.BadRequest, Msg: "model not found in request"} } - prompt, ok := requestBodyMap["prompt"].(string) - if !ok { - return reqCtx, errutil.Error{Code: errutil.BadRequest, Msg: "prompt not found in request"} + prompt, err := requtil.ExtractPromptFromRequestBody(requestBodyMap) + if err != nil { + return reqCtx, err } // NOTE: The nil checking for the modelObject means that we DO allow passthrough currently. diff --git a/pkg/epp/requestcontrol/director_test.go b/pkg/epp/requestcontrol/director_test.go index 05dc1b3b8..e4384a80b 100644 --- a/pkg/epp/requestcontrol/director_test.go +++ b/pkg/epp/requestcontrol/director_test.go @@ -85,7 +85,7 @@ func TestHandleRequest(t *testing.T) { wantRespBody map[string]interface{} }{ { - name: "successful request", + name: "successful completions request", reqBodyMap: map[string]interface{}{ "model": tsModel, "prompt": "test prompt", @@ -102,7 +102,69 @@ func TestHandleRequest(t *testing.T) { }, }, { - name: "successful request with target model", + name: "successful chat completions request", + reqBodyMap: map[string]interface{}{ + "model": tsModel, + "messages": []interface{}{ + map[string]interface{}{ + "role": "user", + "content": "test prompt", + }, + }, + }, + wantReqCtx: &handlers.RequestContext{ + Model: tsModel, + ResolvedTargetModel: tsModel, + TargetPod: "/pod1", + TargetEndpoint: "address-1:8000", + }, + wantRespBody: map[string]interface{}{ + "model": tsModel, + "messages": []interface{}{ + map[string]interface{}{ + "role": "user", + "content": "test prompt", + }, + }, + }, + }, + { + name: "successful chat completions request with multiple messages", + reqBodyMap: map[string]interface{}{ + "model": tsModel, + "messages": []interface{}{ + map[string]interface{}{ + "role": "developer", + "content": "You are a helpful assistant.", + }, + map[string]interface{}{ + "role": "user", + "content": "Hello!", + }, + }, + }, + wantReqCtx: &handlers.RequestContext{ + Model: tsModel, + ResolvedTargetModel: tsModel, + TargetPod: "/pod1", + TargetEndpoint: "address-1:8000", + }, + wantRespBody: map[string]interface{}{ + "model": tsModel, + "messages": []interface{}{ + map[string]interface{}{ + "role": "developer", + "content": "You are a helpful assistant.", + }, + map[string]interface{}{ + "role": "user", + "content": "Hello!", + }, + }, + }, + }, + { + name: "successful completions request with target model", reqBodyMap: map[string]interface{}{ "model": modelWithTarget, "prompt": "test prompt", @@ -122,6 +184,21 @@ func TestHandleRequest(t *testing.T) { name: "no model defined, expect err", wantErrCode: errutil.BadRequest, }, + { + name: "prompt or messages not found, expect err", + reqBodyMap: map[string]interface{}{ + "model": tsModel, + }, + wantErrCode: errutil.BadRequest, + }, + { + name: "empty messages, expect err", + reqBodyMap: map[string]interface{}{ + "model": tsModel, + "messages": []interface{}{}, + }, + wantErrCode: errutil.BadRequest, + }, { name: "invalid model defined, expect err", reqBodyMap: map[string]interface{}{ diff --git a/pkg/epp/util/request/body.go b/pkg/epp/util/request/body.go new file mode 100644 index 000000000..83a600f08 --- /dev/null +++ b/pkg/epp/util/request/body.go @@ -0,0 +1,86 @@ +/* +Copyright 2025 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 request + +import ( + "fmt" + + errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/error" +) + +func ExtractPromptFromRequestBody(body map[string]interface{}) (string, error) { + if _, ok := body["messages"]; ok { + return extractPromptFromMessagesField(body) + } + return extractPromptField(body) +} + +func extractPromptField(body map[string]interface{}) (string, error) { + prompt, ok := body["prompt"] + if !ok { + return "", errutil.Error{Code: errutil.BadRequest, Msg: "prompt not found in request"} + } + promptStr, ok := prompt.(string) + if !ok { + return "", errutil.Error{Code: errutil.BadRequest, Msg: "prompt is not a string"} + } + return promptStr, nil +} + +func extractPromptFromMessagesField(body map[string]interface{}) (string, error) { + messages, ok := body["messages"] + if !ok { + return "", errutil.Error{Code: errutil.BadRequest, Msg: "messages not found in request"} + } + messageList, ok := messages.([]interface{}) + if !ok { + return "", errutil.Error{Code: errutil.BadRequest, Msg: "messages is not a list"} + } + if len(messageList) == 0 { + return "", errutil.Error{Code: errutil.BadRequest, Msg: "messages is empty"} + } + + prompt := "" + for _, msg := range messageList { + msgMap, ok := msg.(map[string]interface{}) + if !ok { + continue + } + content, ok := msgMap["content"] + if !ok { + continue + } + contentStr, ok := content.(string) + if !ok { + continue + } + role, ok := msgMap["role"] + if !ok { + continue + } + roleStr, ok := role.(string) + if !ok { + continue + } + prompt += constructChatMessage(roleStr, contentStr) + } + return prompt, nil +} + +func constructChatMessage(role string, content string) string { + return fmt.Sprintf("<|im_start|>%s\n%s<|im_end|>\n", role, content) +} diff --git a/pkg/epp/util/request/body_test.go b/pkg/epp/util/request/body_test.go new file mode 100644 index 000000000..563fc8cf8 --- /dev/null +++ b/pkg/epp/util/request/body_test.go @@ -0,0 +1,191 @@ +package request + +import ( + "testing" +) + +func TestExtractPromptFromRequestBody(t *testing.T) { + tests := []struct { + name string + body map[string]interface{} + want string + wantErr bool + errType error + }{ + { + name: "chat completions request body", + body: map[string]interface{}{ + "model": "test", + "messages": []interface{}{ + map[string]interface{}{ + "role": "system", "content": "this is a system message", + }, + map[string]interface{}{ + "role": "user", "content": "hello", + }, + map[string]interface{}{ + "role": "assistant", "content": "hi, what can I do for you?", + }, + }, + }, + want: "<|im_start|>system\nthis is a system message<|im_end|>\n" + + "<|im_start|>user\nhello<|im_end|>\n" + + "<|im_start|>assistant\nhi, what can I do for you?<|im_end|>\n", + }, + { + name: "completions request body", + body: map[string]interface{}{ + "model": "test", + "prompt": "test prompt", + }, + want: "test prompt", + }, + { + name: "invalid prompt format", + body: map[string]interface{}{ + "model": "test", + "prompt": []interface{}{ + map[string]interface{}{ + "role": "system", "content": "this is a system message", + }, + map[string]interface{}{ + "role": "user", "content": "hello", + }, + map[string]interface{}{ + "role": "assistant", "content": "hi, what can I", + }, + }, + }, + wantErr: true, + }, + { + name: "invalid messaged format", + body: map[string]interface{}{ + "model": "test", + "messages": map[string]interface{}{ + "role": "system", "content": "this is a system message", + }, + }, + wantErr: true, + }, + { + name: "prompt does not exist", + body: map[string]interface{}{ + "model": "test", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ExtractPromptFromRequestBody(tt.body) + if (err != nil) != tt.wantErr { + t.Errorf("ExtractPromptFromRequestBody() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("ExtractPromptFromRequestBody() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestExtractPromptField(t *testing.T) { + tests := []struct { + name string + body map[string]interface{} + want string + wantErr bool + }{ + { + name: "valid prompt", + body: map[string]interface{}{ + "prompt": "test prompt", + }, + want: "test prompt", + }, + { + name: "prompt not found", + body: map[string]interface{}{}, + wantErr: true, + }, + { + name: "non-string prompt", + body: map[string]interface{}{ + "prompt": 123, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := extractPromptField(tt.body) + if (err != nil) != tt.wantErr { + t.Errorf("extractPromptField() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("extractPromptField() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestExtractPromptFromMessagesField(t *testing.T) { + tests := []struct { + name string + body map[string]interface{} + want string + wantErr bool + }{ + { + name: "valid messages", + body: map[string]interface{}{ + "messages": []interface{}{ + map[string]interface{}{"role": "user", "content": "test1"}, + map[string]interface{}{"role": "assistant", "content": "test2"}, + }, + }, + want: "<|im_start|>user\ntest1<|im_end|>\n<|im_start|>assistant\ntest2<|im_end|>\n", + }, + { + name: "invalid messages format", + body: map[string]interface{}{ + "messages": "invalid", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := extractPromptFromMessagesField(tt.body) + if (err != nil) != tt.wantErr { + t.Errorf("extractPromptFromMessagesField() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("extractPromptFromMessagesField() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestConstructChatMessage(t *testing.T) { + tests := []struct { + role string + content string + want string + }{ + {"user", "hello", "<|im_start|>user\nhello<|im_end|>\n"}, + {"assistant", "hi", "<|im_start|>assistant\nhi<|im_end|>\n"}, + } + + for _, tt := range tests { + if got := constructChatMessage(tt.role, tt.content); got != tt.want { + t.Errorf("constructChatMessage() = %v, want %v", got, tt.want) + } + } +} From 8baf74c1c738962d944921434774fb0fd0660ef6 Mon Sep 17 00:00:00 2001 From: Luke Van Drie Date: Tue, 13 May 2025 15:17:16 -0700 Subject: [PATCH 21/53] Fix: Add sleep to TestMetricsRefresh for flakes. (#824) The TestMetricsRefresh test in pod_metrics_test.go was flaky due to a race condition. The `StopRefreshLoop` method would signal the metrics refresh goroutine to stop but did not wait for its actual termination. If the test updated the mock metrics client immediately after calling `StopRefreshLoop`, the refresh goroutine could, in rare cases, perform a final metrics fetch with the new data before fully exiting. This resulted in the test asserting against unexpected metric values. This commit resolves the issue by making adding a sleep for the metrics refresh interval in TestMetricsRefresh. Additionally, it adds the following for robustness in `StopRefreshLoop`. - `stopOnce` is used to ensure the `done` channel is only closed once (for idempotency and protection against concurrent calls). This change ensures that the refresh goroutine is guaranteed to have stopped before any test assertions are made, eliminating the race condition. --- pkg/epp/backend/metrics/pod_metrics.go | 11 +++++++---- pkg/epp/backend/metrics/pod_metrics_test.go | 1 + pkg/epp/backend/metrics/types.go | 13 +++++++------ 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/pkg/epp/backend/metrics/pod_metrics.go b/pkg/epp/backend/metrics/pod_metrics.go index f885dbf9f..70ac38a6b 100644 --- a/pkg/epp/backend/metrics/pod_metrics.go +++ b/pkg/epp/backend/metrics/pod_metrics.go @@ -42,8 +42,9 @@ type podMetrics struct { ds Datastore interval time.Duration - once sync.Once // ensure the StartRefreshLoop is only called once. - done chan struct{} + startOnce sync.Once // ensures the refresh loop goroutine is started only once + stopOnce sync.Once // ensures the done channel is closed only once + done chan struct{} logger logr.Logger } @@ -86,7 +87,7 @@ func toInternalPod(pod *corev1.Pod) *backend.Pod { // start starts a goroutine exactly once to periodically update metrics. The goroutine will be // stopped either when stop() is called, or the given ctx is cancelled. func (pm *podMetrics) startRefreshLoop(ctx context.Context) { - pm.once.Do(func() { + pm.startOnce.Do(func() { go func() { pm.logger.V(logutil.DEFAULT).Info("Starting refresher", "pod", pm.GetPod()) ticker := time.NewTicker(pm.interval) @@ -138,5 +139,7 @@ func (pm *podMetrics) refreshMetrics() error { func (pm *podMetrics) StopRefreshLoop() { pm.logger.V(logutil.DEFAULT).Info("Stopping refresher", "pod", pm.GetPod()) - close(pm.done) + pm.stopOnce.Do(func() { + close(pm.done) + }) } diff --git a/pkg/epp/backend/metrics/pod_metrics_test.go b/pkg/epp/backend/metrics/pod_metrics_test.go index c654d068d..796b636b4 100644 --- a/pkg/epp/backend/metrics/pod_metrics_test.go +++ b/pkg/epp/backend/metrics/pod_metrics_test.go @@ -78,6 +78,7 @@ func TestMetricsRefresh(t *testing.T) { // Stop the loop, and simulate metric update again, this time the PodMetrics won't get the // new update. pm.StopRefreshLoop() + time.Sleep(pmf.refreshMetricsInterval * 2 /* small buffer for robustness */) pmc.SetRes(map[types.NamespacedName]*MetricsState{namespacedName: updated}) // Still expect the same condition (no metrics update). assert.EventuallyWithT(t, condition, time.Second, time.Millisecond) diff --git a/pkg/epp/backend/metrics/types.go b/pkg/epp/backend/metrics/types.go index 92478db17..bb78c2b34 100644 --- a/pkg/epp/backend/metrics/types.go +++ b/pkg/epp/backend/metrics/types.go @@ -42,12 +42,13 @@ type PodMetricsFactory struct { func (f *PodMetricsFactory) NewPodMetrics(parentCtx context.Context, in *corev1.Pod, ds Datastore) PodMetrics { pod := toInternalPod(in) pm := &podMetrics{ - pmc: f.pmc, - ds: ds, - interval: f.refreshMetricsInterval, - once: sync.Once{}, - done: make(chan struct{}), - logger: log.FromContext(parentCtx).WithValues("pod", pod.NamespacedName), + pmc: f.pmc, + ds: ds, + interval: f.refreshMetricsInterval, + startOnce: sync.Once{}, + stopOnce: sync.Once{}, + done: make(chan struct{}), + logger: log.FromContext(parentCtx).WithValues("pod", pod.NamespacedName), } pm.pod.Store(pod) pm.metrics.Store(newMetricsState()) From 1f62b02a3a3f90094b9132eaa0116e25d73a0c72 Mon Sep 17 00:00:00 2001 From: sina chavoshi Date: Tue, 13 May 2025 15:35:16 -0700 Subject: [PATCH 22/53] chore(conformance): Add timeout configuration (#795) * Add inferencepool_lifecycle test. * Resolve setup issues and enable InferencePool test * removed todo comment in helper.go * Add InferencePoolLifecycle test * update comments in helper.go * remove Conformanc.go from log message * Remove lifecycle test. * Removed unused helper methods ( inference pool must have selector & must be deleted) * Set timeout values as constant * change timeout.go to timing.go --- conformance/conformance.go | 14 ++--- .../tests/basic/inferencepool_accepted.go | 2 +- conformance/utils/config/timing.go | 49 +++++++++++++++ conformance/utils/kubernetes/helpers.go | 62 ++++++++++--------- 4 files changed, 89 insertions(+), 38 deletions(-) create mode 100644 conformance/utils/config/timing.go diff --git a/conformance/conformance.go b/conformance/conformance.go index 1e847fd62..4cff6bb18 100644 --- a/conformance/conformance.go +++ b/conformance/conformance.go @@ -25,7 +25,6 @@ import ( "io/fs" "os" "testing" - "time" "github.com/stretchr/testify/require" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" @@ -64,6 +63,7 @@ import ( // Import the Inference Extension API types inferencev1alpha2 "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" + inferenceconfig "sigs.k8s.io/gateway-api-inference-extension/conformance/utils/config" ) // Constants for the shared Gateway @@ -245,16 +245,16 @@ func ensureGatewayAvailableAndReady(t *testing.T, k8sClient client.Client, opts t.Logf("Attempting to fetch Gateway %s/%s.", gatewayNN.Namespace, gatewayNN.Name) gw := &gatewayv1.Gateway{} // This gw instance will be populated by the poll function - // Define polling interval - // TODO: Make this configurable using a local TimeoutConfig (from ConformanceOptions perhaps) - pollingInterval := 5 * time.Second - // Use the GatewayMustHaveAddress timeout from the suite's TimeoutConfig for the Gateway object to appear - waitForGatewayCreationTimeout := opts.TimeoutConfig.GatewayMustHaveAddress + // Use extension-specific config for the polling interval defined in timeout.go. + extTimeoutConf := inferenceconfig.DefaultInferenceExtensionTimeoutConfig() + + // Use the GatewayMustHaveAddress timeout from the suite's base TimeoutConfig for the Gateway object to appear. + waitForGatewayCreationTimeout := extTimeoutConf.TimeoutConfig.GatewayMustHaveAddress logDebugf(t, opts.Debug, "Waiting up to %v for Gateway object %s/%s to appear after manifest application...", waitForGatewayCreationTimeout, gatewayNN.Namespace, gatewayNN.Name) ctx := context.TODO() - pollErr := wait.PollUntilContextTimeout(ctx, pollingInterval, waitForGatewayCreationTimeout, true, func(pollCtx context.Context) (bool, error) { + pollErr := wait.PollUntilContextTimeout(ctx, extTimeoutConf.GatewayObjectPollInterval, waitForGatewayCreationTimeout, true, func(pollCtx context.Context) (bool, error) { fetchErr := k8sClient.Get(pollCtx, gatewayNN, gw) if fetchErr == nil { t.Logf("Successfully fetched Gateway %s/%s. Spec.GatewayClassName: %s", diff --git a/conformance/tests/basic/inferencepool_accepted.go b/conformance/tests/basic/inferencepool_accepted.go index eae594046..15eb6d742 100644 --- a/conformance/tests/basic/inferencepool_accepted.go +++ b/conformance/tests/basic/inferencepool_accepted.go @@ -54,7 +54,7 @@ var InferencePoolAccepted = suite.ConformanceTest{ Status: metav1.ConditionTrue, Reason: "", // "" means we don't strictly check the Reason for this basic test. } - infrakubernetes.InferencePoolMustHaveCondition(t, s.Client, s.TimeoutConfig, poolNN, acceptedCondition) + infrakubernetes.InferencePoolMustHaveCondition(t, s.Client, poolNN, acceptedCondition) }) }, } diff --git a/conformance/utils/config/timing.go b/conformance/utils/config/timing.go new file mode 100644 index 000000000..95769d24a --- /dev/null +++ b/conformance/utils/config/timing.go @@ -0,0 +1,49 @@ +/* +Copyright 2025 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 config + +import ( + "time" + + // Import the upstream Gateway API timeout config + gatewayconfig "sigs.k8s.io/gateway-api/conformance/utils/config" +) + +// InferenceExtensionTimeoutConfig embeds the upstream TimeoutConfig and adds +// extension-specific timeout values. +type InferenceExtensionTimeoutConfig struct { + // All fields from gatewayconfig.TimeoutConfig will be available directly. + gatewayconfig.TimeoutConfig + + // InferencePoolMustHaveConditionTimeout represents the maximum time to wait for an InferencePool to have a specific condition. + InferencePoolMustHaveConditionTimeout time.Duration + + // InferencePoolMustHaveConditionInterval represents the polling interval for checking an InferencePool's condition. + InferencePoolMustHaveConditionInterval time.Duration + + // GatewayObjectPollInterval is the polling interval used when waiting for a Gateway object to appear. + GatewayObjectPollInterval time.Duration +} + +func DefaultInferenceExtensionTimeoutConfig() InferenceExtensionTimeoutConfig { + return InferenceExtensionTimeoutConfig{ + TimeoutConfig: gatewayconfig.DefaultTimeoutConfig(), + InferencePoolMustHaveConditionTimeout: 300 * time.Second, + InferencePoolMustHaveConditionInterval: 10 * time.Second, + GatewayObjectPollInterval: 5 * time.Second, + } +} diff --git a/conformance/utils/kubernetes/helpers.go b/conformance/utils/kubernetes/helpers.go index af7d5a2a4..fbe24b577 100644 --- a/conformance/utils/kubernetes/helpers.go +++ b/conformance/utils/kubernetes/helpers.go @@ -23,7 +23,6 @@ import ( "fmt" "reflect" "testing" - "time" "github.com/stretchr/testify/require" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -36,7 +35,7 @@ import ( inferenceapi "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" // Adjust if your API version is different // Import necessary utilities from the core Gateway API conformance suite - "sigs.k8s.io/gateway-api/conformance/utils/config" + "sigs.k8s.io/gateway-api-inference-extension/conformance/utils/config" ) // checkCondition is a helper function similar to findConditionInList or CheckCondition @@ -67,45 +66,48 @@ func checkCondition(t *testing.T, conditions []metav1.Condition, expectedConditi // InferencePoolMustHaveCondition waits for the specified InferencePool resource // to exist and report the expected status condition within one of its parent statuses. // It polls the InferencePool's status until the condition is met or the timeout occurs. -func InferencePoolMustHaveCondition(t *testing.T, c client.Client, timeoutConfig config.TimeoutConfig, poolNN types.NamespacedName, expectedCondition metav1.Condition) { +func InferencePoolMustHaveCondition(t *testing.T, c client.Client, poolNN types.NamespacedName, expectedCondition metav1.Condition) { t.Helper() // Marks this function as a test helper + var timeoutConfig config.InferenceExtensionTimeoutConfig = config.DefaultInferenceExtensionTimeoutConfig() var lastObservedPool *inferenceapi.InferencePool var lastError error var conditionFound bool - var interval time.Duration = 5 * time.Second // pull interval for status checks. - - // TODO: Make retry interval configurable. - waitErr := wait.PollUntilContextTimeout(context.Background(), interval, timeoutConfig.DefaultTestTimeout, true, func(ctx context.Context) (bool, error) { - pool := &inferenceapi.InferencePool{} // This is the type instance used for Get - err := c.Get(ctx, poolNN, pool) - if err != nil { - if apierrors.IsNotFound(err) { - t.Logf("InferencePool %s not found yet. Retrying.", poolNN.String()) + + waitErr := wait.PollUntilContextTimeout( + context.Background(), + timeoutConfig.InferencePoolMustHaveConditionInterval, + timeoutConfig.InferencePoolMustHaveConditionTimeout, + true, func(ctx context.Context) (bool, error) { + pool := &inferenceapi.InferencePool{} // This is the type instance used for Get + err := c.Get(ctx, poolNN, pool) + if err != nil { + if apierrors.IsNotFound(err) { + t.Logf("InferencePool %s not found yet. Retrying.", poolNN.String()) + lastError = err + return false, nil + } + t.Logf("Error fetching InferencePool %s (type: %s): %v. Retrying.", poolNN.String(), reflect.TypeOf(pool).String(), err) lastError = err return false, nil } - t.Logf("Error fetching InferencePool %s (type: %s): %v. Retrying.", poolNN.String(), reflect.TypeOf(pool).String(), err) - lastError = err - return false, nil - } - lastObservedPool = pool - lastError = nil - conditionFound = false + lastObservedPool = pool + lastError = nil + conditionFound = false - if len(pool.Status.Parents) == 0 { - t.Logf("InferencePool %s has no parent statuses reported yet.", poolNN.String()) - return false, nil - } + if len(pool.Status.Parents) == 0 { + t.Logf("InferencePool %s has no parent statuses reported yet.", poolNN.String()) + return false, nil + } - for _, parentStatus := range pool.Status.Parents { - if checkCondition(t, parentStatus.Conditions, expectedCondition) { - conditionFound = true - return true, nil + for _, parentStatus := range pool.Status.Parents { + if checkCondition(t, parentStatus.Conditions, expectedCondition) { + conditionFound = true + return true, nil + } } - } - return false, nil - }) + return false, nil + }) if waitErr != nil || !conditionFound { debugMsg := "" From 409fc3f9c132a8fdb7541aaa44fc6a62f694451f Mon Sep 17 00:00:00 2001 From: Clayton Coleman Date: Wed, 14 May 2025 15:51:19 -0400 Subject: [PATCH 23/53] Scheduler subsystem high level design proposal (#603) * Scheduler subsystem high level design proposal This sets down basic design principles of the current gateway scheduler. We also highlight who we are targeting as users, and why we prioritize the current approach. It also selects standard terminology for scheduling that the implementation should adopt. This is a high level design and thus sets general scope, without expecting to fully address all problems. * Review feedback --------- Co-authored-by: Kellen Swain --- docs/proposals/006-scheduler/README.md | 188 +++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 docs/proposals/006-scheduler/README.md diff --git a/docs/proposals/006-scheduler/README.md b/docs/proposals/006-scheduler/README.md new file mode 100644 index 000000000..77fc4c258 --- /dev/null +++ b/docs/proposals/006-scheduler/README.md @@ -0,0 +1,188 @@ +# Gateway API Scheduler + +Authors: @kfswain, @smarterclayton + +## Proposal Status + ***Draft*** + +## Table of Contents + + + +- [Summary](#summary) +- [Goals](#goals) +- [Non-Goals](#non-goals) +- [Proposal](#proposal) + - [Personas](#personas) + - [Requirements](#requirements) + - [Design](#design) + - [Alternatives](#alternatives) +- [FAQ](#faq) +- [Open Questions](#open-questions) + + + +## Summary + +The inference gateway leverages insight into the anticipated cost of a request and a dynamic capacity model of the backend to achieve higher utilization and more predictable response latency than random balancing can achieve. It should accomplish this over multiple optimization dimensions including, but not limited to: + - prompt length + - anticipated output length + - current traffic distribution + - available backend kv-cache + - workload latency objective + - anticipated savings from prefix cache aware routing + - heterogenous accelerator performance + - backend topology (such as prefill disaggregation or different model server tuning). + + This unified model can better serve diverse workloads on shared models with fewer accelerators as it is reactive to current traffic rather than defined up front. The scheduler selects endpoints on these optimization dimensions, effectively acting as the enforcement of these decisions. + +This proposal defines this *scheduler* subsystem and clearly defines scope, with the possibility of extending scope via future proposals. + +## Goals + +- The scheduler should be reasonably fast - decide request mapping to endpoints within O(10ms) on average +- The scheduler should be effective - requiring little configuration out of the box to get great performance +- The scheduler should be maintainable - new in-tree features should compose cleanly +- The scheduler should be extensible - downstream consumers should expect some stability of the code interface +- The scheduler should be enrichable - extending the [model server protocol](../003-model-server-protocol/) with new metrics or adding a new source of data should be minimally invasive +- The scheduler should be pluggable - the reference endpoint picker implementation should support build time plugins, through a clearly defined interface, or fully delegating scheduling decisions per pool to an alternative **replacement scheduler** + +## Non-Goals + +- Dynamic reconfiguration of the reference scheduler algorithms at runtime +- Being a general scheduler framework for any type of load balancing besides inference +- Determining the characteristics of the underlying model servers and hardware + +## Proposal + +### Definitions + +#### Scheduler + +The 'scheduler' as referred to in this proposal, and repo, is the subsystem that operates _after_ any queuing mechanism, and is the algorithm that actuates on the different optimization dimensions & model server data, selecting the endpoint that best serves the workload and best consumes the underlying compute capacity. + +Any reference to scheduler performance is scoped to this subsystem & not the EPP as a whole. + +#### Saturation + +As model servers accrue requests to compute in the batch, the latency of each batch cycle increases, and so the latency of a single request also will increase. This increase in latency also increases throughput (serving multiple requests in parallel). + +Saturation defines the point at which the latency/throughput tradeoff is no longer efficient. For the scope of inference gateway, and this proposal, we will define 2 saturation definitions: +- Hard Saturation - the model server is completely at capacity, and requests will now be queued and/or evicted. +- Soft Saturation - a saturation limit dictated by the latency sensitivity of the workloads using it. + - i.e. if a model server is saturated to a point that all requests sent to it will not achieve the latency SLO, and so those requests (and the model server), can be considered in an 'unusable' state. + +Subsequent designs will expand on this work. + +#### Request Cost + +The 'cost' of an inference request is simply the amount of resource(s) the request will consume. In the context of this proposal, the resource(s) considered are the GPU mem & GPU compute time, usually in terms of *saturation* of the model server. +- Ex: This 200 token prompt that has no prefix cache hit is projected to have 456 output tokens and so will take up X amount of GPU memory, and should take ~Y time to complete, and so will contribute to the saturation of model server Z for that Y time. + +### Personas + +These are the personas we target with the scheduler subsystem: + +#### OSS Algorithm Researcher + +The OSS Researcher forks and extends the reference scheduler to add new algorithmic improvements and shows how it impacts workloads. They: + +- Provide a replacement scheduler OR extend the reference scheduler +- Test their changes repeatedly against a set of scenarios +- Validate that their changes do not regress other scenarios +- Propose changes to the reference scheduler or the replacement scheduler protocol + +#### Production Algorithm Contributor + +The production algorithm contributor is an ML engineer or platform owner who observes that a specific scheduling outcome is non-optimal for their workloads and must rapidly fix the issue and get it live. They: + +- Fix a scheduler bug OR Extend the reference scheduler with changes specific to their environment +- Quickly deploy a custom EPP with their changes to their environment, and sustain that fix until upstream merges +- Add new test cases to validate their issue is resolved and does not introduce a regression +- If necessary, open a feature request and proposal to cover the novel requirement + +#### Inference Platform Admin + +The Inference Platform Admin creates and manages the infrastructure necessary to run LLM workloads. They: + +- Configure the model server under an InferencePool to accomplish the objectives of the workloads +- Configure the scheduler associated with an InferencePool to be more efficient or more predictable +- Observe rollouts for degradation of existing workload performance and stop rollout + +#### Inference Workload Owner + +An Inference Workload Owner persona owns and manages 1 or many Generative AI Workloads. They: + +- Configure API objects to leverage new algorithm features in test and production environments +- Reproducibly measure production traffic against new algorithms +- Identify regressions in performance when new algorithm changes are rolled out via alerting + +### Requirements + +We desire the following outcomes from the reference scheduler: + +1. Allow model servers to more predictably approach saturation +2. Make user-visible request latency more predictable +3. Provide isolation between multiple workloads on the same model servers before saturation +4. Prioritize and fairly share resources between multiple workloads on the same model servers after saturation + +We desire the following outcomes from the act of using a modified, or replacement scheduler: + +1. Fast iteration with the ML ecosystem, namely other languages +2. Use data from already integrated informers without having multiple implementations or copies running +3. Acceptable speed of scheduling for 10-1000 QPS systems + +### Design + +We expect the following challenges to be addressed by the reference scheduler design: + +1. Understand the cost of an incoming request and its impact on the target model server before placing it +2. Track the cost of previously issued requests to avoid overloading servers +3. Integrate future cost features such as prefix cache routing into a holistic cost model +4. Support heterogenous model server capabilities in terms of capacity, latency, memory, and features + +In general, the cost of the request is the resources it will consume during its execution. That includes the fraction of compute and memory (as kv-cache) on the accelerator and may be modified by the workload's latency sensitivity (which requires more compute to be set aside to serve the request). + +#### Reference Scheduler + +The reference scheduler will be a Golang scheduler interface that is expected to run cooperatively with other instances of the scheduler with the same configuration or with appropriate 1 version/config skew. + +The reference scheduler receives a list of **candidate endpoints** from the EPP and is responsible for selecting a match. + +The reference scheduler is **informed** about the current state of model servers via **informers**, of which the current informer is a fast-polling loop retrieving model server metrics via the [model server protocol](../003-model-server-protocol/). + +The reference scheduler is configured with a series of **predicates** that **filter** candidate endpoints, removing impossible matches. If no matches or only one match is feasible, that endpoint is selected. If multiple matches are made, the scheduler will consult a list of configured **scorers** to **score** the matches into a **prioritized** list of endpoints, and then **sample** from that list. + +Once an endpoint is selected, the endpoint is **assumed** to be running that request until the EPP observes the termination of that request (most common) OR an informer invalidates the execution of those requests. The scheduler must integrate the impact of assumed load to with informer state, especially when traffic spikes. + +Given that we anticipate a significant amount of future work to integrate heterogenous hardware (different generations / topologies) and heterogeous server roles (prefill-heavy, prefill/decode split, latency objectives), we expect that there will be an **assignment** informer that partitions the candidate endpoints over multiple dimensions for the scheduler. This will decouple the scheduling algorithm from the process of determining the capacity and suitability of different model servers to different dimensions of request cost. + +#### Alternate Scheduler + +The alternate scheduler will be a low-latency mechanism for out-of-process execution of the core endpoint selection option. The alternate scheduler will accept one or more requests to schedule, a list of endpoints, and optionally the associated informer state for those endpoints. The alternate scheduler will return a list of selected endpoints, length of list is configured. Schedulers can run in parallel with one another, with a scheduler selected as the source of truth, allowing for safe development of new scheduling algorithms that can operate on production traffic without impact. + +#### Scheduler Validation + +The proper functioning of the scheduler to prevent regression of performance is critical. A multi-level test strategy will be required: + +- Unit tests that verify scheduling decisions are accurate for all predictates and scorers +- Integration tests that verify concurrent execution as well as cooperative scheduling +- End to end tests that verify production traces against default scheduling achieve specific behavior + + A benchmarking harness will be provided to capture and reproduce a production trace, primarily to aid algorithmic contributors. A small but diverse set of production traces will be used initially to anchor expectations, and scaling both the number of supported traces and efficient regression testing at scale will be critical. + + We anticipate that accelerator availability will limit the scale of e2e testing and contribution. We will develop a **model server stub** that can emulate the behavior of the core expected algorithm for model servers and does not require accelerators. We will support both time-accurate and configurable ratio emulation to allow fast execution. + +### Alternatives + +#### Replaceable but not extensible scheduler + +A non-extensible scheduler would be a black-box that could be replaced, and would be ideal if we do not intend the reference implementation to be featureful or if there is no wide set of scheduler features valuable to many users. + +Given that we desire to have a strong out of the box reference implementation that improves performance for many users with no configuration, we do not select this alternative. + +#### Highly-parameterizable scheduler + +A parameterizable scheduler would have a rich configuration syntax exposed to InferencePool admins (and potentially InferenceModel users). It would be ideal if most inference workloads had no similarities and every workload needed to be configured at the pool level or higher. + +Given that we desire to have a strong reference implementation that improves performance for many users with no out of the box configuration, and that we desire to have many implementations able to directly consume the InferenceModel and InferencePool APIs, we at this time recommend not exposing full configurability of the extension via the Inference* APIs (collectively referred to as Model Routing APIs). Instead, we recommend that algorithms be configurable either by parameterization to the EPP until we have clear design evidence for a need to add new CRDs. At that time, in keeping with the project principles around API extension, we will reassess. \ No newline at end of file From c2e3fa9e5a46962374f3428374adfd8d4898696d Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Wed, 14 May 2025 13:27:20 -0700 Subject: [PATCH 24/53] Updating Readme (#831) --- README.md | 48 ++++++++++++++++++++++++----------------------- site-src/index.md | 6 ++---- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index ffd867586..9a729c1e0 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,33 @@ [![Go Reference](https://pkg.go.dev/badge/sigs.k8s.io/gateway-api-inference-extension.svg)](https://pkg.go.dev/sigs.k8s.io/gateway-api-inference-extension) [![License](https://img.shields.io/github/license/kubernetes-sigs/gateway-api-inference-extension)](/LICENSE) -# Gateway API Inference Extension (GIE) +# Gateway API Inference Extension -This project offers tools for AI Inference, enabling developers to build [Inference Gateways]. +Gateway API Inference Extension optimizes self-hosting Generative Models on Kubernetes. +This is achieved by leveraging Envoy's [External Processing] (ext-proc) to extend any gateway that supports both ext-proc and [Gateway API] into an **[inference gateway]**. -[Inference Gateways]:#concepts-and-definitions + +[Inference Gateway]:#concepts-and-definitions ## Concepts and Definitions -The following are some key industry terms that are important to understand for +The following specific terms to this project: + +- **Inference Gateway (IGW)**: A proxy/load-balancer which has been coupled with an + `Endpoint Picker`. It provides optimized routing and load balancing for + serving Kubernetes self-hosted generative Artificial Intelligence (AI) + workloads. It simplifies the deployment, management, and observability of AI + inference workloads. +- **Inference Scheduler**: An extendable component that makes decisions about which endpoint is optimal (best cost / + best performance) for an inference request based on `Metrics and Capabilities` + from [Model Serving](/docs/proposals/003-model-server-protocol/README.md). +- **Metrics and Capabilities**: Data provided by model serving platforms about + performance, availability and capabilities to optimize routing. Includes + things like [Prefix Cache] status or [LoRA Adapters] availability. +- **Endpoint Picker(EPP)**: An implementation of an `Inference Scheduler` with additional Routing, Flow, and Request Control layers to allow for sophisticated routing strategies. Additional info on the architecture of the EPP [here](https://github.com/kubernetes-sigs/gateway-api-inference-extension/tree/main/docs/proposals/0683-epp-architecture-proposal). + + +The following are key industry terms that are important to understand for this project: - **Model**: A generative AI model that has learned patterns from data and is @@ -26,22 +44,6 @@ this project: (GPUs) that can be attached to Kubernetes nodes to speed up computations, particularly for training and inference tasks. -And the following are more specific terms to this project: - -- **Scheduler**: Makes decisions about which endpoint is optimal (best cost / - best performance) for an inference request based on `Metrics and Capabilities` - from [Model Serving](/docs/proposals/003-model-server-protocol/README.md). -- **Metrics and Capabilities**: Data provided by model serving platforms about - performance, availability and capabilities to optimize routing. Includes - things like [Prefix Cache] status or [LoRA Adapters] availability. -- **Endpoint Selector**: A `Scheduler` combined with `Metrics and Capabilities` - systems is often referred to together as an [Endpoint Selection Extension] - (this is also sometimes referred to as an "endpoint picker", or "EPP"). -- **Inference Gateway**: A proxy/load-balancer which has been coupled with a - `Endpoint Selector`. It provides optimized routing and load balancing for - serving Kubernetes self-hosted generative Artificial Intelligence (AI) - workloads. It simplifies the deployment, management, and observability of AI - inference workloads. For deeper insights and more advanced concepts, refer to our [proposals](/docs/proposals). @@ -49,13 +51,13 @@ For deeper insights and more advanced concepts, refer to our [proposals](/docs/p [Gateway API]:https://github.com/kubernetes-sigs/gateway-api [Prefix Cache]:https://docs.vllm.ai/en/stable/design/v1/prefix_caching.html [LoRA Adapters]:https://docs.vllm.ai/en/stable/features/lora.html -[Endpoint Selection Extension]:https://gateway-api-inference-extension.sigs.k8s.io/#endpoint-selection-extension +[External Processing]:https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_proc_filter ## Technical Overview -This extension upgrades an [ext-proc](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_proc_filter)-capable proxy or gateway - such as Envoy Gateway, kGateway, or the GKE Gateway - to become an **inference gateway** - supporting inference platform teams self-hosting large language models on Kubernetes. This integration makes it easy to expose and control access to your local [OpenAI-compatible chat completion endpoints](https://platform.openai.com/docs/api-reference/chat) to other workloads on or off cluster, or to integrate your self-hosted models alongside model-as-a-service providers in a higher level **AI Gateway** like LiteLLM, Solo AI Gateway, or Apigee. +This extension upgrades an [ext-proc](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_proc_filter) capable proxy or gateway - such as Envoy Gateway, kGateway, or the GKE Gateway - to become an **[inference gateway]** - supporting inference platform teams self-hosting Generative Models (with a current focus on large language models) on Kubernetes. This integration makes it easy to expose and control access to your local [OpenAI-compatible chat completion endpoints](https://platform.openai.com/docs/api-reference/chat) to other workloads on or off cluster, or to integrate your self-hosted models alongside model-as-a-service providers in a higher level **AI Gateway** like LiteLLM, Solo AI Gateway, or Apigee. -The inference gateway: +The Inference Gateway: * Improves the tail latency and throughput of LLM completion requests against Kubernetes-hosted model servers using an extensible request scheduling alogrithm that is kv-cache and request cost aware, avoiding evictions or queueing as load increases * Provides [Kubernetes-native declarative APIs](https://gateway-api-inference-extension.sigs.k8s.io/concepts/api-overview/) to route client model names to use-case specific LoRA adapters and control incremental rollout of new adapter versions, A/B traffic splitting, and safe blue-green base model and model server upgrades diff --git a/site-src/index.md b/site-src/index.md index 61bece27f..e7050ce43 100644 --- a/site-src/index.md +++ b/site-src/index.md @@ -44,11 +44,9 @@ implementations](https://gateway-api.sigs.k8s.io/implementations/). As this pattern stabilizes, we expect a wide set of these implementations to support this project. -### Endpoint Selection Extension +### Endpoint Picker -As part of this project, we're building an initial reference extension. Over -time, we hope to see a wide variety of extensions emerge that follow this -pattern and provide a wide range of choices. +As part of this project, we've built the Endpoing Picker. A pluggable & extensible ext-proc deployment that implements [this architecture](https://github.com/kubernetes-sigs/gateway-api-inference-extension/tree/main/docs/proposals/0683-epp-architecture-proposal). ### Model Server Frameworks From 5f95113675b480cf508fef002e55c2521c2a9ae3 Mon Sep 17 00:00:00 2001 From: Alex Snaps Date: Thu, 15 May 2025 11:51:14 -0600 Subject: [PATCH 25/53] Update index.md (#836) Fix TZ link --- site-src/contributing/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site-src/contributing/index.md b/site-src/contributing/index.md index db0bdc170..5b8905bff 100644 --- a/site-src/contributing/index.md +++ b/site-src/contributing/index.md @@ -30,7 +30,7 @@ channels: Gateway API community meetings happen every Thursday at 10am Pacific Time ([convert to your -timezone](https://dateful.com/time-zone-converter?t=08:00&tz=PT%20%28Pacific%20Time%29)). +timezone](https://dateful.com/time-zone-converter?t=10:00&tz=PT%20%28Pacific%20Time%29)). To receive an invite to this and other WG-Serving community meetings, join the [WG-Serving mailing list](https://groups.google.com/a/kubernetes.io/g/wg-serving). From 97bad7715c7eda3bdecb4b38091068eeff7ca0c7 Mon Sep 17 00:00:00 2001 From: capri-xiyue <52932582+capri-xiyue@users.noreply.github.com> Date: Thu, 15 May 2025 19:13:13 +0000 Subject: [PATCH 26/53] docs: roll out guide (#829) * added rollout guide Signed-off-by: Xiyue Yu * changed format * changed format * fixed comments and added more examples * seperate files * fixed comment * fixed format * fixed format --------- Signed-off-by: Xiyue Yu --- mkdocs.yml | 5 +- site-src/guides/adapter-rollout.md | 18 +- site-src/guides/inferencepool-rollout.md | 379 ++++++++++++++++++++ site-src/guides/replacing-inference-pool.md | 59 --- 4 files changed, 394 insertions(+), 67 deletions(-) create mode 100644 site-src/guides/inferencepool-rollout.md delete mode 100644 site-src/guides/replacing-inference-pool.md diff --git a/mkdocs.yml b/mkdocs.yml index e5927ed53..c5d173c3e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -61,9 +61,10 @@ nav: - Guides: - User Guides: - Getting started: guides/index.md - - Adapter Rollout: guides/adapter-rollout.md + - Rollout: + - Adapter Rollout: guides/adapter-rollout.md + - InferencePool Rollout: guides/inferencepool-rollout.md - Metrics: guides/metrics.md - - Replacing an Inference Pool: guides/replacing-inference-pool.md - Implementer's Guide: guides/implementers.md - Performance: - Benchmark: performance/benchmark/index.md diff --git a/site-src/guides/adapter-rollout.md b/site-src/guides/adapter-rollout.md index 4e7a36675..3eb4d42ce 100644 --- a/site-src/guides/adapter-rollout.md +++ b/site-src/guides/adapter-rollout.md @@ -1,13 +1,18 @@ -# Adapter Rollout +# Lora Adapter Rollout -The goal of this guide is to demonstrate how to rollout a new adapter version. +The goal of this guide is to show you how to perform incremental roll out operations, +which gradually deploy new versions of your inference infrastructure. +You can update LoRA adapters and Inference Pool with minimal service disruption. +This page also provides guidance on traffic splitting and rollbacks to help ensure reliable deployments for LoRA adapters rollout. -## **Prerequisites** - -Follow the steps in the [main guide](index.md) +LoRA adapter rollouts let you deploy new versions of LoRA adapters in phases, +without altering the underlying base model or infrastructure. +Use LoRA adapter rollouts to test improvements, bug fixes, or new features in your LoRA adapters. +## Example -## **Safely rollout v2 adapter** +### Prerequisites +Follow the steps in the [main guide](index.md) ### Load the new adapter version to the model servers @@ -135,3 +140,4 @@ data: ``` With this, all requests should be served by the new adapter version. + diff --git a/site-src/guides/inferencepool-rollout.md b/site-src/guides/inferencepool-rollout.md new file mode 100644 index 000000000..89a384ab4 --- /dev/null +++ b/site-src/guides/inferencepool-rollout.md @@ -0,0 +1,379 @@ +# InferencePool Rollout +The goal of this guide is to show you how to perform incremental roll out operations, +which gradually deploy new versions of your inference infrastructure. +You can update Inference Pool with minimal service disruption. +This page also provides guidance on traffic splitting and rollbacks to help ensure reliable deployments for InferencePool rollout. + +InferencePool rollout is a powerful technique for performing various infrastructure and model updates with minimal disruption and built-in rollback capabilities. +This method allows you to introduce changes incrementally, monitor their impact, and revert to the previous state if necessary. + +## Use Cases +Use Cases for InferencePool Rollout: + +- Node(compute, accelerator) update roll out +- Base model roll out +- Model server framework rollout + +### Node(compute, accelerator) update roll out +Node update roll outs safely migrate inference workloads to new node hardware or accelerator configurations. +This process happens in a controlled manner without interrupting model service. +Use node update roll outs to minimize service disruption during hardware upgrades, driver updates, or security issue resolution. + +### Base model roll out +Base model updates roll out in phases to a new base LLM, retaining compatibility with existing LoRA adapters. +You can use base model update roll outs to upgrade to improved model architectures or to address model-specific issues. + +### Model server framework rollout +Model server framework rollouts enable the seamless deployment of new versions or entirely different serving frameworks, +like updating from an older vLLM version to a newer one, or even migrating from a custom serving solution to a managed one. +This type of rollout is critical for introducing performance enhancements, new features, or security patches within the serving layer itself, +without requiring changes to the underlying base models or application logic. By incrementally rolling out framework updates, +teams can ensure stability and performance, quickly identifying and reverting any regressions before they impact the entire inference workload. + +## How to do InferencePool rollout + +1. **Deploy new infrastructure**: Create a new InferencePool configured with the new node(compute/accelerator) / model server / base model that you chose. +1. **Configure traffic splitting**: Use an HTTPRoute to split traffic between the existing InferencePool and the new InferencePool. The `backendRefs.weight` field controls the traffic percentage allocated to each pool. +1. **Maintain InferenceModel integrity**: Retain the existing InferenceModel configuration to ensure uniform model behavior across both node configurations or base model versions or model server versions. +1. **Preserve rollback capability**: Retain the original nodes and InferencePool during the roll out to facilitate a rollback if necessary. + +## Example +This is an example of InferencePool rollout with node(compute, accelerator) update roll out + +### Prerequisites +Follow the steps in the [main guide](index.md) + +### Deploy new infrastructure +You start with an existing InferencePool named vllm-llama3-8b-instruct. +To replace the original InferencePool, you create a new InferencePool named vllm-llama3-8b-instruct-new along with +InferenceModels and Endpoint Picker Extension configured with the updated node specifications of `nvidia-h100-80gb` accelerator type, + +```yaml +kubectl apply -f - < Date: Thu, 15 May 2025 22:29:14 +0300 Subject: [PATCH 27/53] reduce log level of "prefix cached servers" to TRACE (#842) * reduced log level of prefix cached servers to trace. default log level of epp is 4, having this log in debug level bombs the log with this line. Signed-off-by: Nir Rozenbaum * that's the right line Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum --- pkg/epp/scheduling/plugins/prefix/plugin.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/epp/scheduling/plugins/prefix/plugin.go b/pkg/epp/scheduling/plugins/prefix/plugin.go index 5cb2d4ce4..de2cf70e9 100644 --- a/pkg/epp/scheduling/plugins/prefix/plugin.go +++ b/pkg/epp/scheduling/plugins/prefix/plugin.go @@ -119,6 +119,7 @@ func New(config Config) *Plugin { return m } +// Name returns the name of the plugin. func (m *Plugin) Name() string { return "prefix-cache" } @@ -131,7 +132,7 @@ func (m *Plugin) PreSchedule(ctx *types.SchedulingContext) { } ctx.CycleState.Write(types.StateKey(m.Name()), state) - ctx.Logger.V(logutil.DEBUG).Info(fmt.Sprintf("PreSchedule, cached servers: %+v", state.PrefixCacheServers), "hashes", state.PrefixHashes) + ctx.Logger.V(logutil.TRACE).Info(fmt.Sprintf("PreSchedule, cached servers: %+v", state.PrefixCacheServers), "hashes", state.PrefixHashes) } // If a request was routed to a server, record it in the cache: @@ -148,6 +149,7 @@ func (m *Plugin) PostSchedule(ctx *types.SchedulingContext, res *types.Result) { metrics.RecordPrefixCacheMatch(matchLen*m.HashBlockSize, total*m.HashBlockSize) } +// Score returns the scoring result for the given list of pods based on context. func (m *Plugin) Score(ctx *types.SchedulingContext, pods []types.Pod) map[types.Pod]float64 { scores := make(map[types.Pod]float64, len(pods)) @@ -184,7 +186,7 @@ func (m *Plugin) matchLongestPrefix(ctx *types.SchedulingContext, hashes []Block hash := hashes[i] cachedServers := m.indexer.Get(hash) if len(cachedServers) > 0 { - ctx.Logger.V(logutil.DEBUG).Info("Found cached servers", "cachedServers", cachedServers, "total # blocks", len(hashes), "longest prefix", i) + ctx.Logger.V(logutil.TRACE).Info("Found cached servers", "cachedServers", cachedServers, "total # blocks", len(hashes), "longest prefix", i) for server := range cachedServers { // Update servers with their longest prefix match. // If we already found this server with longer prefix match, don't update it. From 7ef0ab1a2c47d943f34af6c966d8e1f7bbcac6a1 Mon Sep 17 00:00:00 2001 From: kaushik mitra Date: Thu, 15 May 2025 13:33:14 -0700 Subject: [PATCH 28/53] merge https://github.com/AI-Hypercomputer/inference-benchmark/tree/46d638262650a1928e47699d78ab2da062d4422d with latest (#755) refactor benchmark/index.md changes --- .../regression-testing/inferencemodel.yaml | 237 ++++++++++++++ .../multi-lora-regression.yaml | 62 ++++ .../single-workload-regression.yaml | 60 ++++ .../vllm/multi-lora-deployment.yaml | 289 ++++++++++++++++++ mkdocs.yml | 1 + site-src/performance/benchmark/index.md | 8 +- .../performance/regression-testing/index.md | 103 +++++++ tools/benchmark/benchmark.ipynb | 177 ++++++++++- 8 files changed, 918 insertions(+), 19 deletions(-) create mode 100644 config/manifests/regression-testing/inferencemodel.yaml create mode 100644 config/manifests/regression-testing/multi-lora-regression.yaml create mode 100644 config/manifests/regression-testing/single-workload-regression.yaml create mode 100644 config/manifests/regression-testing/vllm/multi-lora-deployment.yaml create mode 100644 site-src/performance/regression-testing/index.md diff --git a/config/manifests/regression-testing/inferencemodel.yaml b/config/manifests/regression-testing/inferencemodel.yaml new file mode 100644 index 000000000..d8eada95a --- /dev/null +++ b/config/manifests/regression-testing/inferencemodel.yaml @@ -0,0 +1,237 @@ +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferenceModel +metadata: + name: adapter-0 +spec: + modelName: adapter-0 + criticality: Critical + poolRef: + name: vllm-llama3-8b-instruct + targetModels: + - name: adapter-0 + weight: 100 + +--- + +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferenceModel +metadata: + name: adapter-1 +spec: + modelName: adapter-1 + criticality: Critical + poolRef: + name: vllm-llama3-8b-instruct + targetModels: + - name: adapter-1 + weight: 100 + +--- + +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferenceModel +metadata: + name: adapter-2 +spec: + modelName: adapter-2 + criticality: Critical + poolRef: + name: vllm-llama3-8b-instruct + targetModels: + - name: adapter-2 + weight: 100 + +--- + +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferenceModel +metadata: + name: adapter-3 +spec: + modelName: adapter-3 + criticality: Critical + poolRef: + name: vllm-llama3-8b-instruct + targetModels: + - name: adapter-3 + weight: 100 + +--- + +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferenceModel +metadata: + name: adapter-4 +spec: + modelName: adapter-4 + criticality: Critical + poolRef: + name: vllm-llama3-8b-instruct + targetModels: + - name: adapter-4 + weight: 100 + +--- + +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferenceModel +metadata: + name: adapter-5 +spec: + modelName: adapter-5 + criticality: Critical + poolRef: + name: vllm-llama3-8b-instruct + targetModels: + - name: adapter-5 + weight: 100 + +--- + +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferenceModel +metadata: + name: adapter-6 +spec: + modelName: adapter-6 + criticality: Critical + poolRef: + name: vllm-llama3-8b-instruct + targetModels: + - name: adapter-6 + weight: 100 + +--- + +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferenceModel +metadata: + name: adapter-7 +spec: + modelName: adapter-7 + criticality: Critical + poolRef: + name: vllm-llama3-8b-instruct + targetModels: + - name: adapter-7 + weight: 100 + +--- + +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferenceModel +metadata: + name: adapter-8 +spec: + modelName: adapter-8 + criticality: Critical + poolRef: + name: vllm-llama3-8b-instruct + targetModels: + - name: adapter-8 + weight: 100 + +--- + +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferenceModel +metadata: + name: adapter-9 +spec: + modelName: adapter-9 + criticality: Critical + poolRef: + name: vllm-llama3-8b-instruct + targetModels: + - name: adapter-9 + weight: 100 + +--- + +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferenceModel +metadata: + name: adapter-10 +spec: + modelName: adapter-10 + criticality: Critical + poolRef: + name: vllm-llama3-8b-instruct + targetModels: + - name: adapter-10 + weight: 100 + +--- + +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferenceModel +metadata: + name: adapter-11 +spec: + modelName: adapter-11 + criticality: Critical + poolRef: + name: vllm-llama3-8b-instruct + targetModels: + - name: adapter-11 + weight: 100 + +--- + +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferenceModel +metadata: + name: adapter-12 +spec: + modelName: adapter-12 + criticality: Critical + poolRef: + name: vllm-llama3-8b-instruct + targetModels: + - name: adapter-12 + weight: 100 + + +--- + +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferenceModel +metadata: + name: adapter-13 +spec: + modelName: adapter-13 + criticality: Critical + poolRef: + name: vllm-llama3-8b-instruct + targetModels: + - name: adapter-13 + weight: 100 + + +--- + +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferenceModel +metadata: + name: adapter-14 +spec: + modelName: adapter-14 + criticality: Critical + poolRef: + name: vllm-llama3-8b-instruct + targetModels: + - name: adapter-14 + weight: 100 + +--- + + +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferenceModel +metadata: + name: base-model +spec: + modelName: meta-llama/Llama-3.1-8B-Instruct + criticality: Critical + poolRef: + name: vllm-llama3-8b-instruct \ No newline at end of file diff --git a/config/manifests/regression-testing/multi-lora-regression.yaml b/config/manifests/regression-testing/multi-lora-regression.yaml new file mode 100644 index 000000000..00b5d7d50 --- /dev/null +++ b/config/manifests/regression-testing/multi-lora-regression.yaml @@ -0,0 +1,62 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: benchmark-tool + name: benchmark-tool +spec: + replicas: 1 + selector: + matchLabels: + app: benchmark-tool + template: + metadata: + labels: + app: benchmark-tool + spec: + containers: + # Build image from this source https://github.com/AI-Hypercomputer/inference-benchmark/tree/46d638262650a1928e47699d78ab2da062d4422d + - image: '' + imagePullPolicy: Always + name: benchmark-tool + command: + - bash + - -c + - ./latency_throughput_curve.sh + env: + - name: IP + value: '' + - name: REQUEST_RATES + value: '20,40,60,80,100,120,140,160,180,200' + - name: BENCHMARK_TIME_SECONDS + value: '300' + - name: TOKENIZER + value: 'meta-llama/Llama-3.1-8B-Instruct' + - name: MODELS + value: 'adapter-0,adapter-1,adapter-2,adapter-3,adapter-4,adapter-5,adapter-6,adapter-7,adapter-8,adapter-9,adapter-10,adapter-11,adapter-12,adapter-13,adapter-14' + - name: TRAFFIC_SPLIT + value: '0.12,0.12,0.12,0.12,0.12,0.06,0.06,0.06,0.06,0.06,0.02,0.02,0.02,0.02,0.02' + - name: BACKEND + value: vllm + - name: PORT + value: "80" + - name: INPUT_LENGTH + value: "1024" + - name: OUTPUT_LENGTH + value: '1024' + - name: FILE_PREFIX + value: benchmark + - name: PROMPT_DATASET_FILE + value: Infinity-Instruct_conversations.json + - name: HF_TOKEN + valueFrom: + secretKeyRef: + key: token + name: hf-token + resources: + limits: + cpu: "2" + memory: 20Gi + requests: + cpu: "2" + memory: 20Gi \ No newline at end of file diff --git a/config/manifests/regression-testing/single-workload-regression.yaml b/config/manifests/regression-testing/single-workload-regression.yaml new file mode 100644 index 000000000..b13b7eed8 --- /dev/null +++ b/config/manifests/regression-testing/single-workload-regression.yaml @@ -0,0 +1,60 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: benchmark-tool + name: benchmark-tool +spec: + replicas: 1 + selector: + matchLabels: + app: benchmark-tool + template: + metadata: + labels: + app: benchmark-tool + spec: + containers: + # Build image from this source https://github.com/AI-Hypercomputer/inference-benchmark/tree/46d638262650a1928e47699d78ab2da062d4422d + - image: '' + imagePullPolicy: Always + name: benchmark-tool + command: + - bash + - -c + - ./latency_throughput_curve.sh + env: + - name: IP + value: '' + - name: REQUEST_RATES + value: '300,310,320,330,340,350' + - name: BENCHMARK_TIME_SECONDS + value: '300' + - name: TOKENIZER + value: 'meta-llama/Llama-3.1-8B-Instruct' + - name: MODELS + value: 'meta-llama/Llama-3.1-8B-Instruct' + - name: BACKEND + value: vllm + - name: PORT + value: "80" + - name: INPUT_LENGTH + value: "1024" + - name: OUTPUT_LENGTH + value: '1024' + - name: FILE_PREFIX + value: benchmark + - name: PROMPT_DATASET_FILE + value: billsum_conversations.json + - name: HF_TOKEN + valueFrom: + secretKeyRef: + key: token + name: hf-token + resources: + limits: + cpu: "2" + memory: 20Gi + requests: + cpu: "2" + memory: 20Gi \ No newline at end of file diff --git a/config/manifests/regression-testing/vllm/multi-lora-deployment.yaml b/config/manifests/regression-testing/vllm/multi-lora-deployment.yaml new file mode 100644 index 000000000..114cd9922 --- /dev/null +++ b/config/manifests/regression-testing/vllm/multi-lora-deployment.yaml @@ -0,0 +1,289 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: vllm-llama3-8b-instruct +spec: + replicas: 10 + selector: + matchLabels: + app: vllm-llama3-8b-instruct + template: + metadata: + labels: + app: vllm-llama3-8b-instruct + spec: + containers: + - name: vllm + image: "vllm/vllm-openai:latest" + imagePullPolicy: Always + command: ["python3", "-m", "vllm.entrypoints.openai.api_server"] + args: + - "--model" + - "meta-llama/Llama-3.1-8B-Instruct" + - "--tensor-parallel-size" + - "1" + - "--port" + - "8000" + - "--enable-lora" + - "--max-loras" + - "15" + - "--max-cpu-loras" + - "15" + - "--compilation-config" + - "3" + - "--max-lora-rank" + - "8" + - "--max-num-seqs" + - "2048" + - "--max-model-len" + - "2048" + - "--no-enable-prefix-caching" + env: + - name: PORT + value: "8000" + - name: HUGGING_FACE_HUB_TOKEN + valueFrom: + secretKeyRef: + name: hf-token + key: token + - name: VLLM_ALLOW_RUNTIME_LORA_UPDATING + value: "false" + ports: + - containerPort: 8000 + name: http + protocol: TCP + lifecycle: + preStop: + # vLLM stops accepting connections when it receives SIGTERM, so we need to sleep + # to give upstream gateways a chance to take us out of rotation. The time we wait + # is dependent on the time it takes for all upstreams to completely remove us from + # rotation. Older or simpler load balancers might take upwards of 30s, but we expect + # our deployment to run behind a modern gateway like Envoy which is designed to + # probe for readiness aggressively. + sleep: + # Upstream gateway probers for health should be set on a low period, such as 5s, + # and the shorter we can tighten that bound the faster that we release + # accelerators during controlled shutdowns. However, we should expect variance, + # as load balancers may have internal delays, and we don't want to drop requests + # normally, so we're often aiming to set this value to a p99 propagation latency + # of readiness -> load balancer taking backend out of rotation, not the average. + # + # This value is generally stable and must often be experimentally determined on + # for a given load balancer and health check period. We set the value here to + # the highest value we observe on a supported load balancer, and we recommend + # tuning this value down and verifying no requests are dropped. + # + # If this value is updated, be sure to update terminationGracePeriodSeconds. + # + seconds: 30 + # + # IMPORTANT: preStop.sleep is beta as of Kubernetes 1.30 - for older versions + # replace with this exec action. + #exec: + # command: + # - /usr/bin/sleep + # - "30" + livenessProbe: + httpGet: + path: /health + port: http + scheme: HTTP + # vLLM's health check is simple, so we can more aggressively probe it. Liveness + # check endpoints should always be suitable for aggressive probing. + periodSeconds: 1 + successThreshold: 1 + # vLLM has a very simple health implementation, which means that any failure is + # likely significant. However, any liveness triggered restart requires the very + # large core model to be reloaded, and so we should bias towards ensuring the + # server is definitely unhealthy vs immediately restarting. Use 5 attempts as + # evidence of a serious problem. + failureThreshold: 5 + timeoutSeconds: 1 + readinessProbe: + httpGet: + path: /health + port: http + scheme: HTTP + # vLLM's health check is simple, so we can more aggressively probe it. Readiness + # check endpoints should always be suitable for aggressive probing, but may be + # slightly more expensive than readiness probes. + periodSeconds: 1 + successThreshold: 1 + # vLLM has a very simple health implementation, which means that any failure is + # likely significant, + failureThreshold: 1 + timeoutSeconds: 1 + # We set a startup probe so that we don't begin directing traffic or checking + # liveness to this instance until the model is loaded. + startupProbe: + # Failure threshold is when we believe startup will not happen at all, and is set + # to the maximum possible time we believe loading a model will take. In our + # default configuration we are downloading a model from HuggingFace, which may + # take a long time, then the model must load into the accelerator. We choose + # 10 minutes as a reasonable maximum startup time before giving up and attempting + # to restart the pod. + # + # IMPORTANT: If the core model takes more than 10 minutes to load, pods will crash + # loop forever. Be sure to set this appropriately. + failureThreshold: 600 + # Set delay to start low so that if the base model changes to something smaller + # or an optimization is deployed, we don't wait unneccesarily. + initialDelaySeconds: 2 + # As a startup probe, this stops running and so we can more aggressively probe + # even a moderately complex startup - this is a very important workload. + periodSeconds: 1 + httpGet: + # vLLM does not start the OpenAI server (and hence make /health available) + # until models are loaded. This may not be true for all model servers. + path: /health + port: http + scheme: HTTP + resources: + limits: + nvidia.com/gpu: 1 + requests: + nvidia.com/gpu: 1 + volumeMounts: + - mountPath: /data + name: data + - mountPath: /dev/shm + name: shm + - name: adapters + mountPath: "/adapters" + initContainers: + - name: lora-adapter-syncer + tty: true + stdin: true + image: us-central1-docker.pkg.dev/k8s-staging-images/gateway-api-inference-extension/lora-syncer:main + restartPolicy: Always + imagePullPolicy: Always + env: + - name: DYNAMIC_LORA_ROLLOUT_CONFIG + value: "/config/configmap.yaml" + volumeMounts: # DO NOT USE subPath, dynamic configmap updates don't work on subPaths + - name: config-volume + mountPath: /config + restartPolicy: Always + + # vLLM allows VLLM_PORT to be specified as an environment variable, but a user might + # create a 'vllm' service in their namespace. That auto-injects VLLM_PORT in docker + # compatible form as `tcp://:` instead of the numeric value vLLM accepts + # causing CrashLoopBackoff. Set service environment injection off by default. + enableServiceLinks: false + + # Generally, the termination grace period needs to last longer than the slowest request + # we expect to serve plus any extra time spent waiting for load balancers to take the + # model server out of rotation. + # + # An easy starting point is the p99 or max request latency measured for your workload, + # although LLM request latencies vary significantly if clients send longer inputs or + # trigger longer outputs. Since steady state p99 will be higher than the latency + # to drain a server, you may wish to slightly this value either experimentally or + # via the calculation below. + # + # For most models you can derive an upper bound for the maximum drain latency as + # follows: + # + # 1. Identify the maximum context length the model was trained on, or the maximum + # allowed length of output tokens configured on vLLM (llama2-7b was trained to + # 4k context length, while llama3-8b was trained to 128k). + # 2. Output tokens are the more compute intensive to calculate and the accelerator + # will have a maximum concurrency (batch size) - the time per output token at + # maximum batch with no prompt tokens being processed is the slowest an output + # token can be generated (for this model it would be about 100ms TPOT at a max + # batch size around 50) + # 3. Calculate the worst case request duration if a request starts immediately + # before the server stops accepting new connections - generally when it receives + # SIGTERM (for this model that is about 4096 / 10 ~ 40s) + # 4. If there are any requests generating prompt tokens that will delay when those + # output tokens start, and prompt token generation is roughly 6x faster than + # compute-bound output token generation, so add 20% to the time from above (40s + + # 16s ~ 55s) + # + # Thus we think it will take us at worst about 55s to complete the longest possible + # request the model is likely to receive at maximum concurrency (highest latency) + # once requests stop being sent. + # + # NOTE: This number will be lower than steady state p99 latency since we stop receiving + # new requests which require continuous prompt token computation. + # NOTE: The max timeout for backend connections from gateway to model servers should + # be configured based on steady state p99 latency, not drain p99 latency + # + # 5. Add the time the pod takes in its preStop hook to allow the load balancers have + # stopped sending us new requests (55s + 30s ~ 85s) + # + # Because termination grace period controls when the Kubelet forcibly terminates a + # stuck or hung process (a possibility due to a GPU crash), there is operational safety + # in keeping the value roughly proportional to the time to finish serving. There is also + # value in adding a bit of extra time to deal with unexpectedly long workloads. + # + # 6. Add a 50% safety buffer to this time since the operational impact should be low + # (85s * 1.5 ~ 130s) + # + # One additional source of drain latency is that some workloads may run close to + # saturation and have queued requests on each server. Since traffic in excess of the + # max sustainable QPS will result in timeouts as the queues grow, we assume that failure + # to drain in time due to excess queues at the time of shutdown is an expected failure + # mode of server overload. If your workload occasionally experiences high queue depths + # due to periodic traffic, consider increasing the safety margin above to account for + # time to drain queued requests. + terminationGracePeriodSeconds: 130 + + volumes: + - name: data + emptyDir: {} + - name: shm + emptyDir: + medium: Memory + - name: adapters + emptyDir: {} + - name: config-volume + configMap: + name: vllm-llama3-8b-instruct-adapters +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: vllm-llama3-8b-instruct-adapters +data: + configmap.yaml: | + vLLMLoRAConfig: + name: vllm-llama3.1-8b-instruct + port: 8000 + defaultBaseModel: meta-llama/Llama-3.1-8B-Instruct + ensureExist: + models: + - id: adapter-0 + source: nvidia/llama-3.1-nemoguard-8b-topic-control + - id: adapter-1 + source: nvidia/llama-3.1-nemoguard-8b-topic-control + - id: adapter-2 + source: nvidia/llama-3.1-nemoguard-8b-topic-control + - id: adapter-3 + source: nvidia/llama-3.1-nemoguard-8b-topic-control + - id: adapter-4 + source: nvidia/llama-3.1-nemoguard-8b-topic-control + - id: adapter-5 + source: nvidia/llama-3.1-nemoguard-8b-topic-control + - id: adapter-6 + source: nvidia/llama-3.1-nemoguard-8b-topic-control + - id: adapter-7 + source: nvidia/llama-3.1-nemoguard-8b-topic-control + - id: adapter-8 + source: nvidia/llama-3.1-nemoguard-8b-topic-control + - id: adapter-9 + source: nvidia/llama-3.1-nemoguard-8b-topic-control + - id: adapter-10 + source: nvidia/llama-3.1-nemoguard-8b-topic-control + - id: adapter-11 + source: nvidia/llama-3.1-nemoguard-8b-topic-control + - id: adapter-12 + source: nvidia/llama-3.1-nemoguard-8b-topic-control + - id: adapter-13 + source: nvidia/llama-3.1-nemoguard-8b-topic-control + - id: adapter-14 + source: nvidia/llama-3.1-nemoguard-8b-topic-control + + + + diff --git a/mkdocs.yml b/mkdocs.yml index c5d173c3e..1741fd1c8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -68,6 +68,7 @@ nav: - Implementer's Guide: guides/implementers.md - Performance: - Benchmark: performance/benchmark/index.md + - Regression Testing: performance/regression-testing/index.md - Reference: - API Reference: reference/spec.md - API Types: diff --git a/site-src/performance/benchmark/index.md b/site-src/performance/benchmark/index.md index 160cc26fb..42d5e727b 100644 --- a/site-src/performance/benchmark/index.md +++ b/site-src/performance/benchmark/index.md @@ -106,7 +106,7 @@ This guide shows how to run the jupyter notebook using vscode after completing k pip install -r ./tools/benchmark/requirements.txt ``` -1. Open the notebook `./tools/benchmark/benchmark.ipynb`, and run each cell. At the end you should - see a bar chart like below where __"ie"__ represents inference extension. This chart is generated using this benchmarking tool with 6 vLLM (v1) model servers (H100 80 GB), [llama2-7b](https://huggingface.co/meta-llama/Llama-2-7b-chat-hf/tree/main) and the [ShareGPT dataset](https://huggingface.co/datasets/anon8231489123/ShareGPT_Vicuna_unfiltered/resolve/main/ShareGPT_V3_unfiltered_cleaned_split.json). - - ![alt text](example-bar-chart.png) +1. Open the notebook `./tools/benchmark/benchmark.ipynb`, and run each cell. In the last cell update the benchmark ids with`inference-extension` and `k8s-svc`. At the end you should + see a bar chart like below where **"ie"** represents inference extension. This chart is generated using this benchmarking tool with 6 vLLM (v1) model servers (H100 80 GB), [llama2-7b](https://huggingface.co/meta-llama/Llama-2-7b-chat-hf/tree/main) and the [ShareGPT dataset](https://huggingface.co/datasets/anon8231489123/ShareGPT_Vicuna_unfiltered/resolve/main/ShareGPT_V3_unfiltered_cleaned_split.json). + + ![alt text](example-bar-chart.png) \ No newline at end of file diff --git a/site-src/performance/regression-testing/index.md b/site-src/performance/regression-testing/index.md new file mode 100644 index 000000000..16b5552f5 --- /dev/null +++ b/site-src/performance/regression-testing/index.md @@ -0,0 +1,103 @@ +# Regression Testing + +Regression testing verifies that recent code changes have not adversely affected the performance or stability of the Inference Gateway. + +This guide explains how to run regression tests against the Gateway API inference extension using the [Latency Profile Generator (LPG)](https://github.com/AI-Hypercomputer/inference-benchmark/) to simulate traffic and collect performance metrics. + +## Prerequisites + +Refer to the [benchmark guide](/site-src/performance/benchmark/index.md) for common setup steps, including deployment of the inference extension, model server setup, scaling the vLLM deployment, and obtaining the Gateway IP. + +## Create the LPG Docker Image + +Follow the detailed instructions [here](https://github.com/AI-Hypercomputer/inference-benchmark/blob/1c92df607751a7ddb04e2152ed7f6aaf85bd9ca7/README.md) to build the LPG Docker image: + +* Create an artifact repository: + +```bash +gcloud artifacts repositories create ai-benchmark --location=us-central1 --repository-format=docker +``` + +* Prepare datasets for [Infinity-Instruct](https://huggingface.co/meta-llama/Llama-3.1-8B-Instruct) and [billsum]((https://huggingface.co/datasets/FiscalNote/billsum)): + +```bash +pip install datasets transformers numpy pandas tqdm matplotlib +python datasets/import_dataset.py --hf_token YOUR_TOKEN +``` + +* Build the benchmark Docker image: + +```bash +docker build -t inference-benchmark . +``` + +* Push the Docker image to your artifact registry: + +```bash +docker tag inference-benchmark us-central1-docker.pkg.dev/{project-name}/ai-benchmark/inference-benchmark +docker push us-central1-docker.pkg.dev/{project-name}/ai-benchmark/inference-benchmark +``` + +## Conduct Regression Tests + +Run benchmarks using the configurations below, which are optimized for NVIDIA H100 GPUs (80 GB). Adjust configurations for other hardware as necessary. + +### Test Case 1: Single Workload + +- **Dataset:** `billsum_conversations.json` (created from [HuggingFace billsum dataset](https://huggingface.co/datasets/FiscalNote/billsum)). + * This dataset features long prompts, making it prefill-heavy and ideal for testing scenarios that emphasize initial token generation. +- **Model:** [Llama 3 (8B)](https://huggingface.co/meta-llama/Llama-3.1-8B-Instruct) (*critical*) +- **Replicas:** 10 (vLLM) +- **Request Rates:** 300–350 (increments of 10) + +Refer to example manifest: +`./config/manifests/regression-testing/single-workload-regression.yaml` + +### Test Case 2: Multi-LoRA + +- **Dataset:** `Infinity-Instruct_conversations.json` (created from [HuggingFace Infinity-Instruct dataset](https://huggingface.co/datasets/BAAI/Infinity-Instruct)). + * This dataset has long outputs, making it decode-heavy and useful for testing scenarios focusing on sustained token generation. +- **Model:** [Llama 3 (8B)](https://huggingface.co/meta-llama/Llama-3.1-8B-Instruct) +- **LoRA Adapters:** 15 adapters (`nvidia/llama-3.1-nemoguard-8b-topic-control`, rank 8, critical) +- **Hardware:** NVIDIA H100 GPUs (80 GB) +- **Traffic Distribution:** 60% (first 5 adapters, each 12%), 30% (next 5, each 6%), 10% (last 5, each 2%) simulating prod/dev/test tiers +- **Max LoRA:** 3 +- **Replicas:** 10 (vLLM) +- **Request Rates:** 20–200 (increments of 20) + +Optionally, you can also run benchmarks using the `ShareGPT` dataset for additional coverage. + +Update deployments for multi-LoRA support: +- vLLM Deployment: `./config/manifests/regression-testing/vllm/multi-lora-deployment.yaml` +- InferenceModel: `./config/manifests/inferencemodel.yaml` + +Refer to example manifest: +`./config/manifests/regression-testing/multi-lora-regression.yaml` + +### Execute Benchmarks + +Benchmark in two phases: before and after applying your changes: + +- **Before changes:** + +```bash +benchmark_id='regression-before' ./tools/benchmark/download-benchmark-results.bash +``` + +- **After changes:** + +```bash +benchmark_id='regression-after' ./tools/benchmark/download-benchmark-results.bash +``` + +## Analyze Benchmark Results + +Use the provided Jupyter notebook (`./tools/benchmark/benchmark.ipynb`) to analyze results: + +- Update benchmark IDs to `regression-before` and `regression-after`. +- Compare latency and throughput metrics, performing regression analysis. +- Check R² values specifically: + - **Prompts Attempted/Succeeded:** Expect R² ≈ 1 + - **Output Tokens per Minute, P90 per Output Token Latency, P90 Latency:** Expect R² close to 1 (allow minor variance). + +Identify significant deviations, investigate causes, and confirm performance meets expected standards. \ No newline at end of file diff --git a/tools/benchmark/benchmark.ipynb b/tools/benchmark/benchmark.ipynb index ffd4c455e..21723fbd7 100644 --- a/tools/benchmark/benchmark.ipynb +++ b/tools/benchmark/benchmark.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": 26, + "execution_count": null, "metadata": { "executionInfo": { "elapsed": 391, @@ -21,16 +21,17 @@ "#@title Configuration. Edit this before running the rest.\n", "\n", "OUTPUT_DIR='output'\n", - "RUN_ID='default-run'\n", + "RUN_ID='example-run'\n", "# Path to the benchmark dir under `gateway-api-inference-extension/benchmark`\n", "BENCHMARK_DIR =\"./\"\n", "# A regex to match the model name, which matches the output file name.\n", - "MODEL_MATCHER='.*llama.*'" + "MODEL_MATCHER='.*llama.*'\n", + "INTERACTIVE_PLOT='False'" ] }, { "cell_type": "code", - "execution_count": 27, + "execution_count": null, "metadata": { "executionInfo": { "elapsed": 33, @@ -55,6 +56,7 @@ "import matplotlib.pyplot as plt\n", "import numpy as np\n", "import math\n", + "from sklearn.metrics import r2_score\n", "import logging\n", "level = logging.INFO\n", "logger = logging.getLogger(__name__)\n", @@ -82,11 +84,11 @@ " XY(x = 'request_rate', x_label = 'QPS', y = 'output_tokens_per_min'),\n", " XY(x = \"request_rate\", x_label = 'QPS', y = \"p90_per_output_token_latency\"),\n", " XY(x = \"request_rate\", x_label = 'QPS', y = \"p90_latency\"),\n", + " XY(x = \"request_rate\", x_label = 'QPS', y=\"num_prompts_attempted\"),\n", + " XY(x = \"request_rate\", x_label = 'QPS', y=\"num_prompts_succeeded\"),\n", "]\n", "SANITY_CHECK_METRICS = [\n", " XY(x = 'request_rate', x_label = 'QPS', y = 'benchmark_time'),\n", - " XY(x = \"request_rate\", x_label = 'QPS', y=\"num_prompts_attempted\"),\n", - " XY(x = \"request_rate\", x_label = 'QPS', y=\"num_prompts_succeeded\"),\n", " XY(x = 'request_rate', x_label = 'QPS', y = 'throughput_rps'),\n", " XY(x = 'request_rate', x_label = 'QPS', y = 'total_input_tokens'),\n", " XY(x = 'request_rate', x_label = 'QPS', y = 'total_output_token'),\n", @@ -110,6 +112,8 @@ " self.interactive = interactive\n", " self.annotate = annotate\n", " self.output_dir = output_dir\n", + " self.data = load_data(self.labels, self.run_id, self.output_dir)\n", + " self.groups = group_data(self.data, self.metrics)\n", "\n", " def withRunId(self, run_id):\n", " return Plotter(run_id, self.labels, self.metrics, self.num_plots_per_row, self.interactive, self.annotate, self.output_dir)\n", @@ -124,10 +128,16 @@ " return Plotter(self.run_id, self.labels, self.metrics, self.num_plots_per_row, self.interactive, self.annotate, output_dir)\n", "\n", " def plot_bar(self):\n", - " data = load_data(self.labels, self.run_id, self.output_dir)\n", - " groups = group_data(data, self.metrics)\n", + " \n", " logger.debug(\"Plotting run id...\")\n", - " plot_bar(self.labels, groups, self.metrics, self.num_plots_per_row, self.interactive, annotate=self.annotate)\n", + " plot_bar(self.labels, self.groups, self.metrics, self.num_plots_per_row, self.interactive, annotate=self.annotate)\n", + "\n", + " def plot_delta(self):\n", + " \"\"\"\n", + " Plot the delta between two labels.\n", + " \"\"\"\n", + " logger.debug(\"Plotting delta for run id...\")\n", + " plot_delta(self.labels, self.groups, self.metrics, self.num_plots_per_row, self.interactive, annotate=self.annotate)\n", "\n", "def filepaths(root_dir):\n", " \"\"\"\n", @@ -201,6 +211,27 @@ " groups = data.groupby(by=['label'],sort=True)\n", " return groups\n", "\n", + "def compute_r2_for_metrics(groups, metrics, label_before, label_after):\n", + " print(\"\\nCoefficient of Determination (R^2) between before and after runs:\")\n", + " for m in metrics:\n", + " try:\n", + " df_b = groups.get_group(label_before).set_index('request_rate')\n", + " df_a = groups.get_group(label_after).set_index('request_rate')\n", + " except KeyError:\n", + " print(f\" Skipping {m.y}: missing group data for '{label_before}' or '{label_after}'\")\n", + " continue\n", + " common = sorted(set(df_b.index).intersection(df_a.index))\n", + " yb = df_b.loc[common, m.y].values\n", + " ya = df_a.loc[common, m.y].values\n", + " mask = ~np.isnan(yb) & ~np.isnan(ya)\n", + " yb, ya = yb[mask], ya[mask]\n", + " if len(yb) > 1 and np.any(yb != 0):\n", + " r2 = r2_score(yb, ya)\n", + " print(f\" {m.y:<30} R^2 = {r2:.4f}\")\n", + " else:\n", + " print(f\" {m.y:<30} insufficient data for R^2 calculation\")\n", + "\n", + "\n", "def init_plot(metrics, num_plots_per_row=NUM_PLOTS_PER_ROW):\n", " num_plots_per_row = min(num_plots_per_row, len(metrics))\n", " row_count = math.ceil(len(metrics) / num_plots_per_row)\n", @@ -229,7 +260,7 @@ " plot_func(curAx, m)\n", " return fig, axes\n", "\n", - "def plot_bar(labels, groups, metrics=CORE_METRICS, num_plots_per_row=NUM_PLOTS_PER_ROW, interactive=False, annotate=False):\n", + "def plot_bar(labels, groups, metrics=CORE_METRICS, num_plots_per_row=NUM_PLOTS_PER_ROW, interactive=INTERACTIVE_PLOT, annotate=False):\n", " labels = [label.alias for label in labels]\n", " logger.debug(f'Prnting bar chart for {labels}')\n", " logger.debug(f'groups: {groups}')\n", @@ -294,7 +325,106 @@ " fig, axes = plot_metrics(metrics, plot_func, num_plots_per_row)\n", " fig.tight_layout(rect=[0, 0.03, 1, 0.95])\n", " plt.show()\n", - "\n" + "\n", + "def plot_delta(labels, groups, metrics=CORE_METRICS, num_plots_per_row=NUM_PLOTS_PER_ROW, interactive=True, annotate=False):\n", + " \"\"\"\n", + " Plot the delta between base_label and compare_label for each metric.\n", + " A positive delta means compare_label has a higher value than base_label.\n", + " \"\"\"\n", + " base_label = labels[0].name\n", + " compare_label = labels[1].name\n", + " logger.debug(f'Printing delta chart for {base_label} vs {compare_label}')\n", + "\n", + " try:\n", + " base_df = groups.get_group((base_label,))\n", + " compare_df = groups.get_group((compare_label,))\n", + " except Exception as e:\n", + " logger.error(f\"Error getting data for labels {base_label} and {compare_label}: {e}\")\n", + " return\n", + "\n", + " y_columns = [m.y for m in metrics]\n", + "\n", + " # 1. Find common request rates\n", + " base_rates = set(base_df['request_rate'].astype(int))\n", + " compare_rates = set(compare_df['request_rate'].astype(int))\n", + " common_rates = sorted(list(base_rates.intersection(compare_rates)))[:6]\n", + "\n", + " if not common_rates:\n", + " logger.error(f\"No common request rates found between {base_label} and {compare_label}\")\n", + " return\n", + "\n", + " # 2. Prepare data for delta calculation\n", + " base_data = base_df.set_index('request_rate').to_dict()\n", + " compare_data = compare_df.set_index('request_rate').to_dict()\n", + "\n", + " # Calculate deltas (compare_label - base_label)\n", + " delta_data = {y_col: {} for y_col in y_columns}\n", + " for y_col in y_columns:\n", + " for rate in common_rates:\n", + " base_val = base_data.get(y_col, {}).get(rate, np.nan)\n", + " compare_val = compare_data.get(y_col, {}).get(rate, np.nan)\n", + "\n", + " if not np.isnan(base_val) and not np.isnan(compare_val):\n", + " delta_data[y_col][rate] = (compare_val - base_val)/base_val*100\n", + " else:\n", + " delta_data[y_col][rate] = np.nan\n", + "\n", + " # 3. Plotting\n", + " def plot_func(curAx, m):\n", + " x = np.arange(len(common_rates))\n", + " y_values = [delta_data[m.y].get(rr, np.nan) for rr in common_rates]\n", + "\n", + " # Determine colors based on positive/negative values\n", + " colors = ['green' if val > 0 else 'blue' for val in y_values]\n", + "\n", + " rects = curAx.bar(x, y_values, 0.6, color=colors)\n", + "\n", + " # Add a horizontal line at y=0\n", + " curAx.axhline(y=0, color='black', linestyle='-', linewidth=1)\n", + "\n", + " if annotate:\n", + " for rect, val in zip(rects, y_values):\n", + " if not np.isnan(val):\n", + " height = rect.get_height()\n", + " # For negative bars, put text above the bar\n", + " vert_align = 'bottom' if val >= 0 else 'top'\n", + " y_offset = 3 if val >= 0 else -3\n", + "\n", + " curAx.annotate(f'{val:.2f}',\n", + " xy=(rect.get_x() + rect.get_width() / 2, val),\n", + " xytext=(0, y_offset), # vertical offset\n", + " textcoords=\"offset points\",\n", + " ha='center', va=vert_align)\n", + "\n", + " # Create a title that shows what this delta represents\n", + " title = f\"Delta: {compare_label} - {base_label} ({m.y})\"\n", + " curAx.set_title(title, fontsize=12)\n", + "\n", + " # Add labels\n", + " curAx.set_xlabel(m.x_label, fontsize=axis_label_fontsize)\n", + " #curAx.set_ylabel(f\"% Delta in {m.y_label}\", fontsize=axis_label_fontsize)\n", + " curAx.set_xticks(x)\n", + " curAx.set_xticklabels(common_rates)\n", + " curAx.tick_params(axis='both', labelsize=tick_label_fontsize)\n", + "\n", + " # Create a dummy handle for the legend\n", + " legend_handle = [plt.Rectangle((0,0),1,1,color='green'),\n", + " plt.Rectangle((0,0),1,1,color='blue')]\n", + " legend_label = [f'{compare_label} > {base_label}',\n", + " f'{compare_label} < {base_label}']\n", + "\n", + " return legend_handle, legend_label\n", + "\n", + " # Create plot with metrics\n", + " fig, axes = plot_metrics(metrics, plot_func, num_plots_per_row)\n", + "\n", + " # Add an overall title for the figure\n", + " fig.suptitle(f\"% Delta Metrics: {compare_label} - {base_label}\",\n", + " fontsize=title_fontsize, y=0.98)\n", + "\n", + " plt.subplots_adjust(bottom=0.15, top=0.9) # Make room for legends\n", + " fig.tight_layout(rect=[0, 0.1, 1, 0.95]) # Adjust the rectangle in which the subplots fit\n", + " plt.show()" ] }, { @@ -320,9 +450,26 @@ "outputs": [], "source": [ "#@title Plot Result\n", - "\n", - "pl = Plotter(run_id=RUN_ID, labels=[Label('inference-extension'),Label('k8s-svc')], output_dir=OUTPUT_DIR)\n", - "pl.plot_bar()" + "# initialize the plotter with the run id and labels. \n", + "# Example labels are 'inference-extension' and 'k8s-svc' if comparing Inference Extension and K8s Service \n", + "# 'regression-before' and 'regression-after' if comparing two different runs of inference extension to see the regression\n", + "\n", + "benchmark_id1 = # eg 'regression-before' or 'inference-extension'\n", + "benchmark_id2 = # eg 'regression-after' or 'k8s-svc'\n", + "labels = [Label(benchmark_id1), Label(benchmark_id2,)]\n", + "\n", + "# Plot bar chart of metrics\n", + "pl = Plotter(run_id=RUN_ID, labels=labels, output_dir=OUTPUT_DIR)\n", + "pl.plot_bar()\n", + "pl.plot_delta()\n", + "\n", + "# Load & group data to compute R^2\n", + "all_data = load_data(labels, RUN_ID, OUTPUT_DIR)\n", + "groups = group_data(all_data)\n", + "compute_r2_for_metrics(groups, CORE_METRICS,\n", + " label_before=benchmark_id1,\n", + " label_after=benchmark_id2)\n", + "\n" ] } ], @@ -355,4 +502,4 @@ }, "nbformat": 4, "nbformat_minor": 0 -} +} \ No newline at end of file From 6e8a2effa41c95c2e3f7cdc686fbfadbfa7fb234 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Fri, 16 May 2025 00:23:13 +0300 Subject: [PATCH 29/53] fixed log before picker (#844) Signed-off-by: Nir Rozenbaum --- pkg/epp/scheduling/scheduler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/epp/scheduling/scheduler.go b/pkg/epp/scheduling/scheduler.go index 29ffee897..b484cdebc 100644 --- a/pkg/epp/scheduling/scheduler.go +++ b/pkg/epp/scheduling/scheduler.go @@ -196,7 +196,7 @@ func (s *Scheduler) runPickerPlugin(ctx *types.SchedulingContext, weightedScoreP i++ } - loggerDebug.Info("Before running picker plugin", "pods", weightedScorePerPod) + loggerDebug.Info("Before running picker plugin", "pods weighted score", fmt.Sprint(weightedScorePerPod)) before := time.Now() result := s.picker.Pick(ctx, scoredPods) metrics.RecordSchedulerPluginProcessingLatency(plugins.PickerPluginType, s.picker.Name(), time.Since(before)) From 7c63c0d9cf7844e7182a9929d6b294f322b5442a Mon Sep 17 00:00:00 2001 From: Cong Liu Date: Thu, 15 May 2025 21:07:13 -0700 Subject: [PATCH 30/53] Reorganize scheduling plugins (#837) --- cmd/epp/main.go | 2 +- pkg/epp/scheduling/config.go | 2 +- pkg/epp/scheduling/plugins/README.md | 16 ++++++++++++++++ .../plugins/{ => multi}/prefix/indexer.go | 0 .../plugins/{ => multi}/prefix/indexer_test.go | 0 .../plugins/{ => multi}/prefix/plugin.go | 0 .../plugins/{ => multi}/prefix/plugin_test.go | 0 7 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 pkg/epp/scheduling/plugins/README.md rename pkg/epp/scheduling/plugins/{ => multi}/prefix/indexer.go (100%) rename pkg/epp/scheduling/plugins/{ => multi}/prefix/indexer_test.go (100%) rename pkg/epp/scheduling/plugins/{ => multi}/prefix/plugin.go (100%) rename pkg/epp/scheduling/plugins/{ => multi}/prefix/plugin_test.go (100%) diff --git a/cmd/epp/main.go b/cmd/epp/main.go index bda7cc207..9c023f26d 100644 --- a/cmd/epp/main.go +++ b/cmd/epp/main.go @@ -46,8 +46,8 @@ import ( "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/filter" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/multi/prefix" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/picker" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/prefix" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/scorer" runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" envutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/env" diff --git a/pkg/epp/scheduling/config.go b/pkg/epp/scheduling/config.go index 02922894d..77fb26fb6 100644 --- a/pkg/epp/scheduling/config.go +++ b/pkg/epp/scheduling/config.go @@ -18,7 +18,7 @@ package scheduling import ( "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/prefix" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/multi/prefix" ) // NewSchedulerConfig creates a new SchedulerConfig object with the given plugins. diff --git a/pkg/epp/scheduling/plugins/README.md b/pkg/epp/scheduling/plugins/README.md new file mode 100644 index 000000000..80118ddd9 --- /dev/null +++ b/pkg/epp/scheduling/plugins/README.md @@ -0,0 +1,16 @@ +# Scheduling Plugins + +This package contains the scheduling plugin interface definitions and +implementations. + +Plugins are organized by the following rule. Follow this rule when adding a new +plugin. + +``` +plugins/ +|__ filter/(Plugins that only implement the Filter interface.) +|__ scorer/ (Plugins that only implement the Scorer interface.) +|__ picker/(Plugins that only implement the Picker interface.) +|__ multi/ (Plugins that implement multiple plugin interfaces.) +|____prefix/ (Prefix cache aware scheduling plugin.) +``` diff --git a/pkg/epp/scheduling/plugins/prefix/indexer.go b/pkg/epp/scheduling/plugins/multi/prefix/indexer.go similarity index 100% rename from pkg/epp/scheduling/plugins/prefix/indexer.go rename to pkg/epp/scheduling/plugins/multi/prefix/indexer.go diff --git a/pkg/epp/scheduling/plugins/prefix/indexer_test.go b/pkg/epp/scheduling/plugins/multi/prefix/indexer_test.go similarity index 100% rename from pkg/epp/scheduling/plugins/prefix/indexer_test.go rename to pkg/epp/scheduling/plugins/multi/prefix/indexer_test.go diff --git a/pkg/epp/scheduling/plugins/prefix/plugin.go b/pkg/epp/scheduling/plugins/multi/prefix/plugin.go similarity index 100% rename from pkg/epp/scheduling/plugins/prefix/plugin.go rename to pkg/epp/scheduling/plugins/multi/prefix/plugin.go diff --git a/pkg/epp/scheduling/plugins/prefix/plugin_test.go b/pkg/epp/scheduling/plugins/multi/prefix/plugin_test.go similarity index 100% rename from pkg/epp/scheduling/plugins/prefix/plugin_test.go rename to pkg/epp/scheduling/plugins/multi/prefix/plugin_test.go From 46c5c5ed0ddb59b0b05c687319deea51de9cc983 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Mon, 19 May 2025 02:03:16 +0300 Subject: [PATCH 31/53] updated godoc on filters, pickers and prefix. (#850) Signed-off-by: Nir Rozenbaum --- .../plugins/filter/decision_tree_filter.go | 3 +++ .../plugins/filter/least_kvcache_filter.go | 2 +- .../scheduling/plugins/filter/least_queue_filter.go | 5 ++--- .../plugins/filter/lora_affinity_filter.go | 2 +- .../scheduling/plugins/filter/low_queue_filter.go | 2 +- .../plugins/filter/sheddable_capacity_filter.go | 2 +- pkg/epp/scheduling/plugins/multi/prefix/plugin.go | 12 +++++++++++- .../scheduling/plugins/picker/max_score_picker.go | 6 ++++-- pkg/epp/scheduling/plugins/picker/random_picker.go | 8 ++++++++ 9 files changed, 32 insertions(+), 10 deletions(-) diff --git a/pkg/epp/scheduling/plugins/filter/decision_tree_filter.go b/pkg/epp/scheduling/plugins/filter/decision_tree_filter.go index 399780b6f..066a90d69 100644 --- a/pkg/epp/scheduling/plugins/filter/decision_tree_filter.go +++ b/pkg/epp/scheduling/plugins/filter/decision_tree_filter.go @@ -22,6 +22,9 @@ import ( logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) +// compile-time type validation +var _ plugins.Filter = &DecisionTreeFilter{} + // DecisionTreeFilter applies current fitler, and then recursively applies next filters // depending success or failure of the current filter. // It can be used to construct a flow chart algorithm. diff --git a/pkg/epp/scheduling/plugins/filter/least_kvcache_filter.go b/pkg/epp/scheduling/plugins/filter/least_kvcache_filter.go index eed647682..ee7d82486 100644 --- a/pkg/epp/scheduling/plugins/filter/least_kvcache_filter.go +++ b/pkg/epp/scheduling/plugins/filter/least_kvcache_filter.go @@ -26,7 +26,7 @@ import ( // compile-time type validation var _ plugins.Filter = &LeastKVCacheFilter{} -// NewLeastKVCacheFilter returns a new LeastKVCacheFilter. +// NewLeastKVCacheFilter initializes a new LeastKVCacheFilter and returns its pointer. func NewLeastKVCacheFilter() *LeastKVCacheFilter { return &LeastKVCacheFilter{} } diff --git a/pkg/epp/scheduling/plugins/filter/least_queue_filter.go b/pkg/epp/scheduling/plugins/filter/least_queue_filter.go index 7a71d5191..7e0bfc32a 100644 --- a/pkg/epp/scheduling/plugins/filter/least_queue_filter.go +++ b/pkg/epp/scheduling/plugins/filter/least_queue_filter.go @@ -26,7 +26,7 @@ import ( // compile-time type validation var _ plugins.Filter = &LeastQueueFilter{} -// NewLeastQueueFilter returns a new LeastQueueFilter. +// NewLeastQueueFilter initializes a new LeastQueueFilter and returns its pointer. func NewLeastQueueFilter() *LeastQueueFilter { return &LeastQueueFilter{} } @@ -35,8 +35,7 @@ func NewLeastQueueFilter() *LeastQueueFilter { // (max-min) by the number of pods, and finds the pods that fall into the first range. // The intuition is that if there are multiple pods that share similar queue size in the low range, // we should consider them all instead of the absolute minimum one. This worked better than picking -// the least one as it gives more choices for the next filter, which on aggregate gave better -// results. +// the least one as it gives more choices for the next filter, which on aggregate gave better results. type LeastQueueFilter struct{} // Name returns the name of the filter. diff --git a/pkg/epp/scheduling/plugins/filter/lora_affinity_filter.go b/pkg/epp/scheduling/plugins/filter/lora_affinity_filter.go index bc744a8ef..bc1e55b03 100644 --- a/pkg/epp/scheduling/plugins/filter/lora_affinity_filter.go +++ b/pkg/epp/scheduling/plugins/filter/lora_affinity_filter.go @@ -28,7 +28,7 @@ import ( // compile-time type validation var _ plugins.Filter = &LoraAffinityFilter{} -// NewLoraAffinityFilter returns a new LoraAffinityFilter. +// NewLoraAffinityFilter initializes a new LoraAffinityFilter and returns its pointer. func NewLoraAffinityFilter() *LoraAffinityFilter { return &LoraAffinityFilter{ loraAffinityThreshold: config.Conf.LoraAffinityThreshold, diff --git a/pkg/epp/scheduling/plugins/filter/low_queue_filter.go b/pkg/epp/scheduling/plugins/filter/low_queue_filter.go index feb599b43..ce7d3523a 100644 --- a/pkg/epp/scheduling/plugins/filter/low_queue_filter.go +++ b/pkg/epp/scheduling/plugins/filter/low_queue_filter.go @@ -25,7 +25,7 @@ import ( // compile-time type validation var _ plugins.Filter = &LowQueueFilter{} -// NewLowQueueFilter returns a new LowQueueFilter. +// NewLowQueueFilter initializes a new LowQueueFilter and returns its pointer. func NewLowQueueFilter() *LowQueueFilter { return &LowQueueFilter{ queueingThresholdLoRA: config.Conf.QueueingThresholdLoRA, diff --git a/pkg/epp/scheduling/plugins/filter/sheddable_capacity_filter.go b/pkg/epp/scheduling/plugins/filter/sheddable_capacity_filter.go index 5a298a022..cdc3355fd 100644 --- a/pkg/epp/scheduling/plugins/filter/sheddable_capacity_filter.go +++ b/pkg/epp/scheduling/plugins/filter/sheddable_capacity_filter.go @@ -25,7 +25,7 @@ import ( // compile-time type validation var _ plugins.Filter = &SheddableCapacityFilter{} -// NewSheddableCapacityFilter returns a new SheddableCapacityFilter. +// NewSheddableCapacityFilter initializes a new SheddableCapacityFilter and returns its pointer. func NewSheddableCapacityFilter() *SheddableCapacityFilter { return &SheddableCapacityFilter{ queueThreshold: config.Conf.QueueThresholdCritical, diff --git a/pkg/epp/scheduling/plugins/multi/prefix/plugin.go b/pkg/epp/scheduling/plugins/multi/prefix/plugin.go index de2cf70e9..b07f085db 100644 --- a/pkg/epp/scheduling/plugins/multi/prefix/plugin.go +++ b/pkg/epp/scheduling/plugins/multi/prefix/plugin.go @@ -23,6 +23,7 @@ import ( "github.com/cespare/xxhash/v2" k8stypes "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) @@ -87,6 +88,7 @@ func (s ServerID) String() string { return k8stypes.NamespacedName(s).String() } +// compile-time type validation var _ types.StateData = &schedulingContextState{} // This is the state of this plugin to be used during a scheduling cycle. @@ -111,6 +113,12 @@ func (s *schedulingContextState) Clone() types.StateData { } } +// compile-time type validation +var _ plugins.PreSchedule = &Plugin{} +var _ plugins.Scorer = &Plugin{} +var _ plugins.PostSchedule = &Plugin{} + +// New initializes a new prefix Plugin and returns its pointer. func New(config Config) *Plugin { m := &Plugin{ Config: config, @@ -124,6 +132,7 @@ func (m *Plugin) Name() string { return "prefix-cache" } +// PreSchedule initializes the prefix plugin state for the current scheduling cycle. func (m *Plugin) PreSchedule(ctx *types.SchedulingContext) { hashes := hashPrompt(ctx, m.HashBlockSize, m.MaxPrefixBlocksToMatch) state := &schedulingContextState{ @@ -135,7 +144,7 @@ func (m *Plugin) PreSchedule(ctx *types.SchedulingContext) { ctx.Logger.V(logutil.TRACE).Info(fmt.Sprintf("PreSchedule, cached servers: %+v", state.PrefixCacheServers), "hashes", state.PrefixHashes) } -// If a request was routed to a server, record it in the cache: +// PostSchedule records in the plugin cache the result of the scheduling selection. func (m *Plugin) PostSchedule(ctx *types.SchedulingContext, res *types.Result) { targetPod := res.TargetPod.GetPod() state, err := m.getPrefixState(ctx.CycleState) @@ -199,6 +208,7 @@ func (m *Plugin) matchLongestPrefix(ctx *types.SchedulingContext, hashes []Block return res } +// getPrefixState returns the cycle state as a schedulingContextState. func (m *Plugin) getPrefixState(cycleState *types.CycleState) (*schedulingContextState, error) { prefixStateKey := types.StateKey(m.Name()) state, err := cycleState.Read(prefixStateKey) diff --git a/pkg/epp/scheduling/plugins/picker/max_score_picker.go b/pkg/epp/scheduling/plugins/picker/max_score_picker.go index a6d7b397c..e85aee5fe 100644 --- a/pkg/epp/scheduling/plugins/picker/max_score_picker.go +++ b/pkg/epp/scheduling/plugins/picker/max_score_picker.go @@ -24,11 +24,13 @@ import ( logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) +// compile-time type validation var _ plugins.Picker = &MaxScorePicker{} -func NewMaxScorePicker() plugins.Picker { +// NewMaxScorePicker initializes a new MaxScorePicker and returns its pointer. +func NewMaxScorePicker() *MaxScorePicker { return &MaxScorePicker{ - random: &RandomPicker{}, + random: NewRandomPicker(), } } diff --git a/pkg/epp/scheduling/plugins/picker/random_picker.go b/pkg/epp/scheduling/plugins/picker/random_picker.go index fb9f9a295..1d12198ce 100644 --- a/pkg/epp/scheduling/plugins/picker/random_picker.go +++ b/pkg/epp/scheduling/plugins/picker/random_picker.go @@ -25,15 +25,23 @@ import ( logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) +// compile-time type validation var _ plugins.Picker = &RandomPicker{} +// NewRandomPicker initializes a new RandomPicker and returns its pointer. +func NewRandomPicker() *RandomPicker { + return &RandomPicker{} +} + // RandomPicker picks a random pod from the list of candidates. type RandomPicker struct{} +// Name returns the name of the picker. func (p *RandomPicker) Name() string { return "random" } +// Pick selects a random pod from the list of candidates. func (p *RandomPicker) Pick(ctx *types.SchedulingContext, scoredPods []*types.ScoredPod) *types.Result { ctx.Logger.V(logutil.DEBUG).Info(fmt.Sprintf("Selecting a random pod from %d candidates: %+v", len(scoredPods), scoredPods)) i := rand.Intn(len(scoredPods)) From e8834c311ed599e2a99f85328cf2e0ae143402c3 Mon Sep 17 00:00:00 2001 From: Luke Van Drie Date: Sun, 18 May 2025 16:19:14 -0700 Subject: [PATCH 32/53] Fix: Ignore header order in hermetic test (#849) The `TestFullDuplexStreamed_KubeInferenceModelRequest` test was flaky due to non-deterministic ordering of headers in the `ProcessingResponse.response_headers.set_headers` field. This change introduces `protocmp.SortRepeated` to sort these headers by key before comparison. HTTP header order is not semantically significant, so this change makes the test more robust without compromising its correctness. --- test/integration/epp/hermetic_test.go | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/test/integration/epp/hermetic_test.go b/test/integration/epp/hermetic_test.go index 923392530..3035a9b57 100644 --- a/test/integration/epp/hermetic_test.go +++ b/test/integration/epp/hermetic_test.go @@ -55,7 +55,6 @@ import ( metricsutils "k8s.io/component-base/metrics/testutil" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/cache" - "sigs.k8s.io/controller-runtime/pkg/client" k8sclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/config" "sigs.k8s.io/controller-runtime/pkg/envtest" @@ -66,7 +65,6 @@ import ( "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" epptestutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/testing" @@ -1270,7 +1268,12 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { if err != nil && !test.wantErr { t.Errorf("Unexpected error, got: %v, want error: %v", err, test.wantErr) } - if diff := cmp.Diff(test.wantResponses, responses, protocmp.Transform()); diff != "" { + if diff := cmp.Diff(test.wantResponses, responses, + protocmp.Transform(), + protocmp.SortRepeated(func(a, b *configPb.HeaderValueOption) bool { + return a.GetHeader().GetKey() < b.GetHeader().GetKey() + }), + ); diff != "" { t.Errorf("Unexpected response, (-want +got): %v", diff) } @@ -1398,7 +1401,7 @@ func BeforeSuite() func() { // Init runtime. ctrl.SetLogger(logger) - mgr, err := server.NewManagerWithOptions(cfg, managerTestOptions("default", "vllm-llama3-8b-instruct-pool")) + mgr, err := runserver.NewManagerWithOptions(cfg, managerTestOptions("default", "vllm-llama3-8b-instruct-pool")) if err != nil { logutil.Fatal(logger, err, "Failed to create controller manager") } @@ -1537,7 +1540,7 @@ func managerTestOptions(namespace, name string) ctrl.Options { return ctrl.Options{ Scheme: scheme, Cache: cache.Options{ - ByObject: map[client.Object]cache.ByObject{ + ByObject: map[k8sclient.Object]cache.ByObject{ &corev1.Pod{}: { Namespaces: map[string]cache.Config{ namespace: {}, From bd457e1f63543a1541cd81252cd0619187770c68 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 May 2025 08:15:17 -0700 Subject: [PATCH 33/53] Bump the kubernetes group with 6 updates (#851) Bumps the kubernetes group with 6 updates: | Package | From | To | | --- | --- | --- | | [k8s.io/api](https://github.com/kubernetes/api) | `0.32.4` | `0.32.5` | | [k8s.io/apiextensions-apiserver](https://github.com/kubernetes/apiextensions-apiserver) | `0.32.4` | `0.32.5` | | [k8s.io/apimachinery](https://github.com/kubernetes/apimachinery) | `0.32.4` | `0.32.5` | | [k8s.io/client-go](https://github.com/kubernetes/client-go) | `0.32.4` | `0.32.5` | | [k8s.io/code-generator](https://github.com/kubernetes/code-generator) | `0.32.4` | `0.32.5` | | [k8s.io/component-base](https://github.com/kubernetes/component-base) | `0.32.4` | `0.32.5` | Updates `k8s.io/api` from 0.32.4 to 0.32.5 - [Commits](https://github.com/kubernetes/api/compare/v0.32.4...v0.32.5) Updates `k8s.io/apiextensions-apiserver` from 0.32.4 to 0.32.5 - [Release notes](https://github.com/kubernetes/apiextensions-apiserver/releases) - [Commits](https://github.com/kubernetes/apiextensions-apiserver/compare/v0.32.4...v0.32.5) Updates `k8s.io/apimachinery` from 0.32.4 to 0.32.5 - [Commits](https://github.com/kubernetes/apimachinery/compare/v0.32.4...v0.32.5) Updates `k8s.io/client-go` from 0.32.4 to 0.32.5 - [Changelog](https://github.com/kubernetes/client-go/blob/master/CHANGELOG.md) - [Commits](https://github.com/kubernetes/client-go/compare/v0.32.4...v0.32.5) Updates `k8s.io/code-generator` from 0.32.4 to 0.32.5 - [Commits](https://github.com/kubernetes/code-generator/compare/v0.32.4...v0.32.5) Updates `k8s.io/component-base` from 0.32.4 to 0.32.5 - [Commits](https://github.com/kubernetes/component-base/compare/v0.32.4...v0.32.5) --- updated-dependencies: - dependency-name: k8s.io/api dependency-version: 0.32.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: kubernetes - dependency-name: k8s.io/apiextensions-apiserver dependency-version: 0.32.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: kubernetes - dependency-name: k8s.io/apimachinery dependency-version: 0.32.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: kubernetes - dependency-name: k8s.io/client-go dependency-version: 0.32.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: kubernetes - dependency-name: k8s.io/code-generator dependency-version: 0.32.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: kubernetes - dependency-name: k8s.io/component-base dependency-version: 0.32.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: kubernetes ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 18 +++++++++--------- go.sum | 28 ++++++++++++++-------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/go.mod b/go.mod index 9888e7be9..7fa3693aa 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,12 @@ module sigs.k8s.io/gateway-api-inference-extension go 1.24.0 require ( + github.com/cespare/xxhash/v2 v2.3.0 github.com/elastic/crd-ref-docs v0.1.0 github.com/envoyproxy/go-control-plane/envoy v1.32.4 github.com/go-logr/logr v1.4.2 github.com/google/go-cmp v0.7.0 + github.com/google/uuid v1.6.0 github.com/onsi/ginkgo/v2 v2.23.4 github.com/onsi/gomega v1.37.0 github.com/prometheus/client_golang v1.22.0 @@ -17,12 +19,12 @@ require ( go.uber.org/zap v1.27.0 google.golang.org/grpc v1.71.1 google.golang.org/protobuf v1.36.6 - k8s.io/api v0.32.4 - k8s.io/apiextensions-apiserver v0.32.4 - k8s.io/apimachinery v0.32.4 - k8s.io/client-go v0.32.4 - k8s.io/code-generator v0.32.4 - k8s.io/component-base v0.32.4 + k8s.io/api v0.32.5 + k8s.io/apiextensions-apiserver v0.32.5 + k8s.io/apimachinery v0.32.5 + k8s.io/client-go v0.32.5 + k8s.io/code-generator v0.32.5 + k8s.io/component-base v0.32.5 k8s.io/utils v0.0.0-20241210054802-24370beab758 sigs.k8s.io/controller-runtime v0.20.4 sigs.k8s.io/gateway-api v1.3.0 @@ -40,7 +42,6 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cncf/xds/go v0.0.0-20241223141626-cff3c89139a3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.12.0 // indirect @@ -67,7 +68,6 @@ require ( github.com/google/gnostic-models v0.6.8 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect github.com/huandu/xstrings v1.3.3 // indirect @@ -124,7 +124,7 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiserver v0.32.4 // indirect + k8s.io/apiserver v0.32.5 // indirect k8s.io/gengo/v2 v2.0.0-20240911193312-2b36238f13e9 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect diff --git a/go.sum b/go.sum index 508f9ea50..8d3827c1d 100644 --- a/go.sum +++ b/go.sum @@ -293,20 +293,20 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.32.4 h1:kw8Y/G8E7EpNy7gjB8gJZl3KJkNz8HM2YHrZPtAZsF4= -k8s.io/api v0.32.4/go.mod h1:5MYFvLvweRhyKylM3Es/6uh/5hGp0dg82vP34KifX4g= -k8s.io/apiextensions-apiserver v0.32.4 h1:IA+CoR63UDOijR/vEpow6wQnX4V6iVpzazJBskHrpHE= -k8s.io/apiextensions-apiserver v0.32.4/go.mod h1:Y06XO/b92H8ymOdG1HlA1submf7gIhbEDc3RjriqZOs= -k8s.io/apimachinery v0.32.4 h1:8EEksaxA7nd7xWJkkwLDN4SvWS5ot9g6Z/VZb3ju25I= -k8s.io/apimachinery v0.32.4/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= -k8s.io/apiserver v0.32.4 h1:Yf7sd/y+GOQKH1Qf6wUeayZrYXe2SKZ17Bcq7VQM5HQ= -k8s.io/apiserver v0.32.4/go.mod h1:JFUMNtE2M5yqLZpIsgCb06SkVSW1YcxW1oyLSTfjXR8= -k8s.io/client-go v0.32.4 h1:zaGJS7xoYOYumoWIFXlcVrsiYioRPrXGO7dBfVC5R6M= -k8s.io/client-go v0.32.4/go.mod h1:k0jftcyYnEtwlFW92xC7MTtFv5BNcZBr+zn9jPlT9Ic= -k8s.io/code-generator v0.32.4 h1:d4dm/43RD6xhPBX22JgJw9JUpwTKzVR6tAxJD7pz83o= -k8s.io/code-generator v0.32.4/go.mod h1:R0bKdIg1smtvsKvj9q7SxTeKq5X9ko6PuICCGt4yqxg= -k8s.io/component-base v0.32.4 h1:HuF+2JVLbFS5GODLIfPCb1Td6b+G2HszJoArcWOSr5I= -k8s.io/component-base v0.32.4/go.mod h1:10KloJEYw1keU/Xmjfy9TKJqUq7J2mYdiD1VDXoco4o= +k8s.io/api v0.32.5 h1:uqjjsYo1kTJr5NIcoIaP9F+TgXgADH7nKQx91FDAhtk= +k8s.io/api v0.32.5/go.mod h1:bXXFU3fGCZ/eFMZvfHZC69PeGbXEL4zzjuPVzOxHF64= +k8s.io/apiextensions-apiserver v0.32.5 h1:o0aKvmzIIs8Uk54pidk32pxET+Pg2ULnh9WI1PuKTwE= +k8s.io/apiextensions-apiserver v0.32.5/go.mod h1:5fpedJa3HJJFBukAZ6ur91DEDye5gYuXISPbOiNLYpU= +k8s.io/apimachinery v0.32.5 h1:6We3aJ6crC0ap8EhsEXcgX3LpI6SEjubpiOMXLROwPM= +k8s.io/apimachinery v0.32.5/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= +k8s.io/apiserver v0.32.5 h1:phmm2EOUVFI+cLiq8Grtuh166fTt/qgvkGPkpgzp5uY= +k8s.io/apiserver v0.32.5/go.mod h1:5bfueS1tgARVWVXRJBMI5mHoCmev0jOvbxebai/kiqc= +k8s.io/client-go v0.32.5 h1:huFmQMzgWu0z4kbWsuZci+Gt4Fo72I4CcrvhToZ/Qp0= +k8s.io/client-go v0.32.5/go.mod h1:Qchw6f9WIVrur7DKojAHpRgGLcANT0RLIvF39Jz58xA= +k8s.io/code-generator v0.32.5 h1:dvoXgaWTDPLsg0txUzWj5xPV8UwHOsBhmm4JC9Gd1Qo= +k8s.io/code-generator v0.32.5/go.mod h1:7S6jUv4ZAnI2yDUJUQUEuc3gv6+qFhnkB5Fhs9Eb0d8= +k8s.io/component-base v0.32.5 h1:2HiX+m3s9Iz5CMqdCVDH2V942UqzQvjuhcXb4W+KCsg= +k8s.io/component-base v0.32.5/go.mod h1:jDsPNFFElv9m27TcYxlpEX7TZ3vdgx2g4PaqMUHpV/Y= k8s.io/gengo/v2 v2.0.0-20240911193312-2b36238f13e9 h1:si3PfKm8dDYxgfbeA6orqrtLkvvIeH8UqffFJDl0bz4= k8s.io/gengo/v2 v2.0.0-20240911193312-2b36238f13e9/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= From 03a4177ef1c65e8ae33a114cf36eeafca1228ef7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 May 2025 08:43:18 -0700 Subject: [PATCH 34/53] Bump github.com/prometheus/common from 0.63.0 to 0.64.0 (#853) Bumps [github.com/prometheus/common](https://github.com/prometheus/common) from 0.63.0 to 0.64.0. - [Release notes](https://github.com/prometheus/common/releases) - [Changelog](https://github.com/prometheus/common/blob/main/RELEASE.md) - [Commits](https://github.com/prometheus/common/compare/v0.63.0...v0.64.0) --- updated-dependencies: - dependency-name: github.com/prometheus/common dependency-version: 0.64.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 16 ++++++++-------- go.sum | 32 ++++++++++++++++---------------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/go.mod b/go.mod index 7fa3693aa..93ee8b992 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/onsi/gomega v1.37.0 github.com/prometheus/client_golang v1.22.0 github.com/prometheus/client_model v0.6.2 - github.com/prometheus/common v0.63.0 + github.com/prometheus/common v0.64.0 github.com/stretchr/testify v1.10.0 go.uber.org/multierr v1.11.0 go.uber.org/zap v1.27.0 @@ -105,15 +105,15 @@ require ( go.opentelemetry.io/otel/trace v1.34.0 // indirect go.opentelemetry.io/proto/otlp v1.3.1 // indirect go.uber.org/automaxprocs v1.6.0 // indirect - golang.org/x/crypto v0.37.0 // indirect + golang.org/x/crypto v0.38.0 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect golang.org/x/mod v0.24.0 // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/oauth2 v0.25.0 // indirect - golang.org/x/sync v0.13.0 // indirect - golang.org/x/sys v0.32.0 // indirect - golang.org/x/term v0.31.0 // indirect - golang.org/x/text v0.24.0 // indirect + golang.org/x/net v0.40.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sync v0.14.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/term v0.32.0 // indirect + golang.org/x/text v0.25.0 // indirect golang.org/x/time v0.7.0 // indirect golang.org/x/tools v0.31.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect diff --git a/go.sum b/go.sum index 8d3827c1d..007ce1425 100644 --- a/go.sum +++ b/go.sum @@ -162,8 +162,8 @@ github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/ github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= -github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= +github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4= +github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= @@ -220,8 +220,8 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -232,29 +232,29 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= -golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= -golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= -golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= -golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= From 9f15441df9742570d5d26b381cc697e517b6b0d3 Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Tue, 20 May 2025 09:39:16 -0700 Subject: [PATCH 35/53] Updating readme to show llm-d collab (#855) --- README.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9a729c1e0..282e13044 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,11 @@ Gateway API Inference Extension optimizes self-hosting Generative Models on Kubernetes. This is achieved by leveraging Envoy's [External Processing] (ext-proc) to extend any gateway that supports both ext-proc and [Gateway API] into an **[inference gateway]**. - [Inference Gateway]:#concepts-and-definitions +## New! +Inference Gateway has partnered with vLLM to accelerate LLM serving optimizations with [llm-d](https://llm-d.ai/blog/llm-d-announce)! + ## Concepts and Definitions The following specific terms to this project: @@ -53,6 +55,8 @@ For deeper insights and more advanced concepts, refer to our [proposals](/docs/p [LoRA Adapters]:https://docs.vllm.ai/en/stable/features/lora.html [External Processing]:https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_proc_filter + + ## Technical Overview This extension upgrades an [ext-proc](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_proc_filter) capable proxy or gateway - such as Envoy Gateway, kGateway, or the GKE Gateway - to become an **[inference gateway]** - supporting inference platform teams self-hosting Generative Models (with a current focus on large language models) on Kubernetes. This integration makes it easy to expose and control access to your local [OpenAI-compatible chat completion endpoints](https://platform.openai.com/docs/api-reference/chat) to other workloads on or off cluster, or to integrate your self-hosted models alongside model-as-a-service providers in a higher level **AI Gateway** like LiteLLM, Solo AI Gateway, or Apigee. @@ -66,7 +70,14 @@ The Inference Gateway: ![Architecture Diagram](./docs/inference-gateway-architecture.svg) -It currently requires a version of vLLM that supports the necessary metrics to predict traffic load which is defined in the [model server protocol](https://github.com/kubernetes-sigs/gateway-api-inference-extension/tree/main/docs/proposals/003-model-server-protocol). Support for Google's Jetstream, nVidia Triton, text-generation-inference, and SGLang is coming soon. +### Model Server Integration + +IGW’s pluggable architecture was leveraged to enable the [llm-d Inference Scheduler](https://github.com/llm-d/llm-d-inference-scheduler). + +Llm-d customizes vLLM & IGW to create a disaggregated serving solution. We've worked closely with this team to enable this integration. IGW will continue to work closely with llm-d to generalize the disaggregated serving plugin(s), & set a standard for disaggregated serving to be used across any [protocol-adherent](https://github.com/kubernetes-sigs/gateway-api-inference-extension/tree/main/docs/proposals/003-model-server-protocol) model server. + +IGW + llm-d natively supports vLLM, support for: Google's Jetstream, nVidia's Triton, text-generation-inference, and SGLang is coming soon. More details can be found in [model server integration](https://gateway-api-inference-extension.sigs.k8s.io/implementations/model-servers/). + ## Status From acb21c7581e5f165723da5b374c9ea96c8e6aa7a Mon Sep 17 00:00:00 2001 From: "Victor Adossi (\"vados\")" Date: Wed, 21 May 2025 04:17:15 +0900 Subject: [PATCH 36/53] fix: typo ('endpoing' -> 'endpoint') (#857) --- site-src/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site-src/index.md b/site-src/index.md index e7050ce43..b30436611 100644 --- a/site-src/index.md +++ b/site-src/index.md @@ -46,7 +46,7 @@ this project. ### Endpoint Picker -As part of this project, we've built the Endpoing Picker. A pluggable & extensible ext-proc deployment that implements [this architecture](https://github.com/kubernetes-sigs/gateway-api-inference-extension/tree/main/docs/proposals/0683-epp-architecture-proposal). +As part of this project, we've built the Endpoint Picker. A pluggable & extensible ext-proc deployment that implements [this architecture](https://github.com/kubernetes-sigs/gateway-api-inference-extension/tree/main/docs/proposals/0683-epp-architecture-proposal). ### Model Server Frameworks From 89580289d8bf6ca663a9b0b3b0b4a8c11bcadf7d Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Tue, 20 May 2025 13:42:35 -0700 Subject: [PATCH 37/53] Updating readme wording (#858) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 282e13044..87ee41421 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ IGW’s pluggable architecture was leveraged to enable the [llm-d Inference Sche Llm-d customizes vLLM & IGW to create a disaggregated serving solution. We've worked closely with this team to enable this integration. IGW will continue to work closely with llm-d to generalize the disaggregated serving plugin(s), & set a standard for disaggregated serving to be used across any [protocol-adherent](https://github.com/kubernetes-sigs/gateway-api-inference-extension/tree/main/docs/proposals/003-model-server-protocol) model server. -IGW + llm-d natively supports vLLM, support for: Google's Jetstream, nVidia's Triton, text-generation-inference, and SGLang is coming soon. More details can be found in [model server integration](https://gateway-api-inference-extension.sigs.k8s.io/implementations/model-servers/). +IGW has enhanced support for vLLM via llm-d, and broad support for any model servers implementing the protocol. More details can be found in [model server integration](https://gateway-api-inference-extension.sigs.k8s.io/implementations/model-servers/). ## Status From 87b3a085924780af070f830a8033e5af8c10ac44 Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Tue, 20 May 2025 14:02:34 -0700 Subject: [PATCH 38/53] adding logging & support for better response when requests are not valid json (#847) --- pkg/epp/handlers/server.go | 28 ++++- pkg/epp/server/runserver.go | 1 - test/integration/epp/hermetic_test.go | 174 +++++++++++++++++++++++++- 3 files changed, 196 insertions(+), 7 deletions(-) diff --git a/pkg/epp/handlers/server.go b/pkg/epp/handlers/server.go index be85ba6bf..7731dede2 100644 --- a/pkg/epp/handlers/server.go +++ b/pkg/epp/handlers/server.go @@ -196,8 +196,8 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) err = json.Unmarshal(body, &reqCtx.Request.Body) if err != nil { logger.V(logutil.DEFAULT).Error(err, "Error unmarshaling request body") - // TODO: short circuit and send the body back as is (this could be an envoy error), currently we drop - // whatever the body request would have been and send our immediate response instead. + err = errutil.Error{Code: errutil.BadRequest, Msg: "Error unmarshaling request body: " + string(body)} + break } // Body stream complete. Allocate empty slice for response to use. @@ -287,7 +287,24 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) var responseErr error responseErr = json.Unmarshal(body, &responseBody) if responseErr != nil { - logger.V(logutil.DEFAULT).Error(responseErr, "Error unmarshaling request body") + logger.V(logutil.DEFAULT).Error(responseErr, "Error unmarshaling request body", "body", string(body)) + reqCtx.respBodyResp = &extProcPb.ProcessingResponse{ + Response: &extProcPb.ProcessingResponse_ResponseBody{ + ResponseBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: body, + EndOfStream: true, + }, + }, + }, + }, + }, + }, + } + break } reqCtx, responseErr = s.HandleResponseBody(ctx, reqCtx, responseBody) @@ -436,5 +453,10 @@ func BuildErrResponse(err error) (*extProcPb.ProcessingResponse, error) { default: return nil, status.Errorf(status.Code(err), "failed to handle request: %v", err) } + + if err.Error() != "" { + resp.Response.(*extProcPb.ProcessingResponse_ImmediateResponse).ImmediateResponse.Body = []byte(err.Error()) + } + return resp, nil } diff --git a/pkg/epp/server/runserver.go b/pkg/epp/server/runserver.go index 4b8620826..69f4805dd 100644 --- a/pkg/epp/server/runserver.go +++ b/pkg/epp/server/runserver.go @@ -47,7 +47,6 @@ type ExtProcServerRunner struct { Datastore datastore.Datastore SecureServing bool CertPath string - UseStreaming bool RefreshPrometheusMetricsInterval time.Duration Scheduler requestcontrol.Scheduler diff --git a/test/integration/epp/hermetic_test.go b/test/integration/epp/hermetic_test.go index 3035a9b57..a9d54fa4e 100644 --- a/test/integration/epp/hermetic_test.go +++ b/test/integration/epp/hermetic_test.go @@ -183,6 +183,74 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { }, }, }, + { + name: "invalid json; return body", + requests: []*extProcPb.ProcessingRequest{ + { + Request: &extProcPb.ProcessingRequest_RequestHeaders{ + RequestHeaders: &extProcPb.HttpHeaders{ + Headers: &configPb.HeaderMap{ + Headers: []*configPb.HeaderValue{ + { + Key: "hi", + Value: "mom", + }, + }, + }, + }, + }, + }, + { + Request: &extProcPb.ProcessingRequest_RequestBody{ + RequestBody: &extProcPb.HttpBody{Body: []byte("no healthy upstream"), EndOfStream: true}, + }, + }, + }, + // pod-1 will be picked because it has relatively low queue size, with the requested + // model being active, and has low KV cache. + pods: map[*backend.Pod]*backendmetrics.MetricsState{ + fakePod(0): { + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, + }, + WaitingModels: map[string]int{}, + }, + fakePod(1): { + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.1, + ActiveModels: map[string]int{ + "foo": 1, + "sql-lora-1fdg2": 1, + }, + WaitingModels: map[string]int{}, + }, + fakePod(2): { + WaitingQueueSize: 10, + KVCacheUsagePercent: 0.2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, + }, + WaitingModels: map[string]int{}, + }, + }, + wantErr: false, + wantResponses: []*extProcPb.ProcessingResponse{ + { + Response: &extProcPb.ProcessingResponse_ImmediateResponse{ + ImmediateResponse: &extProcPb.ImmediateResponse{ + Status: &envoyTypePb.HttpStatus{ + Code: envoyTypePb.StatusCode_BadRequest, + }, + Body: []byte("inference gateway: BadRequest - Error unmarshaling request body: no healthy upstream"), + }, + }, + }, + }, + }, { name: "select active lora, low queue", requests: integrationutils.GenerateStreamedRequestSet(logger, "test2", "sql-lora"), @@ -407,6 +475,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { Status: &envoyTypePb.HttpStatus{ Code: envoyTypePb.StatusCode_TooManyRequests, }, + Body: []byte("inference gateway: InferencePoolResourceExhausted - failed to find target pod: inference gateway: Internal - no pods available for the given request"), }, }, }, @@ -842,6 +911,106 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { }, }, }, + { + name: "Response is invalid json; return body", + requests: []*extProcPb.ProcessingRequest{ + { + Request: &extProcPb.ProcessingRequest_ResponseHeaders{ + ResponseHeaders: &extProcPb.HttpHeaders{ + Headers: &configPb.HeaderMap{ + Headers: []*configPb.HeaderValue{ + { + Key: "content-type", + Value: "application/json", + }, + }, + }, + }, + }, + }, + { + Request: &extProcPb.ProcessingRequest_ResponseBody{ + ResponseBody: &extProcPb.HttpBody{Body: []byte("no healthy upstream"), EndOfStream: true}, + }, + }, + }, + + // + // pod 0 will be picked as all other models are above threshold + pods: map[*backend.Pod]*backendmetrics.MetricsState{ + fakePod(0): { + WaitingQueueSize: 4, + KVCacheUsagePercent: 0.2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, + "sql-lora-1fdg3": 1, + }, + WaitingModels: map[string]int{}, + }, + fakePod(1): { + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.85, + ActiveModels: map[string]int{ + "foo": 1, + "sql-lora-1fdg3": 1, + }, + WaitingModels: map[string]int{}, + }, + fakePod(2): { + WaitingQueueSize: 10, + KVCacheUsagePercent: 0.9, + ActiveModels: map[string]int{ + "foo": 1, + "sql-lora-1fdg3": 1, + }, + WaitingModels: map[string]int{}, + }, + }, + wantErr: false, + wantResponses: []*extProcPb.ProcessingResponse{ + { + Response: &extProcPb.ProcessingResponse_ResponseHeaders{ + ResponseHeaders: &extProcPb.HeadersResponse{ + Response: &extProcPb.CommonResponse{ + HeaderMutation: &extProcPb.HeaderMutation{ + SetHeaders: []*configPb.HeaderValueOption{ + { + Header: &configPb.HeaderValue{ + Key: "x-went-into-resp-headers", + RawValue: []byte("true"), + }, + }, + { + Header: &configPb.HeaderValue{ + Key: "content-type", + RawValue: []uint8("application/json"), + }, + }, + }, + }, + }, + }, + }, + }, + { + Response: &extProcPb.ProcessingResponse_ResponseBody{ + ResponseBody: &extProcPb.BodyResponse{ + Response: &extProcPb.CommonResponse{ + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: []byte("no healthy upstream"), + EndOfStream: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, { name: "responsebody sent over a single request, but empty body with EndOfStream in the second request(this is how envoy operates); content-type is json, buffer", requests: []*extProcPb.ProcessingRequest{ @@ -1261,7 +1430,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - client, cleanup := setUpHermeticServer(t, test.pods, true) + client, cleanup := setUpHermeticServer(t, test.pods) t.Cleanup(cleanup) responses, err := integrationutils.StreamedRequest(t, client, test.requests, len(test.wantResponses)) @@ -1290,14 +1459,13 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { } } -func setUpHermeticServer(t *testing.T, podAndMetrics map[*backend.Pod]*backendmetrics.MetricsState, streamed bool) (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { +func setUpHermeticServer(t *testing.T, podAndMetrics map[*backend.Pod]*backendmetrics.MetricsState) (client extProcPb.ExternalProcessor_ProcessClient, cleanup func()) { // Reconfigure the TestPodMetricsClient. res := map[types.NamespacedName]*backendmetrics.MetricsState{} for pod, metrics := range podAndMetrics { res[pod.NamespacedName] = metrics } serverRunner.TestPodMetricsClient.SetRes(res) - serverRunner.UseStreaming = streamed serverCtx, stopServer := context.WithCancel(context.Background()) From 70285f18df1bfd27f2b733e12a754979b1e815f7 Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Tue, 20 May 2025 19:42:34 -0700 Subject: [PATCH 39/53] Adding util func for splitting large bodies into chunks (#859) --- pkg/epp/handlers/server.go | 34 ++++++++++++++++++ pkg/epp/handlers/server_test.go | 64 +++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 pkg/epp/handlers/server_test.go diff --git a/pkg/epp/handlers/server.go b/pkg/epp/handlers/server.go index 7731dede2..4b849c8aa 100644 --- a/pkg/epp/handlers/server.go +++ b/pkg/epp/handlers/server.go @@ -37,6 +37,11 @@ import ( requtil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/request" ) +const ( + // Certain envoy implementations set a max limit of 64Kb per streamed chunk, intentionally setting this lower for a safe margin. + bodyByteLimit = 62000 +) + func NewStreamingServer(destinationEndpointHintMetadataNamespace, destinationEndpointHintKey string, datastore Datastore, director Director) *StreamingServer { return &StreamingServer{ destinationEndpointHintMetadataNamespace: destinationEndpointHintMetadataNamespace, @@ -460,3 +465,32 @@ func BuildErrResponse(err error) (*extProcPb.ProcessingResponse, error) { return resp, nil } + +func buildCommonResponses(bodyBytes []byte, byteLimit int) []*extProcPb.CommonResponse { + responses := []*extProcPb.CommonResponse{} + startingIndex := 0 + bodyLen := len(bodyBytes) + + for startingIndex < bodyLen { + eos := false + len := min(bodyLen-startingIndex, byteLimit) + chunk := bodyBytes[startingIndex : len+startingIndex] + if len+startingIndex == bodyLen { + eos = true + } + + commonResp := &extProcPb.CommonResponse{ + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: chunk, + EndOfStream: eos, + }, + }, + }, + } + responses = append(responses, commonResp) + startingIndex += len + } + return responses +} diff --git a/pkg/epp/handlers/server_test.go b/pkg/epp/handlers/server_test.go new file mode 100644 index 000000000..cc99a517b --- /dev/null +++ b/pkg/epp/handlers/server_test.go @@ -0,0 +1,64 @@ +package handlers + +import ( + "crypto/rand" + "testing" +) + +func TestBuildCommonResponses(t *testing.T) { + tests := []struct { + name string + count int + expectedMessageCount int + }{ + { + name: "below limit", + count: bodyByteLimit - 1000, + expectedMessageCount: 1, + }, + { + name: "at limit", + count: bodyByteLimit, + expectedMessageCount: 1, + }, + { + name: "off by one error?", + count: bodyByteLimit + 1, + expectedMessageCount: 2, + }, + { + name: "above limit", + count: bodyByteLimit + 1000, + expectedMessageCount: 2, + }, + { + name: "above limit", + count: (bodyByteLimit * 2) + 1000, + expectedMessageCount: 3, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + arr := generateBytes(test.count) + responses := buildCommonResponses(arr, bodyByteLimit) + for i, response := range responses { + eos := response.BodyMutation.GetStreamedResponse().GetEndOfStream() + if eos == true && i+1 != len(responses) { + t.Fatalf("EoS should not be set") + } + if eos == false && i+1 == len(responses) { + t.Fatalf("EoS should be set") + } + } + if len(responses) != test.expectedMessageCount { + t.Fatalf("Expected: %v, Got %v", test.expectedMessageCount, len(responses)) + } + }) + } +} + +func generateBytes(count int) []byte { + arr := make([]byte, count) + _, _ = rand.Read(arr) + return arr +} From 8770afeff590fc5e754b1b0955c46ce9b22237c0 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Wed, 21 May 2025 20:54:35 +0300 Subject: [PATCH 40/53] Scheduler config refactor for simplifying plugins registration (#835) * small refactor of scheduler config handles how to register a plugin that implements multiple scheduler plugins interfaces with a single registration command Signed-off-by: Nir Rozenbaum * code review Signed-off-by: Nir Rozenbaum * minor change Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum --- cmd/epp/main.go | 27 ++-- pkg/epp/scheduling/config.go | 62 --------- pkg/epp/scheduling/plugins/scorer/kvcache.go | 11 +- pkg/epp/scheduling/plugins/scorer/queue.go | 12 +- .../plugins/scorer/weighted_scorer.go | 40 ++++++ pkg/epp/scheduling/scheduler.go | 25 ++-- pkg/epp/scheduling/scheduler_config.go | 123 ++++++++++++++++++ pkg/epp/scheduling/scheduler_test.go | 27 ++-- .../scheduling/types/scheduling_context.go | 1 - 9 files changed, 219 insertions(+), 109 deletions(-) delete mode 100644 pkg/epp/scheduling/config.go create mode 100644 pkg/epp/scheduling/plugins/scorer/weighted_scorer.go create mode 100644 pkg/epp/scheduling/scheduler_config.go diff --git a/cmd/epp/main.go b/cmd/epp/main.go index 9c023f26d..44ad9f0d1 100644 --- a/cmd/epp/main.go +++ b/cmd/epp/main.go @@ -44,7 +44,6 @@ import ( "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics/collectors" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/filter" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/multi/prefix" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/picker" @@ -196,23 +195,21 @@ func run() error { if schedulerV2 == "true" { queueScorerWeight := envutil.GetEnvInt("QUEUE_SCORE_WEIGHT", scorer.DefaultQueueScorerWeight, setupLog) kvCacheScorerWeight := envutil.GetEnvInt("KV_CACHE_SCORE_WEIGHT", scorer.DefaultKVCacheScorerWeight, setupLog) - scorers := map[plugins.Scorer]int{ - &scorer.QueueScorer{}: queueScorerWeight, - &scorer.KVCacheScorer{}: kvCacheScorerWeight, - } - schedConfigOpts := []scheduling.ConfigOption{} + + schedulerConfig := scheduling.NewSchedulerConfig(). + WithFilters(filter.NewSheddableCapacityFilter()). + WithScorers(scorer.NewWeightedScorer(&scorer.QueueScorer{}, queueScorerWeight), + scorer.NewWeightedScorer(&scorer.KVCacheScorer{}, kvCacheScorerWeight)). + WithPicker(picker.NewMaxScorePicker()) + if prefixCacheScheduling == "true" { prefixScorerWeight := envutil.GetEnvInt("PREFIX_CACHE_SCORE_WEIGHT", prefix.DefaultScorerWeight, setupLog) - schedConfigOpts = append(schedConfigOpts, scheduling.AddPrefixPlugin(loadPrefixCacheConfig(), prefixScorerWeight)) + if err := schedulerConfig.AddPlugins(scorer.NewWeightedScorer(prefix.New(loadPrefixCacheConfig()), prefixScorerWeight)); err != nil { + setupLog.Error(err, "Failed to register scheduler plugins") + return err + } } - schedulerConfig := scheduling.NewSchedulerConfig( - []plugins.PreSchedule{}, - []plugins.Filter{filter.NewSheddableCapacityFilter()}, - scorers, - picker.NewMaxScorePicker(), - []plugins.PostSchedule{}, - []plugins.PostResponse{}, - schedConfigOpts...) + scheduler = scheduling.NewSchedulerWithConfig(datastore, schedulerConfig) } serverRunner := &runserver.ExtProcServerRunner{ diff --git a/pkg/epp/scheduling/config.go b/pkg/epp/scheduling/config.go deleted file mode 100644 index 77fb26fb6..000000000 --- a/pkg/epp/scheduling/config.go +++ /dev/null @@ -1,62 +0,0 @@ -/* -Copyright 2025 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 scheduling - -import ( - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/multi/prefix" -) - -// NewSchedulerConfig creates a new SchedulerConfig object with the given plugins. -func NewSchedulerConfig(preSchedulePlugins []plugins.PreSchedule, filters []plugins.Filter, scorers map[plugins.Scorer]int, - picker plugins.Picker, postSchedulePlugins []plugins.PostSchedule, postResponsePlugins []plugins.PostResponse, opts ...ConfigOption) *SchedulerConfig { - config := &SchedulerConfig{ - preSchedulePlugins: preSchedulePlugins, - filters: filters, - scorers: scorers, - picker: picker, - postSchedulePlugins: postSchedulePlugins, - postResponsePlugins: postResponsePlugins, - } - for _, opt := range opts { - opt(config) - } - return config -} - -// SchedulerConfig provides a configuration for the scheduler which influence routing decisions. -type SchedulerConfig struct { - preSchedulePlugins []plugins.PreSchedule - filters []plugins.Filter - scorers map[plugins.Scorer]int // map from scorer to weight - picker plugins.Picker - postSchedulePlugins []plugins.PostSchedule - postResponsePlugins []plugins.PostResponse -} - -type ConfigOption func(*SchedulerConfig) - -// TODO(https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/813): Replace this -// with a more generic way to add plugins. -func AddPrefixPlugin(prefixConfig prefix.Config, weight int) ConfigOption { - return func(cfg *SchedulerConfig) { - prefixPlugin := prefix.New(prefixConfig) - cfg.preSchedulePlugins = append(cfg.preSchedulePlugins, prefixPlugin) - cfg.postSchedulePlugins = append(cfg.postSchedulePlugins, prefixPlugin) - cfg.scorers[prefixPlugin] = weight - } -} diff --git a/pkg/epp/scheduling/plugins/scorer/kvcache.go b/pkg/epp/scheduling/plugins/scorer/kvcache.go index dbb6079dc..de6f87b0e 100644 --- a/pkg/epp/scheduling/plugins/scorer/kvcache.go +++ b/pkg/epp/scheduling/plugins/scorer/kvcache.go @@ -17,6 +17,7 @@ limitations under the License. package scorer import ( + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" ) @@ -24,13 +25,19 @@ const ( DefaultKVCacheScorerWeight = 1 ) +// compile-time type validation +var _ plugins.Scorer = &KVCacheScorer{} + +// KVCacheScorer scores list of candidate pods based on KV cache utilization. type KVCacheScorer struct{} -func (ss *KVCacheScorer) Name() string { +// Name returns the name of the scorer. +func (s *KVCacheScorer) Name() string { return "kv-cache" } -func (ss *KVCacheScorer) Score(ctx *types.SchedulingContext, pods []types.Pod) map[types.Pod]float64 { +// Score returns the scoring result for the given list of pods based on context. +func (s *KVCacheScorer) Score(ctx *types.SchedulingContext, pods []types.Pod) map[types.Pod]float64 { scores := make(map[types.Pod]float64, len(pods)) for _, pod := range pods { scores[pod] = 1 - pod.GetMetrics().KVCacheUsagePercent diff --git a/pkg/epp/scheduling/plugins/scorer/queue.go b/pkg/epp/scheduling/plugins/scorer/queue.go index bbe6b6961..ac87f0b9e 100644 --- a/pkg/epp/scheduling/plugins/scorer/queue.go +++ b/pkg/epp/scheduling/plugins/scorer/queue.go @@ -19,6 +19,7 @@ package scorer import ( "math" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" ) @@ -26,13 +27,20 @@ const ( DefaultQueueScorerWeight = 1 ) +// compile-time type validation +var _ plugins.Scorer = &QueueScorer{} + +// QueueScorer scores list of candidate pods based on the pod's waiting queue size. +// the less waiting queue size the pod has, the higher score it will get (since it's more available to serve new request). type QueueScorer struct{} -func (q *QueueScorer) Name() string { +// Name returns the name of the scorer. +func (s *QueueScorer) Name() string { return "queue" } -func (q *QueueScorer) Score(ctx *types.SchedulingContext, pods []types.Pod) map[types.Pod]float64 { +// Score returns the scoring result for the given list of pods based on context. +func (s *QueueScorer) Score(ctx *types.SchedulingContext, pods []types.Pod) map[types.Pod]float64 { minQueueSize := math.MaxInt maxQueueSize := math.MinInt diff --git a/pkg/epp/scheduling/plugins/scorer/weighted_scorer.go b/pkg/epp/scheduling/plugins/scorer/weighted_scorer.go new file mode 100644 index 000000000..1e83b6c83 --- /dev/null +++ b/pkg/epp/scheduling/plugins/scorer/weighted_scorer.go @@ -0,0 +1,40 @@ +/* +Copyright 2025 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 scorer + +import ( + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" +) + +// NewWeightedScorer initializes a new WeightedScorer and returns its pointer. +func NewWeightedScorer(scorer plugins.Scorer, weight int) *WeightedScorer { + return &WeightedScorer{ + Scorer: scorer, + weight: weight, + } +} + +// WeightedScorer is a struct that encapsulates a scorer with its weight. +type WeightedScorer struct { + plugins.Scorer + weight int +} + +// Weight returns the weight of the scorer. +func (s *WeightedScorer) Weight() int { + return s.weight +} diff --git a/pkg/epp/scheduling/scheduler.go b/pkg/epp/scheduling/scheduler.go index b484cdebc..87591b2bc 100644 --- a/pkg/epp/scheduling/scheduler.go +++ b/pkg/epp/scheduling/scheduler.go @@ -28,6 +28,7 @@ import ( "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/filter" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/picker" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/scorer" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/error" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" @@ -64,13 +65,9 @@ func NewScheduler(datastore Datastore) *Scheduler { }, } - defaultConfig := &SchedulerConfig{ - preSchedulePlugins: []plugins.PreSchedule{}, - filters: []plugins.Filter{filter.NewSheddableCapacityFilter(), lowLatencyFilter}, - scorers: map[plugins.Scorer]int{}, - picker: &picker.RandomPicker{}, - postSchedulePlugins: []plugins.PostSchedule{}, - } + defaultConfig := NewSchedulerConfig(). + WithFilters(filter.NewSheddableCapacityFilter(), lowLatencyFilter). + WithPicker(&picker.RandomPicker{}) return NewSchedulerWithConfig(datastore, defaultConfig) } @@ -92,7 +89,7 @@ type Scheduler struct { datastore Datastore preSchedulePlugins []plugins.PreSchedule filters []plugins.Filter - scorers map[plugins.Scorer]int // map from scorer to its weight + scorers []*scorer.WeightedScorer picker plugins.Picker postSchedulePlugins []plugins.PostSchedule postResponsePlugins []plugins.PostResponse @@ -172,15 +169,15 @@ func (s *Scheduler) runScorerPlugins(ctx *types.SchedulingContext, pods []types. weightedScorePerPod[pod] = float64(0) // initialize weighted score per pod with 0 value } // Iterate through each scorer in the chain and accumulate the weighted scores. - for scorer, weight := range s.scorers { - loggerDebug.Info("Running scorer", "scorer", scorer.Name()) + for _, weightedScorer := range s.scorers { + loggerDebug.Info("Running scorer", "scorer", weightedScorer.Name()) before := time.Now() - scores := scorer.Score(ctx, pods) - metrics.RecordSchedulerPluginProcessingLatency(plugins.ScorerPluginType, scorer.Name(), time.Since(before)) + scores := weightedScorer.Score(ctx, pods) + metrics.RecordSchedulerPluginProcessingLatency(plugins.ScorerPluginType, weightedScorer.Name(), time.Since(before)) for pod, score := range scores { // weight is relative to the sum of weights - weightedScorePerPod[pod] += score * float64(weight) // TODO normalize score before multiply with weight + weightedScorePerPod[pod] += score * float64(weightedScorer.Weight()) } - loggerDebug.Info("After running scorer", "scorer", scorer.Name()) + loggerDebug.Info("After running scorer", "scorer", weightedScorer.Name()) } loggerDebug.Info("After running scorer plugins") diff --git a/pkg/epp/scheduling/scheduler_config.go b/pkg/epp/scheduling/scheduler_config.go new file mode 100644 index 000000000..06a23f469 --- /dev/null +++ b/pkg/epp/scheduling/scheduler_config.go @@ -0,0 +1,123 @@ +/* +Copyright 2025 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 scheduling + +import ( + "fmt" + + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/scorer" +) + +// NewSchedulerConfig creates a new SchedulerConfig object and returns its pointer. +func NewSchedulerConfig() *SchedulerConfig { + return &SchedulerConfig{ + preSchedulePlugins: []plugins.PreSchedule{}, + filters: []plugins.Filter{}, + scorers: []*scorer.WeightedScorer{}, + postSchedulePlugins: []plugins.PostSchedule{}, + postResponsePlugins: []plugins.PostResponse{}, + // picker remains nil since config doesn't support multiple pickers + } +} + +// SchedulerConfig provides a configuration for the scheduler which influence routing decisions. +type SchedulerConfig struct { + preSchedulePlugins []plugins.PreSchedule + filters []plugins.Filter + scorers []*scorer.WeightedScorer + picker plugins.Picker + postSchedulePlugins []plugins.PostSchedule + postResponsePlugins []plugins.PostResponse +} + +// WithPreSchedulePlugins sets the given plugins as the PreSchedule plugins. +// If the SchedulerConfig has PreSchedule plugins, this call replaces the existing plugins with the given ones. +func (c *SchedulerConfig) WithPreSchedulePlugins(plugins ...plugins.PreSchedule) *SchedulerConfig { + c.preSchedulePlugins = plugins + return c +} + +// WithFilters sets the given filter plugins as the Filter plugins. +// if the SchedulerConfig has Filter plugins, this call replaces the existing plugins with the given ones. +func (c *SchedulerConfig) WithFilters(filters ...plugins.Filter) *SchedulerConfig { + c.filters = filters + return c +} + +// WithScorers sets the given scorer plugins as the Scorer plugins. +// if the SchedulerConfig has Scorer plugins, this call replaces the existing plugins with the given ones. +func (c *SchedulerConfig) WithScorers(scorers ...*scorer.WeightedScorer) *SchedulerConfig { + c.scorers = scorers + return c +} + +// WithPicker sets the given picker plugins as the Picker plugin. +// if the SchedulerConfig has Picker plugin, this call replaces the existing plugin with the given one. +func (c *SchedulerConfig) WithPicker(picker plugins.Picker) *SchedulerConfig { + c.picker = picker + return c +} + +// WithPostSchedulePlugins sets the given plugins as the PostSchedule plugins. +// If the SchedulerConfig has PostSchedule plugins, this call replaces the existing plugins with the given ones. +func (c *SchedulerConfig) WithPostSchedulePlugins(plugins ...plugins.PostSchedule) *SchedulerConfig { + c.postSchedulePlugins = plugins + return c +} + +// WithPostResponsePlugins sets the given plugins as the PostResponse plugins. +// If the SchedulerConfig has PostResponse plugins, this call replaces the existing plugins with the given ones. +func (c *SchedulerConfig) WithPostResponsePlugins(plugins ...plugins.PostResponse) *SchedulerConfig { + c.postResponsePlugins = plugins + return c +} + +// AddPlugins adds the given plugins to all scheduler plugins according to the interfaces each plugin implements. +// A plugin may implement more than one scheduler plugin interface. +// Special Case: In order to add a scorer, one must use the scorer.NewWeightedScorer function in order to provide a weight. +// if a scorer implements more than one interface, supplying a WeightedScorer is sufficient. The function will take the internal +// scorer object and register it to all interfaces it implements. +func (c *SchedulerConfig) AddPlugins(pluginObjects ...plugins.Plugin) error { + for _, plugin := range pluginObjects { + if weightedScorer, ok := plugin.(*scorer.WeightedScorer); ok { + c.scorers = append(c.scorers, weightedScorer) + plugin = weightedScorer.Scorer // if we got WeightedScorer, unwrap the plugin + } else if scorer, ok := plugin.(plugins.Scorer); ok { // if we got a Scorer instead of WeightedScorer that's an error. + return fmt.Errorf("failed to register scorer '%s' without a weight. follow function documentation to register a scorer", scorer.Name()) + } + if preSchedulePlugin, ok := plugin.(plugins.PreSchedule); ok { + c.preSchedulePlugins = append(c.preSchedulePlugins, preSchedulePlugin) + } + if filter, ok := plugin.(plugins.Filter); ok { + c.filters = append(c.filters, filter) + } + if picker, ok := plugin.(plugins.Picker); ok { + if c.picker != nil { + return fmt.Errorf("failed to set '%s' as picker, already have a registered picker plugin '%s'", picker.Name(), c.picker.Name()) + } + c.picker = picker + } + if postSchedulePlugin, ok := plugin.(plugins.PostSchedule); ok { + c.postSchedulePlugins = append(c.postSchedulePlugins, postSchedulePlugin) + } + if postResponsePlugin, ok := plugin.(plugins.PostResponse); ok { + c.postResponsePlugins = append(c.postResponsePlugins, postResponsePlugin) + } + } + return nil +} diff --git a/pkg/epp/scheduling/scheduler_test.go b/pkg/epp/scheduling/scheduler_test.go index 933122e61..1c6e6495c 100644 --- a/pkg/epp/scheduling/scheduler_test.go +++ b/pkg/epp/scheduling/scheduler_test.go @@ -26,6 +26,7 @@ import ( "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" // Import config for thresholds "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/scorer" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" ) @@ -276,9 +277,9 @@ func TestSchedulePlugins(t *testing.T) { config: SchedulerConfig{ preSchedulePlugins: []plugins.PreSchedule{tp1, tp2}, filters: []plugins.Filter{tp1, tp2}, - scorers: map[plugins.Scorer]int{ - tp1: 1, - tp2: 1, + scorers: []*scorer.WeightedScorer{ + scorer.NewWeightedScorer(tp1, 1), + scorer.NewWeightedScorer(tp2, 1), }, picker: pickerPlugin, postSchedulePlugins: []plugins.PostSchedule{tp1, tp2}, @@ -298,9 +299,9 @@ func TestSchedulePlugins(t *testing.T) { config: SchedulerConfig{ preSchedulePlugins: []plugins.PreSchedule{tp1, tp2}, filters: []plugins.Filter{tp1, tp2}, - scorers: map[plugins.Scorer]int{ - tp1: 60, - tp2: 40, + scorers: []*scorer.WeightedScorer{ + scorer.NewWeightedScorer(tp1, 60), + scorer.NewWeightedScorer(tp2, 40), }, picker: pickerPlugin, postSchedulePlugins: []plugins.PostSchedule{tp1, tp2}, @@ -320,9 +321,9 @@ func TestSchedulePlugins(t *testing.T) { config: SchedulerConfig{ preSchedulePlugins: []plugins.PreSchedule{tp1, tp2}, filters: []plugins.Filter{tp1, tp_filterAll}, - scorers: map[plugins.Scorer]int{ - tp1: 1, - tp2: 1, + scorers: []*scorer.WeightedScorer{ + scorer.NewWeightedScorer(tp1, 1), + scorer.NewWeightedScorer(tp2, 1), }, picker: pickerPlugin, postSchedulePlugins: []plugins.PostSchedule{tp1, tp2}, @@ -346,8 +347,8 @@ func TestSchedulePlugins(t *testing.T) { for _, plugin := range test.config.filters { plugin.(*TestPlugin).reset() } - for plugin := range test.config.scorers { - plugin.(*TestPlugin).reset() + for _, plugin := range test.config.scorers { + plugin.Scorer.(*TestPlugin).reset() } test.config.picker.(*TestPlugin).reset() for _, plugin := range test.config.postSchedulePlugins { @@ -396,8 +397,8 @@ func TestSchedulePlugins(t *testing.T) { } } - for plugin := range test.config.scorers { - tp, _ := plugin.(*TestPlugin) + for _, plugin := range test.config.scorers { + tp, _ := plugin.Scorer.(*TestPlugin) if tp.ScoreCallCount != 1 { t.Errorf("Plugin %s Score() called %d times, expected 1", plugin.Name(), tp.ScoreCallCount) } diff --git a/pkg/epp/scheduling/types/scheduling_context.go b/pkg/epp/scheduling/types/scheduling_context.go index 37621806d..d89ad3d9e 100644 --- a/pkg/epp/scheduling/types/scheduling_context.go +++ b/pkg/epp/scheduling/types/scheduling_context.go @@ -24,7 +24,6 @@ import ( ) func NewSchedulingContext(ctx context.Context, req *LLMRequest, resp *LLMResponse, pods []Pod) *SchedulingContext { - logger := log.FromContext(ctx).WithValues("request", req) return &SchedulingContext{ Context: ctx, From a5bf0acd13cc2a116f737a0396bf247ed7da5923 Mon Sep 17 00:00:00 2001 From: Kellen Swain Date: Wed, 21 May 2025 12:38:36 -0700 Subject: [PATCH 41/53] wiring up chunked response logic (#860) --- pkg/epp/handlers/request.go | 25 ++++----- pkg/epp/handlers/response.go | 36 +++++++------ pkg/epp/handlers/server.go | 89 +++++++++++++++------------------ pkg/epp/handlers/server_test.go | 7 ++- 4 files changed, 74 insertions(+), 83 deletions(-) diff --git a/pkg/epp/handlers/request.go b/pkg/epp/handlers/request.go index 1df05ce5d..ab93e023a 100644 --- a/pkg/epp/handlers/request.go +++ b/pkg/epp/handlers/request.go @@ -60,23 +60,20 @@ func (s *StreamingServer) HandleRequestHeaders(ctx context.Context, reqCtx *Requ return nil } -func (s *StreamingServer) generateRequestBodyResponse(requestBodyBytes []byte) *extProcPb.ProcessingResponse { - return &extProcPb.ProcessingResponse{ - Response: &extProcPb.ProcessingResponse_RequestBody{ - RequestBody: &extProcPb.BodyResponse{ - Response: &extProcPb.CommonResponse{ - BodyMutation: &extProcPb.BodyMutation{ - Mutation: &extProcPb.BodyMutation_StreamedResponse{ - StreamedResponse: &extProcPb.StreamedBodyResponse{ - Body: requestBodyBytes, - EndOfStream: true, - }, - }, - }, +func (s *StreamingServer) generateRequestBodyResponses(requestBodyBytes []byte) []*extProcPb.ProcessingResponse { + commonResponses := buildCommonResponses(requestBodyBytes, bodyByteLimit, true) + responses := []*extProcPb.ProcessingResponse{} + for _, commonResp := range commonResponses { + resp := &extProcPb.ProcessingResponse{ + Response: &extProcPb.ProcessingResponse_RequestBody{ + RequestBody: &extProcPb.BodyResponse{ + Response: commonResp, }, }, - }, + } + responses = append(responses, resp) } + return responses } func (s *StreamingServer) generateRequestHeaderResponse(reqCtx *RequestContext) *extProcPb.ProcessingResponse { diff --git a/pkg/epp/handlers/response.go b/pkg/epp/handlers/response.go index bbc46c930..7284628cd 100644 --- a/pkg/epp/handlers/response.go +++ b/pkg/epp/handlers/response.go @@ -63,25 +63,7 @@ func (s *StreamingServer) HandleResponseBody( // will add the processing for streaming case. reqCtx.ResponseComplete = true - reqCtx.respBodyResp = &extProcPb.ProcessingResponse{ - // The Endpoint Picker supports two approaches to communicating the target endpoint, as a request header - // and as an unstructure ext-proc response metadata key/value pair. This enables different integration - // options for gateway providers. - Response: &extProcPb.ProcessingResponse_ResponseBody{ - ResponseBody: &extProcPb.BodyResponse{ - Response: &extProcPb.CommonResponse{ - BodyMutation: &extProcPb.BodyMutation{ - Mutation: &extProcPb.BodyMutation_StreamedResponse{ - StreamedResponse: &extProcPb.StreamedBodyResponse{ - Body: responseBytes, - EndOfStream: true, - }, - }, - }, - }, - }, - }, - } + reqCtx.respBodyResp = generateResponseBodyResponses(responseBytes, true) return reqCtx, nil } @@ -127,6 +109,22 @@ func (s *StreamingServer) generateResponseHeaderResponse(reqCtx *RequestContext) } } +func generateResponseBodyResponses(responseBodyBytes []byte, setEoS bool) []*extProcPb.ProcessingResponse { + commonResponses := buildCommonResponses(responseBodyBytes, bodyByteLimit, setEoS) + responses := []*extProcPb.ProcessingResponse{} + for _, commonResp := range commonResponses { + resp := &extProcPb.ProcessingResponse{ + Response: &extProcPb.ProcessingResponse_ResponseBody{ + ResponseBody: &extProcPb.BodyResponse{ + Response: commonResp, + }, + }, + } + responses = append(responses, resp) + } + return responses +} + func (s *StreamingServer) generateResponseHeaders(reqCtx *RequestContext) []*configPb.HeaderValueOption { // can likely refactor these two bespoke headers to be updated in PostDispatch, to centralize logic. headers := []*configPb.HeaderValueOption{ diff --git a/pkg/epp/handlers/server.go b/pkg/epp/handlers/server.go index 4b849c8aa..debf23964 100644 --- a/pkg/epp/handlers/server.go +++ b/pkg/epp/handlers/server.go @@ -99,11 +99,11 @@ type RequestContext struct { Response *Response reqHeaderResp *extProcPb.ProcessingResponse - reqBodyResp *extProcPb.ProcessingResponse + reqBodyResp []*extProcPb.ProcessingResponse reqTrailerResp *extProcPb.ProcessingResponse respHeaderResp *extProcPb.ProcessingResponse - respBodyResp *extProcPb.ProcessingResponse + respBodyResp []*extProcPb.ProcessingResponse respTrailerResp *extProcPb.ProcessingResponse } @@ -222,7 +222,7 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) } reqCtx.RequestSize = len(requestBodyBytes) reqCtx.reqHeaderResp = s.generateRequestHeaderResponse(reqCtx) - reqCtx.reqBodyResp = s.generateRequestBodyResponse(requestBodyBytes) + reqCtx.reqBodyResp = s.generateRequestBodyResponses(requestBodyBytes) metrics.RecordRequestCounter(reqCtx.Model, reqCtx.ResolvedTargetModel) metrics.RecordRequestSizes(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.RequestSize) @@ -264,22 +264,7 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) metrics.RecordResponseSizes(reqCtx.Model, reqCtx.ResolvedTargetModel, reqCtx.ResponseSize) } - reqCtx.respBodyResp = &extProcPb.ProcessingResponse{ - Response: &extProcPb.ProcessingResponse_ResponseBody{ - ResponseBody: &extProcPb.BodyResponse{ - Response: &extProcPb.CommonResponse{ - BodyMutation: &extProcPb.BodyMutation{ - Mutation: &extProcPb.BodyMutation_StreamedResponse{ - StreamedResponse: &extProcPb.StreamedBodyResponse{ - Body: v.ResponseBody.Body, - EndOfStream: v.ResponseBody.EndOfStream, - }, - }, - }, - }, - }, - }, - } + reqCtx.respBodyResp = generateResponseBodyResponses(v.ResponseBody.Body, v.ResponseBody.EndOfStream) } else { body = append(body, v.ResponseBody.Body...) @@ -293,22 +278,7 @@ func (s *StreamingServer) Process(srv extProcPb.ExternalProcessor_ProcessServer) responseErr = json.Unmarshal(body, &responseBody) if responseErr != nil { logger.V(logutil.DEFAULT).Error(responseErr, "Error unmarshaling request body", "body", string(body)) - reqCtx.respBodyResp = &extProcPb.ProcessingResponse{ - Response: &extProcPb.ProcessingResponse_ResponseBody{ - ResponseBody: &extProcPb.BodyResponse{ - Response: &extProcPb.CommonResponse{ - BodyMutation: &extProcPb.BodyMutation{ - Mutation: &extProcPb.BodyMutation_StreamedResponse{ - StreamedResponse: &extProcPb.StreamedBodyResponse{ - Body: body, - EndOfStream: true, - }, - }, - }, - }, - }, - }, - } + reqCtx.respBodyResp = generateResponseBodyResponses(body, true) break } @@ -361,10 +331,13 @@ func (r *RequestContext) updateStateAndSendIfNeeded(srv extProcPb.ExternalProces } r.RequestState = HeaderRequestResponseComplete } - if r.RequestState == HeaderRequestResponseComplete && r.reqBodyResp != nil { - loggerTrace.Info("Sending request body response") - if err := srv.Send(r.reqBodyResp); err != nil { - return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) + if r.RequestState == HeaderRequestResponseComplete && r.reqBodyResp != nil && len(r.reqBodyResp) > 0 { + loggerTrace.Info("Sending request body response(s)") + + for _, response := range r.reqBodyResp { + if err := srv.Send(response); err != nil { + return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) + } } r.RequestState = BodyRequestResponsesComplete metrics.IncRunningRequests(r.Model) @@ -385,15 +358,17 @@ func (r *RequestContext) updateStateAndSendIfNeeded(srv extProcPb.ExternalProces } r.RequestState = HeaderResponseResponseComplete } - if r.RequestState == HeaderResponseResponseComplete && r.respBodyResp != nil { - loggerTrace.Info("Sending response body response") - if err := srv.Send(r.respBodyResp); err != nil { - return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) - } + if r.RequestState == HeaderResponseResponseComplete && r.respBodyResp != nil && len(r.respBodyResp) > 0 { + loggerTrace.Info("Sending response body response(s)") + for _, response := range r.respBodyResp { + if err := srv.Send(response); err != nil { + return status.Errorf(codes.Unknown, "failed to send response back to Envoy: %v", err) + } - body := r.respBodyResp.Response.(*extProcPb.ProcessingResponse_ResponseBody) - if body.ResponseBody.Response.GetBodyMutation().GetStreamedResponse().GetEndOfStream() { - r.RequestState = BodyResponseResponsesComplete + body := response.Response.(*extProcPb.ProcessingResponse_ResponseBody) + if body.ResponseBody.Response.GetBodyMutation().GetStreamedResponse().GetEndOfStream() { + r.RequestState = BodyResponseResponsesComplete + } } // Dump the response so a new stream message can begin r.respBodyResp = nil @@ -466,16 +441,31 @@ func BuildErrResponse(err error) (*extProcPb.ProcessingResponse, error) { return resp, nil } -func buildCommonResponses(bodyBytes []byte, byteLimit int) []*extProcPb.CommonResponse { +func buildCommonResponses(bodyBytes []byte, byteLimit int, setEos bool) []*extProcPb.CommonResponse { responses := []*extProcPb.CommonResponse{} startingIndex := 0 bodyLen := len(bodyBytes) + if bodyLen == 0 { + return []*extProcPb.CommonResponse{ + { + BodyMutation: &extProcPb.BodyMutation{ + Mutation: &extProcPb.BodyMutation_StreamedResponse{ + StreamedResponse: &extProcPb.StreamedBodyResponse{ + Body: bodyBytes, + EndOfStream: setEos, + }, + }, + }, + }, + } + } + for startingIndex < bodyLen { eos := false len := min(bodyLen-startingIndex, byteLimit) chunk := bodyBytes[startingIndex : len+startingIndex] - if len+startingIndex == bodyLen { + if setEos && len+startingIndex >= bodyLen { eos = true } @@ -492,5 +482,6 @@ func buildCommonResponses(bodyBytes []byte, byteLimit int) []*extProcPb.CommonRe responses = append(responses, commonResp) startingIndex += len } + return responses } diff --git a/pkg/epp/handlers/server_test.go b/pkg/epp/handlers/server_test.go index cc99a517b..e836bf510 100644 --- a/pkg/epp/handlers/server_test.go +++ b/pkg/epp/handlers/server_test.go @@ -11,6 +11,11 @@ func TestBuildCommonResponses(t *testing.T) { count int expectedMessageCount int }{ + { + name: "zero case", + count: 0, + expectedMessageCount: 1, + }, { name: "below limit", count: bodyByteLimit - 1000, @@ -40,7 +45,7 @@ func TestBuildCommonResponses(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { arr := generateBytes(test.count) - responses := buildCommonResponses(arr, bodyByteLimit) + responses := buildCommonResponses(arr, bodyByteLimit, true) for i, response := range responses { eos := response.BodyMutation.GetStreamedResponse().GetEndOfStream() if eos == true && i+1 != len(responses) { From d55ead7c7f67444184a16b3230a3057fdb1d4121 Mon Sep 17 00:00:00 2001 From: nayihz Date: Fri, 23 May 2025 00:12:35 +0800 Subject: [PATCH 42/53] feat: merge two metric servers (#728) * feat: migrate epp metric server Signed-off-by: nayihz * feat: migrate bbr metric server Signed-off-by: nayihz * fix: metric reset not effect Signed-off-by: nayihz * fix: add the stability level to the help message of the metric * fix: refactor custom inferencepool metric Signed-off-by: nayihz --------- Signed-off-by: nayihz --- cmd/bbr/main.go | 84 ++---- cmd/epp/main.go | 107 ++------ pkg/bbr/handlers/request_test.go | 4 +- pkg/bbr/metrics/metrics.go | 51 ++-- pkg/epp/metrics/collectors/inference_pool.go | 31 ++- .../metrics/collectors/inference_pool_test.go | 4 +- pkg/epp/metrics/metrics.go | 245 +++++++++--------- pkg/epp/metrics/metrics_test.go | 34 +-- pkg/epp/server/controller_manager.go | 8 +- pkg/epp/util/metrics/metrics.go | 28 ++ test/integration/epp/hermetic_test.go | 61 ++--- 11 files changed, 281 insertions(+), 376 deletions(-) create mode 100644 pkg/epp/util/metrics/metrics.go diff --git a/cmd/bbr/main.go b/cmd/bbr/main.go index 84b1fffac..0dffa74d5 100644 --- a/cmd/bbr/main.go +++ b/cmd/bbr/main.go @@ -18,26 +18,23 @@ package main import ( "flag" - "net" - "net/http" + "fmt" "os" - "strconv" "github.com/go-logr/logr" - "github.com/prometheus/client_golang/prometheus/promhttp" uberzap "go.uber.org/zap" "go.uber.org/zap/zapcore" "google.golang.org/grpc" healthPb "google.golang.org/grpc/health/grpc_health_v1" - "k8s.io/client-go/rest" - "k8s.io/component-base/metrics/legacyregistry" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/log/zap" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/metrics/filters" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/gateway-api-inference-extension/internal/runnable" + "sigs.k8s.io/gateway-api-inference-extension/pkg/bbr/metrics" runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/bbr/server" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) @@ -85,7 +82,18 @@ func run() error { return err } - mgr, err := ctrl.NewManager(cfg, ctrl.Options{}) + metrics.Register() + + // Register metrics handler. + // Metrics endpoint is enabled in 'config/default/kustomization.yaml'. The Metrics options configure the server. + // More info: + // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.1/pkg/metrics/server + // - https://book.kubebuilder.io/reference/metrics.html + metricsServerOptions := metricsserver.Options{ + BindAddress: fmt.Sprintf(":%d", *metricsPort), + FilterProvider: filters.WithAuthenticationAndAuthorization, + } + mgr, err := ctrl.NewManager(cfg, ctrl.Options{Metrics: metricsServerOptions}) if err != nil { setupLog.Error(err, "Failed to create manager", "config", cfg) return err @@ -107,11 +115,6 @@ func run() error { return err } - // Register metrics handler. - if err := registerMetricsHandler(mgr, *metricsPort, cfg); err != nil { - return err - } - // Start the manager. This blocks until a signal is received. setupLog.Info("Manager starting") if err := mgr.Start(ctx); err != nil { @@ -152,58 +155,3 @@ func initLogging(opts *zap.Options) { logger := zap.New(zap.UseFlagOptions(opts), zap.RawZapOpts(uberzap.AddCaller())) ctrl.SetLogger(logger) } - -const metricsEndpoint = "/metrics" - -// registerMetricsHandler adds the metrics HTTP handler as a Runnable to the given manager. -func registerMetricsHandler(mgr manager.Manager, port int, cfg *rest.Config) error { - metrics.Register() - - // Init HTTP server. - h, err := metricsHandlerWithAuthenticationAndAuthorization(cfg) - if err != nil { - return err - } - - mux := http.NewServeMux() - mux.Handle(metricsEndpoint, h) - - srv := &http.Server{ - Addr: net.JoinHostPort("", strconv.Itoa(port)), - Handler: mux, - } - - if err := mgr.Add(&manager.Server{ - Name: "metrics", - Server: srv, - }); err != nil { - setupLog.Error(err, "Failed to register metrics HTTP handler") - return err - } - return nil -} - -func metricsHandlerWithAuthenticationAndAuthorization(cfg *rest.Config) (http.Handler, error) { - h := promhttp.HandlerFor( - legacyregistry.DefaultGatherer, - promhttp.HandlerOpts{}, - ) - httpClient, err := rest.HTTPClientFor(cfg) - if err != nil { - setupLog.Error(err, "Failed to create http client for metrics auth") - return nil, err - } - - filter, err := filters.WithAuthenticationAndAuthorization(cfg, httpClient) - if err != nil { - setupLog.Error(err, "Failed to create metrics filter for auth") - return nil, err - } - metricsLogger := ctrl.Log.WithName("metrics").WithValues("path", metricsEndpoint) - metricsAuthHandler, err := filter(metricsLogger, h) - if err != nil { - setupLog.Error(err, "Failed to create metrics auth handler") - return nil, err - } - return metricsAuthHandler, nil -} diff --git a/cmd/epp/main.go b/cmd/epp/main.go index 44ad9f0d1..1f707666a 100644 --- a/cmd/epp/main.go +++ b/cmd/epp/main.go @@ -19,25 +19,22 @@ package main import ( "flag" "fmt" - "net" - "net/http" "os" - "strconv" "github.com/go-logr/logr" - "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/prometheus/client_golang/prometheus" uberzap "go.uber.org/zap" "go.uber.org/zap/zapcore" "google.golang.org/grpc" healthPb "google.golang.org/grpc/health/grpc_health_v1" "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/rest" - "k8s.io/component-base/metrics/legacyregistry" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/metrics/filters" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/gateway-api-inference-extension/internal/runnable" backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" @@ -53,10 +50,6 @@ import ( "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) -const ( - defaultMetricsEndpoint = "/metrics" -) - var ( grpcPort = flag.Int( "grpcPort", @@ -163,16 +156,6 @@ func run() error { return err } - poolNamespacedName := types.NamespacedName{ - Name: *poolName, - Namespace: *poolNamespace, - } - mgr, err := runserver.NewDefaultManager(poolNamespacedName, cfg) - if err != nil { - setupLog.Error(err, "Failed to create controller manager") - return err - } - // Set up mapper for metric scraping. mapping, err := backendmetrics.NewMetricMapping( *totalQueuedRequestsMetric, @@ -191,6 +174,29 @@ func run() error { datastore := datastore.NewDatastore(ctx, pmf) + customCollectors := []prometheus.Collector{collectors.NewInferencePoolMetricsCollector(datastore)} + metrics.Register(customCollectors...) + metrics.RecordInferenceExtensionInfo() + // Register metrics handler. + // Metrics endpoint is enabled in 'config/default/kustomization.yaml'. The Metrics options configure the server. + // More info: + // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.1/pkg/metrics/server + // - https://book.kubebuilder.io/reference/metrics.html + metricsServerOptions := metricsserver.Options{ + BindAddress: fmt.Sprintf(":%d", *metricsPort), + FilterProvider: filters.WithAuthenticationAndAuthorization, + } + + poolNamespacedName := types.NamespacedName{ + Name: *poolName, + Namespace: *poolNamespace, + } + mgr, err := runserver.NewDefaultManager(poolNamespacedName, cfg, metricsServerOptions) + if err != nil { + setupLog.Error(err, "Failed to create controller manager") + return err + } + scheduler := scheduling.NewScheduler(datastore) if schedulerV2 == "true" { queueScorerWeight := envutil.GetEnvInt("QUEUE_SCORE_WEIGHT", scorer.DefaultQueueScorerWeight, setupLog) @@ -239,11 +245,6 @@ func run() error { return err } - // Register metrics handler. - if err := registerMetricsHandler(mgr, *metricsPort, cfg, datastore); err != nil { - return err - } - // Start the manager. This blocks until a signal is received. setupLog.Info("Controller manager starting") if err := mgr.Start(ctx); err != nil { @@ -287,62 +288,6 @@ func registerHealthServer(mgr manager.Manager, logger logr.Logger, ds datastore. return nil } -// registerMetricsHandler adds the metrics HTTP handler as a Runnable to the given manager. -func registerMetricsHandler(mgr manager.Manager, port int, cfg *rest.Config, ds datastore.Datastore) error { - metrics.Register() - legacyregistry.CustomMustRegister(collectors.NewInferencePoolMetricsCollector(ds)) - - metrics.RecordInferenceExtensionInfo() - - // Init HTTP server. - h, err := metricsHandlerWithAuthenticationAndAuthorization(cfg) - if err != nil { - return err - } - - mux := http.NewServeMux() - mux.Handle(defaultMetricsEndpoint, h) - - srv := &http.Server{ - Addr: net.JoinHostPort("", strconv.Itoa(port)), - Handler: mux, - } - - if err := mgr.Add(&manager.Server{ - Name: "metrics", - Server: srv, - }); err != nil { - setupLog.Error(err, "Failed to register metrics HTTP handler") - return err - } - return nil -} - -func metricsHandlerWithAuthenticationAndAuthorization(cfg *rest.Config) (http.Handler, error) { - h := promhttp.HandlerFor( - legacyregistry.DefaultGatherer, - promhttp.HandlerOpts{}, - ) - httpClient, err := rest.HTTPClientFor(cfg) - if err != nil { - setupLog.Error(err, "Failed to create http client for metrics auth") - return nil, err - } - - filter, err := filters.WithAuthenticationAndAuthorization(cfg, httpClient) - if err != nil { - setupLog.Error(err, "Failed to create metrics filter for auth") - return nil, err - } - metricsLogger := ctrl.Log.WithName("metrics").WithValues("path", defaultMetricsEndpoint) - metricsAuthHandler, err := filter(metricsLogger, h) - if err != nil { - setupLog.Error(err, "Failed to create metrics auth handler") - return nil, err - } - return metricsAuthHandler, nil -} - func validateFlags() error { if *poolName == "" { return fmt.Errorf("required %q flag not set", "poolName") diff --git a/pkg/bbr/handlers/request_test.go b/pkg/bbr/handlers/request_test.go index 55c42a218..3bc0d6fe4 100644 --- a/pkg/bbr/handlers/request_test.go +++ b/pkg/bbr/handlers/request_test.go @@ -26,8 +26,8 @@ import ( extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" "github.com/google/go-cmp/cmp" "google.golang.org/protobuf/testing/protocmp" - "k8s.io/component-base/metrics/legacyregistry" metricsutils "k8s.io/component-base/metrics/testutil" + crmetrics "sigs.k8s.io/controller-runtime/pkg/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/bbr/metrics" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) @@ -204,7 +204,7 @@ func TestHandleRequestBody(t *testing.T) { bbr_success_total{} 1 ` - if err := metricsutils.GatherAndCompare(legacyregistry.DefaultGatherer, strings.NewReader(wantMetrics), "inference_model_request_total"); err != nil { + if err := metricsutils.GatherAndCompare(crmetrics.Registry, strings.NewReader(wantMetrics), "inference_model_request_total"); err != nil { t.Error(err) } } diff --git a/pkg/bbr/metrics/metrics.go b/pkg/bbr/metrics/metrics.go index fc3538fba..4aec0e16d 100644 --- a/pkg/bbr/metrics/metrics.go +++ b/pkg/bbr/metrics/metrics.go @@ -19,49 +19,48 @@ package metrics import ( "sync" + "github.com/prometheus/client_golang/prometheus" compbasemetrics "k8s.io/component-base/metrics" - "k8s.io/component-base/metrics/legacyregistry" + "sigs.k8s.io/controller-runtime/pkg/metrics" + + metricsutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/metrics" ) const component = "bbr" var ( - successCounter = compbasemetrics.NewCounterVec( - &compbasemetrics.CounterOpts{ - Subsystem: component, - Name: "success_total", - Help: "Count of successes pulling model name from body and injecting it in the request headers.", - StabilityLevel: compbasemetrics.ALPHA, + successCounter = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Subsystem: component, + Name: "success_total", + Help: metricsutil.HelpMsgWithStability("Count of successes pulling model name from body and injecting it in the request headers.", compbasemetrics.ALPHA), }, []string{}, ) - modelNotInBodyCounter = compbasemetrics.NewCounterVec( - &compbasemetrics.CounterOpts{ - Subsystem: component, - Name: "model_not_in_body_total", - Help: "Count of times the model was not present in the request body.", - StabilityLevel: compbasemetrics.ALPHA, + modelNotInBodyCounter = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Subsystem: component, + Name: "model_not_in_body_total", + Help: metricsutil.HelpMsgWithStability("Count of times the model was not present in the request body.", compbasemetrics.ALPHA), }, []string{}, ) - modelNotParsedCounter = compbasemetrics.NewCounterVec( - &compbasemetrics.CounterOpts{ - Subsystem: component, - Name: "model_not_parsed_total", - Help: "Count of times the model was in the request body but we could not parse it.", - StabilityLevel: compbasemetrics.ALPHA, + modelNotParsedCounter = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Subsystem: component, + Name: "model_not_parsed_total", + Help: metricsutil.HelpMsgWithStability("Count of times the model was in the request body but we could not parse it.", compbasemetrics.ALPHA), }, []string{}, ) // TODO: Uncomment and use this metrics once the core server implementation has handling to skip body parsing if header exists. /* - modelAlreadyPresentInHeaderCounter = compbasemetrics.NewCounterVec( - &compbasemetrics.CounterOpts{ + modelAlreadyPresentInHeaderCounter = prometheus.NewCounterVec( + prometheus.CounterOpts{ Subsystem: component, Name: "model_already_present_in_header_total", Help: "Count of times the model was already present in request headers.", - StabilityLevel: compbasemetrics.ALPHA, }, []string{}, ) @@ -73,10 +72,10 @@ var registerMetrics sync.Once // Register all metrics. func Register() { registerMetrics.Do(func() { - legacyregistry.MustRegister(successCounter) - legacyregistry.MustRegister(modelNotInBodyCounter) - legacyregistry.MustRegister(modelNotParsedCounter) - // legacyregistry.MustRegister(modelAlreadyPresentInHeaderCounter) + metrics.Registry.MustRegister(successCounter) + metrics.Registry.MustRegister(modelNotInBodyCounter) + metrics.Registry.MustRegister(modelNotParsedCounter) + // metrics.Registry.MustRegister(modelAlreadyPresentInHeaderCounter) }) } diff --git a/pkg/epp/metrics/collectors/inference_pool.go b/pkg/epp/metrics/collectors/inference_pool.go index f916be514..2be3c1957 100644 --- a/pkg/epp/metrics/collectors/inference_pool.go +++ b/pkg/epp/metrics/collectors/inference_pool.go @@ -17,47 +17,46 @@ limitations under the License. package collectors import ( - "k8s.io/component-base/metrics" + "github.com/prometheus/client_golang/prometheus" + compbasemetrics "k8s.io/component-base/metrics" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" + metricsutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/metrics" ) var ( - descInferencePoolPerPodQueueSize = metrics.NewDesc( + descInferencePoolPerPodQueueSize = prometheus.NewDesc( "inference_pool_per_pod_queue_size", - "The total number of requests pending in the model server queue for each underlying pod.", + metricsutil.HelpMsgWithStability("The total number of requests pending in the model server queue for each underlying pod.", compbasemetrics.ALPHA), []string{ "name", "model_server_pod", }, nil, - metrics.ALPHA, - "", ) ) type inferencePoolMetricsCollector struct { - metrics.BaseStableCollector - ds datastore.Datastore } // Check if inferencePoolMetricsCollector implements necessary interface -var _ metrics.StableCollector = &inferencePoolMetricsCollector{} +var _ prometheus.Collector = &inferencePoolMetricsCollector{} -// NewInferencePoolMetricsCollector implements the metrics.StableCollector interface and +// NewInferencePoolMetricsCollector implements the prometheus.Collector interface and // exposes metrics about inference pool. -func NewInferencePoolMetricsCollector(ds datastore.Datastore) metrics.StableCollector { +func NewInferencePoolMetricsCollector(ds datastore.Datastore) prometheus.Collector { return &inferencePoolMetricsCollector{ ds: ds, } } -// DescribeWithStability implements the metrics.StableCollector interface. -func (c *inferencePoolMetricsCollector) DescribeWithStability(ch chan<- *metrics.Desc) { +// DescribeWithStability implements the prometheus.Collector interface. +func (c *inferencePoolMetricsCollector) Describe(ch chan<- *prometheus.Desc) { ch <- descInferencePoolPerPodQueueSize } -// CollectWithStability implements the metrics.StableCollector interface. -func (c *inferencePoolMetricsCollector) CollectWithStability(ch chan<- metrics.Metric) { +// CollectWithStability implements the prometheus.Collector interface. +func (c *inferencePoolMetricsCollector) Collect(ch chan<- prometheus.Metric) { pool, err := c.ds.PoolGet() if err != nil { return @@ -69,9 +68,9 @@ func (c *inferencePoolMetricsCollector) CollectWithStability(ch chan<- metrics.M } for _, pod := range podMetrics { - ch <- metrics.NewLazyConstMetric( + ch <- prometheus.MustNewConstMetric( descInferencePoolPerPodQueueSize, - metrics.GaugeValue, + prometheus.GaugeValue, float64(pod.GetMetrics().WaitingQueueSize), pool.Name, pod.GetPod().NamespacedName.Name, diff --git a/pkg/epp/metrics/collectors/inference_pool_test.go b/pkg/epp/metrics/collectors/inference_pool_test.go index b7ddf019d..d97377ee7 100644 --- a/pkg/epp/metrics/collectors/inference_pool_test.go +++ b/pkg/epp/metrics/collectors/inference_pool_test.go @@ -55,7 +55,7 @@ func TestNoMetricsCollected(t *testing.T) { ds: datastore, } - if err := testutil.CustomCollectAndCompare(collector, strings.NewReader(""), ""); err != nil { + if err := testutil.CollectAndCompare(collector, strings.NewReader(""), ""); err != nil { t.Fatal(err) } } @@ -90,7 +90,7 @@ func TestMetricsCollected(t *testing.T) { collector := &inferencePoolMetricsCollector{ ds: ds, } - err := testutil.CustomCollectAndCompare(collector, strings.NewReader(` + err := testutil.CollectAndCompare(collector, strings.NewReader(` # HELP inference_pool_per_pod_queue_size [ALPHA] The total number of requests pending in the model server queue for each underlying pod. # TYPE inference_pool_per_pod_queue_size gauge inference_pool_per_pod_queue_size{model_server_pod="pod1",name="test-pool"} 100 diff --git a/pkg/epp/metrics/metrics.go b/pkg/epp/metrics/metrics.go index a0d521400..c77e0f05c 100644 --- a/pkg/epp/metrics/metrics.go +++ b/pkg/epp/metrics/metrics.go @@ -21,10 +21,13 @@ import ( "sync" "time" + "github.com/prometheus/client_golang/prometheus" compbasemetrics "k8s.io/component-base/metrics" - "k8s.io/component-base/metrics/legacyregistry" "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/metrics" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" + metricsutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/metrics" ) const ( @@ -43,216 +46,199 @@ var ( var ( // Inference Model Metrics - requestCounter = compbasemetrics.NewCounterVec( - &compbasemetrics.CounterOpts{ - Subsystem: InferenceModelComponent, - Name: "request_total", - Help: "Counter of inference model requests broken out for each model and target model.", - StabilityLevel: compbasemetrics.ALPHA, + requestCounter = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Subsystem: InferenceModelComponent, + Name: "request_total", + Help: metricsutil.HelpMsgWithStability("Counter of inference model requests broken out for each model and target model.", compbasemetrics.ALPHA), }, []string{"model_name", "target_model_name"}, ) - requestErrCounter = compbasemetrics.NewCounterVec( - &compbasemetrics.CounterOpts{ - Subsystem: InferenceModelComponent, - Name: "request_error_total", - Help: "Counter of inference model requests errors broken out for each model and target model.", - StabilityLevel: compbasemetrics.ALPHA, + requestErrCounter = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Subsystem: InferenceModelComponent, + Name: "request_error_total", + Help: metricsutil.HelpMsgWithStability("Counter of inference model requests errors broken out for each model and target model.", compbasemetrics.ALPHA), }, []string{"model_name", "target_model_name", "error_code"}, ) - requestLatencies = compbasemetrics.NewHistogramVec( - &compbasemetrics.HistogramOpts{ + requestLatencies = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ Subsystem: InferenceModelComponent, Name: "request_duration_seconds", - Help: "Inference model response latency distribution in seconds for each model and target model.", + Help: metricsutil.HelpMsgWithStability("Inference model response latency distribution in seconds for each model and target model.", compbasemetrics.ALPHA), Buckets: []float64{ 0.005, 0.025, 0.05, 0.1, 0.2, 0.4, 0.6, 0.8, 1.0, 1.25, 1.5, 2, 3, 4, 5, 6, 8, 10, 15, 20, 30, 45, 60, 120, 180, 240, 300, 360, 480, 600, 900, 1200, 1800, 2700, 3600, }, - StabilityLevel: compbasemetrics.ALPHA, }, []string{"model_name", "target_model_name"}, ) - requestSizes = compbasemetrics.NewHistogramVec( - &compbasemetrics.HistogramOpts{ + requestSizes = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ Subsystem: InferenceModelComponent, Name: "request_sizes", - Help: "Inference model requests size distribution in bytes for each model and target model.", + Help: metricsutil.HelpMsgWithStability("Inference model requests size distribution in bytes for each model and target model.", compbasemetrics.ALPHA), // Use buckets ranging from 1000 bytes (1KB) to 10^9 bytes (1GB). Buckets: []float64{ 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536, // More fine-grained up to 64KB 131072, 262144, 524288, 1048576, 2097152, 4194304, 8388608, // Exponential up to 8MB 16777216, 33554432, 67108864, 134217728, 268435456, 536870912, 1073741824, // Exponential up to 1GB }, - StabilityLevel: compbasemetrics.ALPHA, }, []string{"model_name", "target_model_name"}, ) - responseSizes = compbasemetrics.NewHistogramVec( - &compbasemetrics.HistogramOpts{ + responseSizes = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ Subsystem: InferenceModelComponent, Name: "response_sizes", - Help: "Inference model responses size distribution in bytes for each model and target model.", + Help: metricsutil.HelpMsgWithStability("Inference model responses size distribution in bytes for each model and target model.", compbasemetrics.ALPHA), // Most models have a response token < 8192 tokens. Each token, in average, has 4 characters. // 8192 * 4 = 32768. - Buckets: []float64{1, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32778, 65536}, - StabilityLevel: compbasemetrics.ALPHA, + Buckets: []float64{1, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32778, 65536}, }, []string{"model_name", "target_model_name"}, ) - inputTokens = compbasemetrics.NewHistogramVec( - &compbasemetrics.HistogramOpts{ + inputTokens = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ Subsystem: InferenceModelComponent, Name: "input_tokens", - Help: "Inference model input token count distribution for requests in each model.", + Help: metricsutil.HelpMsgWithStability("Inference model input token count distribution for requests in each model.", compbasemetrics.ALPHA), // Most models have a input context window less than 1 million tokens. - Buckets: []float64{1, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32778, 65536, 131072, 262144, 524288, 1048576}, - StabilityLevel: compbasemetrics.ALPHA, + Buckets: []float64{1, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32778, 65536, 131072, 262144, 524288, 1048576}, }, []string{"model_name", "target_model_name"}, ) - outputTokens = compbasemetrics.NewHistogramVec( - &compbasemetrics.HistogramOpts{ + outputTokens = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ Subsystem: InferenceModelComponent, Name: "output_tokens", - Help: "Inference model output token count distribution for requests in each model.", + Help: metricsutil.HelpMsgWithStability("Inference model output token count distribution for requests in each model.", compbasemetrics.ALPHA), // Most models generates output less than 8192 tokens. - Buckets: []float64{1, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192}, - StabilityLevel: compbasemetrics.ALPHA, + Buckets: []float64{1, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192}, }, []string{"model_name", "target_model_name"}, ) - runningRequests = compbasemetrics.NewGaugeVec( - &compbasemetrics.GaugeOpts{ - Subsystem: InferenceModelComponent, - Name: "running_requests", - Help: "Inference model number of running requests in each model.", - StabilityLevel: compbasemetrics.ALPHA, + runningRequests = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Subsystem: InferenceModelComponent, + Name: "running_requests", + Help: metricsutil.HelpMsgWithStability("Inference model number of running requests in each model.", compbasemetrics.ALPHA), }, []string{"model_name"}, ) // NTPOT - Normalized Time Per Output Token - NormalizedTimePerOutputToken = compbasemetrics.NewHistogramVec( - &compbasemetrics.HistogramOpts{ + NormalizedTimePerOutputToken = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ Subsystem: InferenceModelComponent, Name: "normalized_time_per_output_token_seconds", - Help: "Inference model latency divided by number of output tokens in seconds for each model and target model.", + Help: metricsutil.HelpMsgWithStability("Inference model latency divided by number of output tokens in seconds for each model and target model.", compbasemetrics.ALPHA), // From few milliseconds per token to multiple seconds per token Buckets: []float64{ 0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, }, - StabilityLevel: compbasemetrics.ALPHA, }, []string{"model_name", "target_model_name"}, ) // Inference Pool Metrics - inferencePoolAvgKVCache = compbasemetrics.NewGaugeVec( - &compbasemetrics.GaugeOpts{ - Subsystem: InferencePoolComponent, - Name: "average_kv_cache_utilization", - Help: "The average kv cache utilization for an inference server pool.", - StabilityLevel: compbasemetrics.ALPHA, + inferencePoolAvgKVCache = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Subsystem: InferencePoolComponent, + Name: "average_kv_cache_utilization", + Help: metricsutil.HelpMsgWithStability("The average kv cache utilization for an inference server pool.", compbasemetrics.ALPHA), }, []string{"name"}, ) - inferencePoolAvgQueueSize = compbasemetrics.NewGaugeVec( - &compbasemetrics.GaugeOpts{ - Subsystem: InferencePoolComponent, - Name: "average_queue_size", - Help: "The average number of requests pending in the model server queue.", - StabilityLevel: compbasemetrics.ALPHA, + inferencePoolAvgQueueSize = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Subsystem: InferencePoolComponent, + Name: "average_queue_size", + Help: metricsutil.HelpMsgWithStability("The average number of requests pending in the model server queue.", compbasemetrics.ALPHA), }, []string{"name"}, ) - inferencePoolReadyPods = compbasemetrics.NewGaugeVec( - &compbasemetrics.GaugeOpts{ - Subsystem: InferencePoolComponent, - Name: "ready_pods", - Help: "The number of ready pods in the inference server pool.", - StabilityLevel: compbasemetrics.ALPHA, + inferencePoolReadyPods = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Subsystem: InferencePoolComponent, + Name: "ready_pods", + Help: metricsutil.HelpMsgWithStability("The number of ready pods in the inference server pool.", compbasemetrics.ALPHA), }, []string{"name"}, ) // Scheduler Metrics - SchedulerE2ELatency = compbasemetrics.NewHistogramVec( - &compbasemetrics.HistogramOpts{ + SchedulerE2ELatency = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ Subsystem: InferenceExtension, Name: "scheduler_e2e_duration_seconds", - Help: "End-to-end scheduling latency distribution in seconds.", + Help: metricsutil.HelpMsgWithStability("End-to-end scheduling latency distribution in seconds.", compbasemetrics.ALPHA), Buckets: []float64{ 0.0001, 0.0002, 0.0005, 0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, }, - StabilityLevel: compbasemetrics.ALPHA, + // StabilityLevel: prometheus.ALPHA, }, []string{}, ) - SchedulerPluginProcessingLatencies = compbasemetrics.NewHistogramVec( - &compbasemetrics.HistogramOpts{ + SchedulerPluginProcessingLatencies = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ Subsystem: InferenceExtension, Name: "scheduler_plugin_duration_seconds", - Help: "Scheduler plugin processing latency distribution in seconds for each plugin type and plugin name.", + Help: metricsutil.HelpMsgWithStability("Scheduler plugin processing latency distribution in seconds for each plugin type and plugin name.", compbasemetrics.ALPHA), Buckets: []float64{ 0.0001, 0.0002, 0.0005, 0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, }, - StabilityLevel: compbasemetrics.ALPHA, }, []string{"plugin_type", "plugin_name"}, ) // Prefix indexer Metrics - PrefixCacheSize = compbasemetrics.NewGaugeVec( - &compbasemetrics.GaugeOpts{ - Subsystem: InferenceExtension, - Name: "prefix_indexer_size", - Help: "Size of the prefix indexer.", - StabilityLevel: compbasemetrics.ALPHA, + PrefixCacheSize = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Subsystem: InferenceExtension, + Name: "prefix_indexer_size", + Help: metricsutil.HelpMsgWithStability("Size of the prefix indexer.", compbasemetrics.ALPHA), }, []string{}, ) - PrefixCacheHitRatio = compbasemetrics.NewHistogramVec( - &compbasemetrics.HistogramOpts{ + PrefixCacheHitRatio = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ Subsystem: InferenceExtension, Name: "prefix_indexer_hit_ratio", - Help: "Ratio of prefix length matched to total prefix length in the cache lookup.", + Help: metricsutil.HelpMsgWithStability("Ratio of prefix length matched to total prefix length in the cache lookup.", compbasemetrics.ALPHA), // Buckets from 0.0 to 1.0 in increments - Buckets: []float64{0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0}, - StabilityLevel: compbasemetrics.ALPHA, + Buckets: []float64{0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0}, }, []string{}, ) - PrefixCacheHitLength = compbasemetrics.NewHistogramVec( - &compbasemetrics.HistogramOpts{ - Subsystem: InferenceExtension, - Name: "prefix_indexer_hit_bytes", - Help: "Length of the prefix match in number of bytes in the cache lookup.", - Buckets: []float64{0, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536}, - StabilityLevel: compbasemetrics.ALPHA, + PrefixCacheHitLength = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Subsystem: InferenceExtension, + Name: "prefix_indexer_hit_bytes", + Help: metricsutil.HelpMsgWithStability("Length of the prefix match in number of bytes in the cache lookup.", compbasemetrics.ALPHA), + Buckets: []float64{0, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536}, }, []string{}, ) // Info Metrics - InferenceExtensionInfo = compbasemetrics.NewGaugeVec( - &compbasemetrics.GaugeOpts{ - Subsystem: InferenceExtension, - Name: "info", - Help: "General information of the current build of Inference Extension.", - StabilityLevel: compbasemetrics.ALPHA, + InferenceExtensionInfo = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Subsystem: InferenceExtension, + Name: "info", + Help: metricsutil.HelpMsgWithStability("General information of the current build of Inference Extension.", compbasemetrics.ALPHA), }, []string{"commit", "build_ref"}, ) @@ -261,33 +247,54 @@ var ( var registerMetrics sync.Once // Register all metrics. -func Register() { +func Register(customCollectors ...prometheus.Collector) { registerMetrics.Do(func() { - legacyregistry.MustRegister(requestCounter) - legacyregistry.MustRegister(requestErrCounter) - legacyregistry.MustRegister(requestLatencies) - legacyregistry.MustRegister(requestSizes) - legacyregistry.MustRegister(responseSizes) - legacyregistry.MustRegister(inputTokens) - legacyregistry.MustRegister(outputTokens) - legacyregistry.MustRegister(runningRequests) - legacyregistry.MustRegister(NormalizedTimePerOutputToken) - - legacyregistry.MustRegister(inferencePoolAvgKVCache) - legacyregistry.MustRegister(inferencePoolAvgQueueSize) - legacyregistry.MustRegister(inferencePoolReadyPods) - - legacyregistry.MustRegister(SchedulerPluginProcessingLatencies) - legacyregistry.MustRegister(SchedulerE2ELatency) - - legacyregistry.MustRegister(InferenceExtensionInfo) - - legacyregistry.MustRegister(PrefixCacheSize) - legacyregistry.MustRegister(PrefixCacheHitRatio) - legacyregistry.MustRegister(PrefixCacheHitLength) + metrics.Registry.MustRegister(requestCounter) + metrics.Registry.MustRegister(requestErrCounter) + metrics.Registry.MustRegister(requestLatencies) + metrics.Registry.MustRegister(requestSizes) + metrics.Registry.MustRegister(responseSizes) + metrics.Registry.MustRegister(inputTokens) + metrics.Registry.MustRegister(outputTokens) + metrics.Registry.MustRegister(runningRequests) + metrics.Registry.MustRegister(NormalizedTimePerOutputToken) + metrics.Registry.MustRegister(inferencePoolAvgKVCache) + metrics.Registry.MustRegister(inferencePoolAvgQueueSize) + metrics.Registry.MustRegister(inferencePoolReadyPods) + metrics.Registry.MustRegister(SchedulerPluginProcessingLatencies) + metrics.Registry.MustRegister(SchedulerE2ELatency) + metrics.Registry.MustRegister(InferenceExtensionInfo) + metrics.Registry.MustRegister(PrefixCacheSize) + metrics.Registry.MustRegister(PrefixCacheHitRatio) + metrics.Registry.MustRegister(PrefixCacheHitLength) + for _, collector := range customCollectors { + metrics.Registry.MustRegister(collector) + } }) } +// Just for integration test +func Reset() { + requestCounter.Reset() + requestErrCounter.Reset() + requestLatencies.Reset() + requestSizes.Reset() + responseSizes.Reset() + inputTokens.Reset() + outputTokens.Reset() + runningRequests.Reset() + NormalizedTimePerOutputToken.Reset() + inferencePoolAvgKVCache.Reset() + inferencePoolAvgQueueSize.Reset() + inferencePoolReadyPods.Reset() + SchedulerPluginProcessingLatencies.Reset() + SchedulerE2ELatency.Reset() + InferenceExtensionInfo.Reset() + PrefixCacheSize.Reset() + PrefixCacheHitRatio.Reset() + PrefixCacheHitLength.Reset() +} + // RecordRequstCounter records the number of requests. func RecordRequestCounter(modelName, targetModelName string) { requestCounter.WithLabelValues(modelName, targetModelName).Inc() diff --git a/pkg/epp/metrics/metrics_test.go b/pkg/epp/metrics/metrics_test.go index 4ad6f96e1..8cee042eb 100644 --- a/pkg/epp/metrics/metrics_test.go +++ b/pkg/epp/metrics/metrics_test.go @@ -22,8 +22,8 @@ import ( "testing" "time" - "k8s.io/component-base/metrics/legacyregistry" "k8s.io/component-base/metrics/testutil" + "sigs.k8s.io/controller-runtime/pkg/metrics" errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/error" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) @@ -93,7 +93,7 @@ func TestRecordRequestCounterandSizes(t *testing.T) { if err != nil { t.Fatal(err) } - if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, wantRequestTotal, RequestTotalMetric); err != nil { + if err := testutil.GatherAndCompare(metrics.Registry, wantRequestTotal, RequestTotalMetric); err != nil { t.Error(err) } wantRequestSizes, err := os.Open("testdata/request_sizes_metric") @@ -105,7 +105,7 @@ func TestRecordRequestCounterandSizes(t *testing.T) { if err != nil { t.Fatal(err) } - if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, wantRequestSizes, RequestSizesMetric); err != nil { + if err := testutil.GatherAndCompare(metrics.Registry, wantRequestSizes, RequestSizesMetric); err != nil { t.Error(err) } }) @@ -165,7 +165,7 @@ func TestRecordRequestErrorCounter(t *testing.T) { if err != nil { t.Fatal(err) } - if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, wantRequestErrorCounter, RequestErrorTotalMetric); err != nil { + if err := testutil.GatherAndCompare(metrics.Registry, wantRequestErrorCounter, RequestErrorTotalMetric); err != nil { t.Error(err) } }) @@ -247,7 +247,7 @@ func TestRecordRequestLatencies(t *testing.T) { if err != nil { t.Fatal(err) } - if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, wantRequestLatencies, RequestLatenciesMetric); err != nil { + if err := testutil.GatherAndCompare(metrics.Registry, wantRequestLatencies, RequestLatenciesMetric); err != nil { t.Error(err) } }) @@ -348,7 +348,7 @@ func TestRecordNormalizedTimePerOutputToken(t *testing.T) { if err != nil { t.Fatal(err) } - if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, wantLatencyPerToken, NormalizedTimePerOutputTokenMetric); err != nil { + if err := testutil.GatherAndCompare(metrics.Registry, wantLatencyPerToken, NormalizedTimePerOutputTokenMetric); err != nil { t.Error(err) } }) @@ -416,7 +416,7 @@ func TestRecordResponseMetrics(t *testing.T) { if err != nil { t.Fatal(err) } - if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, wantResponseSize, ResponseSizesMetric); err != nil { + if err := testutil.GatherAndCompare(metrics.Registry, wantResponseSize, ResponseSizesMetric); err != nil { t.Error(err) } @@ -429,7 +429,7 @@ func TestRecordResponseMetrics(t *testing.T) { if err != nil { t.Fatal(err) } - if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, wantInputToken, InputTokensMetric); err != nil { + if err := testutil.GatherAndCompare(metrics.Registry, wantInputToken, InputTokensMetric); err != nil { t.Error(err) } @@ -442,7 +442,7 @@ func TestRecordResponseMetrics(t *testing.T) { if err != nil { t.Fatal(err) } - if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, wantOutputToken, OutputTokensMetric); err != nil { + if err := testutil.GatherAndCompare(metrics.Registry, wantOutputToken, OutputTokensMetric); err != nil { t.Error(err) } }) @@ -502,7 +502,7 @@ func TestRunningRequestsMetrics(t *testing.T) { if err != nil { t.Fatal(err) } - if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, wantRunningRequests, RunningRequestsMetric); err != nil { + if err := testutil.GatherAndCompare(metrics.Registry, wantRunningRequests, RunningRequestsMetric); err != nil { t.Error(err) } }) @@ -538,7 +538,7 @@ func TestInferencePoolMetrics(t *testing.T) { if err != nil { t.Fatal(err) } - if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, wantKVCache, KVCacheAvgUsageMetric); err != nil { + if err := testutil.GatherAndCompare(metrics.Registry, wantKVCache, KVCacheAvgUsageMetric); err != nil { t.Error(err) } @@ -551,7 +551,7 @@ func TestInferencePoolMetrics(t *testing.T) { if err != nil { t.Fatal(err) } - if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, wantQueueSize, QueueAvgSizeMetric); err != nil { + if err := testutil.GatherAndCompare(metrics.Registry, wantQueueSize, QueueAvgSizeMetric); err != nil { t.Error(err) } }) @@ -615,7 +615,7 @@ func TestSchedulerPluginProcessingLatencies(t *testing.T) { if err != nil { t.Fatal(err) } - if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, wantPluginLatencies, "inference_extension_scheduler_plugin_duration_seconds"); err != nil { + if err := testutil.GatherAndCompare(metrics.Registry, wantPluginLatencies, "inference_extension_scheduler_plugin_duration_seconds"); err != nil { t.Error(err) } }) @@ -658,7 +658,7 @@ func TestSchedulerE2ELatency(t *testing.T) { if err != nil { t.Fatal(err) } - if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, wantE2ELatency, "inference_extension_scheduler_e2e_duration_seconds"); err != nil { + if err := testutil.GatherAndCompare(metrics.Registry, wantE2ELatency, "inference_extension_scheduler_e2e_duration_seconds"); err != nil { t.Error(err) } }) @@ -734,7 +734,7 @@ func TestPrefixCacheMetrics(t *testing.T) { if err != nil { t.Fatal(err) } - if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, wantCacheSizeMetrics, PrefixCacheSizeMetric); err != nil { + if err := testutil.GatherAndCompare(metrics.Registry, wantCacheSizeMetrics, PrefixCacheSizeMetric); err != nil { t.Error(err) } @@ -748,7 +748,7 @@ func TestPrefixCacheMetrics(t *testing.T) { if err != nil { t.Fatal(err) } - if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, wantHitRatioMetrics, PrefixCacheHitRatioMetric); err != nil { + if err := testutil.GatherAndCompare(metrics.Registry, wantHitRatioMetrics, PrefixCacheHitRatioMetric); err != nil { t.Error(err) } @@ -762,7 +762,7 @@ func TestPrefixCacheMetrics(t *testing.T) { if err != nil { t.Fatal(err) } - if err := testutil.GatherAndCompare(legacyregistry.DefaultGatherer, wantHitLengthMetrics, PrefixCacheHitLengthMetric); err != nil { + if err := testutil.GatherAndCompare(metrics.Registry, wantHitLengthMetrics, PrefixCacheHitLengthMetric); err != nil { t.Error(err) } }) diff --git a/pkg/epp/server/controller_manager.go b/pkg/epp/server/controller_manager.go index e56682104..89e509696 100644 --- a/pkg/epp/server/controller_manager.go +++ b/pkg/epp/server/controller_manager.go @@ -30,6 +30,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/manager" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" ) @@ -41,7 +42,7 @@ func init() { } // defaultManagerOptions returns the default options used to create the manager. -func defaultManagerOptions(namespacedName types.NamespacedName) ctrl.Options { +func defaultManagerOptions(namespacedName types.NamespacedName, metricsServerOptions metricsserver.Options) ctrl.Options { return ctrl.Options{ Scheme: scheme, Cache: cache.Options{ @@ -67,12 +68,13 @@ func defaultManagerOptions(namespacedName types.NamespacedName) ctrl.Options { }, }, }, + Metrics: metricsServerOptions, } } // NewDefaultManager creates a new controller manager with default configuration. -func NewDefaultManager(namespacedName types.NamespacedName, restConfig *rest.Config) (ctrl.Manager, error) { - manager, err := ctrl.NewManager(restConfig, defaultManagerOptions(namespacedName)) +func NewDefaultManager(namespacedName types.NamespacedName, restConfig *rest.Config, metricsServerOptions metricsserver.Options) (ctrl.Manager, error) { + manager, err := ctrl.NewManager(restConfig, defaultManagerOptions(namespacedName, metricsServerOptions)) if err != nil { return nil, fmt.Errorf("failed to create controller manager: %v", err) } diff --git a/pkg/epp/util/metrics/metrics.go b/pkg/epp/util/metrics/metrics.go new file mode 100644 index 000000000..167669435 --- /dev/null +++ b/pkg/epp/util/metrics/metrics.go @@ -0,0 +1,28 @@ +/* +Copyright 2025 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 metrics + +import ( + "fmt" + + compbasemetrics "k8s.io/component-base/metrics" +) + +// HelpMsgWithStability is a helper function to create a help message with stability level. +func HelpMsgWithStability(msg string, stability compbasemetrics.StabilityLevel) string { + return fmt.Sprintf("[%v] %v", stability, msg) +} diff --git a/test/integration/epp/hermetic_test.go b/test/integration/epp/hermetic_test.go index a9d54fa4e..4ea56c9d1 100644 --- a/test/integration/epp/hermetic_test.go +++ b/test/integration/epp/hermetic_test.go @@ -24,8 +24,6 @@ import ( "errors" "fmt" "io" - "net" - "net/http" "os" "path/filepath" "strconv" @@ -37,7 +35,6 @@ import ( extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3" envoyTypePb "github.com/envoyproxy/go-control-plane/envoy/type/v3" "github.com/google/go-cmp/cmp" - "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/stretchr/testify/assert" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" @@ -51,20 +48,22 @@ import ( utilruntime "k8s.io/apimachinery/pkg/util/runtime" k8syaml "k8s.io/apimachinery/pkg/util/yaml" clientgoscheme "k8s.io/client-go/kubernetes/scheme" - "k8s.io/component-base/metrics/legacyregistry" metricsutils "k8s.io/component-base/metrics/testutil" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/cache" k8sclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/config" "sigs.k8s.io/controller-runtime/pkg/envtest" - "sigs.k8s.io/controller-runtime/pkg/manager" + crmetrics "sigs.k8s.io/controller-runtime/pkg/metrics" + "sigs.k8s.io/controller-runtime/pkg/metrics/filters" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" epptestutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/testing" @@ -1448,13 +1447,12 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { if len(test.wantMetrics) != 0 { for metricName, value := range test.wantMetrics { - if err := metricsutils.GatherAndCompare(legacyregistry.DefaultGatherer, strings.NewReader(value), metricName); err != nil { + if err := metricsutils.GatherAndCompare(crmetrics.Registry, strings.NewReader(value), metricName); err != nil { t.Error(err) } } } - - legacyregistry.Reset() + metrics.Reset() }) } } @@ -1569,15 +1567,21 @@ func BeforeSuite() func() { // Init runtime. ctrl.SetLogger(logger) - mgr, err := runserver.NewManagerWithOptions(cfg, managerTestOptions("default", "vllm-llama3-8b-instruct-pool")) + metrics.Register() + // Register metrics handler. + // Metrics endpoint is enabled in 'config/default/kustomization.yaml'. The Metrics options configure the server. + // More info: + // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.1/pkg/metrics/server + // - https://book.kubebuilder.io/reference/metrics.html + metricsServerOptions := metricsserver.Options{ + BindAddress: fmt.Sprintf(":%d", metricsPort), + FilterProvider: filters.WithAuthenticationAndAuthorization, + } + mgr, err := server.NewManagerWithOptions(cfg, managerTestOptions("default", "vllm-llama3-8b-instruct-pool", metricsServerOptions)) if err != nil { logutil.Fatal(logger, err, "Failed to create controller manager") } - if err := registerMetricsHandler(mgr, metricsPort); err != nil { - logutil.Fatal(logger, err, "Failed to register metrics handler") - } - serverRunner = runserver.NewDefaultExtProcServerRunner() serverRunner.TestPodMetricsClient = &backendmetrics.FakePodMetricsClient{} pmf := backendmetrics.NewPodMetricsFactory(serverRunner.TestPodMetricsClient, 10*time.Millisecond) @@ -1674,37 +1678,9 @@ func makeMetadata(endpoint string) *structpb.Struct { } } -// registerMetricsHandler is a simplified version of metrics endpoint handler -// without Authentication for integration tests. -func registerMetricsHandler(mgr manager.Manager, port int) error { - metrics.Register() - - // Init HTTP server. - h := promhttp.HandlerFor( - legacyregistry.DefaultGatherer, - promhttp.HandlerOpts{}, - ) - - mux := http.NewServeMux() - mux.Handle("/metrics", h) - - srv := &http.Server{ - Addr: net.JoinHostPort("", strconv.Itoa(port)), - Handler: mux, - } - - if err := mgr.Add(&manager.Server{ - Name: "metrics", - Server: srv, - }); err != nil { - return err - } - return nil -} - // inject options that allow multiple test runs to run // https://github.com/kubernetes-sigs/controller-runtime/issues/2937 -func managerTestOptions(namespace, name string) ctrl.Options { +func managerTestOptions(namespace, name string, metricsServerOptions metricsserver.Options) ctrl.Options { return ctrl.Options{ Scheme: scheme, Cache: cache.Options{ @@ -1733,6 +1709,7 @@ func managerTestOptions(namespace, name string) ctrl.Options { Controller: config.Controller{ SkipNameValidation: boolPointer(true), }, + Metrics: metricsServerOptions, } } From ed32a431ce2eb275859b0c7ff71d4b77710b59f1 Mon Sep 17 00:00:00 2001 From: capri-xiyue <52932582+capri-xiyue@users.noreply.github.com> Date: Thu, 22 May 2025 17:46:34 +0000 Subject: [PATCH 43/53] docs: added examples to address various generative AI application scenarios by using gateway api inference extension (#812) * added common cases * added more details Signed-off-by: Xiyue Yu * fixed comments * changed file location * fixed typo * Update site-src/guides/serve-multiple-lora-adapters.md Co-authored-by: Cong Liu * Update site-src/guides/serve-multiple-lora-adapters.md Co-authored-by: Cong Liu * Update mkdocs.yml Co-authored-by: Rob Scott * Update site-src/guides/serve-multiple-lora-adapters.md Co-authored-by: Rob Scott * Update site-src/guides/serve-multiple-genai-models.md Co-authored-by: Rob Scott * added subsession * fixed wording --------- Signed-off-by: Xiyue Yu Co-authored-by: Cong Liu Co-authored-by: Rob Scott --- mkdocs.yml | 7 +- .../guides/serve-multiple-genai-models.md | 71 +++++++++++++ .../guides/serve-multiple-lora-adapters.md | 100 ++++++++++++++++++ site-src/images/serve-LoRA-adapters.png | Bin 0 -> 379551 bytes site-src/images/serve-mul-gen-AI-models.png | Bin 0 -> 412887 bytes 5 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 site-src/guides/serve-multiple-genai-models.md create mode 100644 site-src/guides/serve-multiple-lora-adapters.md create mode 100644 site-src/images/serve-LoRA-adapters.png create mode 100644 site-src/images/serve-mul-gen-AI-models.png diff --git a/mkdocs.yml b/mkdocs.yml index 1741fd1c8..a48b79d7f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -61,9 +61,12 @@ nav: - Guides: - User Guides: - Getting started: guides/index.md + - Use Cases: + - Serve Multiple GenAI models: guides/serve-multiple-genai-models.md + - Serve Multiple LoRA adapters: guides/serve-multiple-lora-adapters.md - Rollout: - - Adapter Rollout: guides/adapter-rollout.md - - InferencePool Rollout: guides/inferencepool-rollout.md + - Adapter Rollout: guides/adapter-rollout.md + - InferencePool Rollout: guides/inferencepool-rollout.md - Metrics: guides/metrics.md - Implementer's Guide: guides/implementers.md - Performance: diff --git a/site-src/guides/serve-multiple-genai-models.md b/site-src/guides/serve-multiple-genai-models.md new file mode 100644 index 000000000..2c6d8a53c --- /dev/null +++ b/site-src/guides/serve-multiple-genai-models.md @@ -0,0 +1,71 @@ +# Serve multiple generative AI models +A company wants to deploy multiple large language models (LLMs) to serve different workloads. +For example, they might want to deploy a Gemma3 model for a chatbot interface and a Deepseek model for a recommendation application. +The company needs to ensure optimal serving performance for these LLMs. +Using Gateway API Inference Extension, you can deploy these LLMs on your cluster with your chosen accelerator configuration in an `InferencePool`. +You can then route requests based on the model name (such as "chatbot" and "recommender") and the `Criticality` property. + +## How +The following diagram illustrates how Gateway API Inference Extension routes requests to different models based on the model name. +![Serving multiple generative AI models](../images/serve-mul-gen-AI-models.png) + +This example illustrates a conceptual example regarding how to use the `HTTPRoute` object to route based on model name like “chatbot” or “recommender” to `InferencePool`. +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: routes-to-llms +spec: + parentRefs: + - name: inference-gateway + rules: + - matches: + - headers: + - type: Exact + #Body-Based routing(https://github.com/kubernetes-sigs/gateway-api-inference-extension/blob/main/pkg/bbr/README.md) is being used to copy the model name from the request body to the header. + name: X-Gateway-Model-Name + value: chatbot + path: + type: PathPrefix + value: / + backendRefs: + - name: gemma3 + kind: InferencePool + - matches: + - headers: + - type: Exact + #Body-Based routing(https://github.com/kubernetes-sigs/gateway-api-inference-extension/blob/main/pkg/bbr/README.md) is being used to copy the model name from the request body to the header. + name: X-Gateway-Model-Name + value: recommender + path: + type: PathPrefix + value: / + backendRefs: + - name: deepseek-r1 + kind: InferencePool +``` + +## Try it out + +1. Get the gateway IP: +```bash +IP=$(kubectl get gateway/inference-gateway -o jsonpath='{.status.addresses[0].value}'); PORT=80 +``` +2. Send a few requests to model "chatbot" as follows: +```bash +curl -i ${IP}:${PORT}/v1/completions -H 'Content-Type: application/json' -d '{ +"model": "chatbot", +"prompt": "What is the color of the sky", +"max_tokens": 100, +"temperature": 0 +}' +``` +3. Send a few requests to model "recommender" as follows: +```bash +curl -i ${IP}:${PORT}/v1/completions -H 'Content-Type: application/json' -d '{ +"model": "chatbot", +"prompt": "Give me restaurant recommendations in Paris", +"max_tokens": 100, +"temperature": 0 +}' +``` \ No newline at end of file diff --git a/site-src/guides/serve-multiple-lora-adapters.md b/site-src/guides/serve-multiple-lora-adapters.md new file mode 100644 index 000000000..b471faef4 --- /dev/null +++ b/site-src/guides/serve-multiple-lora-adapters.md @@ -0,0 +1,100 @@ +# Serve LoRA adapters on a shared pool +A company wants to serve LLMs for document analysis and focuses on audiences in multiple languages, such as English and Spanish. +They have a fine-tuned LoRA adapter for each language, but need to efficiently use their GPU and TPU capacity. +You can use Gateway API Inference Extension to deploy dynamic LoRA fine-tuned adapters for each language (for example, `english-bot` and `spanish-bot`) on a common base model and accelerator. +This lets you reduce the number of required accelerators by densely packing multiple models in a shared pool. + +## How +The following diagram illustrates how Gateway API Inference Extension serves multiple LoRA adapters on a shared pool. +![Serving LoRA adapters on a shared pool](../images/serve-LoRA-adapters.png) +This example illustrates how you can densely serve multiple LoRA adapters with distinct workload performance objectives on a common InferencePool. +```yaml +apiVersion: gateway.networking.x-k8s.io/v1alpha1 +kind: InferencePool +metadata: + name: gemma3 +spec: + selector: + pool: gemma3 +``` +Let us say we have a couple of LoRA adapters named “english-bot” and “spanish-bot” for the Gemma3 base model. +You can create an `InferenceModel` resource and associate these LoRA adapters to the relevant InferencePool resource. +In this case, we associate these LoRA adapters to the gemma3 InferencePool resource created above. + +```yaml +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferenceModel +metadata: + name: english-bot +spec: + modelName: english-bot + criticality: Standard + poolRef: + name: gemma3 + +--- +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferenceModel +metadata: + name: spanish-bot +spec: + modelName: spanish-bot + criticality: Critical + poolRef: + name: gemma3 + +``` +Now, you can route your requests from the gateway using the `HTTPRoute` object. +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: inference-gateway +spec: + listeners: + - protocol: HTTP + port: 80 + name: http + +--- +kind: HTTPRoute +apiVersion: gateway.networking.k8s.io/v1 +metadata: + name: routes-to-llms +spec: + parentRefs: + - name: inference-gateway + rules: + - matches: + path: + type: PathPrefix + value: / + backendRefs: + - name: gemma3 + kind: InferencePool +``` + +## Try it out + +1. Get the gateway IP: +```bash +IP=$(kubectl get gateway/inference-gateway -o jsonpath='{.status.addresses[0].value}'); PORT=80 +``` +2. Send a few requests to model "english-bot" as follows: +```bash +curl -i ${IP}:${PORT}/v1/completions -H 'Content-Type: application/json' -d '{ +"model": "english-bot", +"prompt": "What is the color of the sky", +"max_tokens": 100, +"temperature": 0 +}' +``` +3. Send a few requests to model "spanish-bot" as follows: +```bash +curl -i ${IP}:${PORT}/v1/completions -H 'Content-Type: application/json' -d '{ +"model": "spanish-bot", +"prompt": "¿De qué color es...?", +"max_tokens": 100, +"temperature": 0 +}' +``` \ No newline at end of file diff --git a/site-src/images/serve-LoRA-adapters.png b/site-src/images/serve-LoRA-adapters.png new file mode 100644 index 0000000000000000000000000000000000000000..e33dc708ab67fd7ebce79f3a10dd84afb4654bb5 GIT binary patch literal 379551 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 M#GfU`vq=w@ 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�RliKHkWg~$ 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 WsCC-qK7hg^YgZeuy7bL9zyAg7G|Eo^ literal 0 HcmV?d00001 diff --git a/site-src/images/serve-mul-gen-AI-models.png b/site-src/images/serve-mul-gen-AI-models.png new file mode 100644 index 0000000000000000000000000000000000000000..957a054f15cf0f8c88b405e397ed8ad7cd13d10d GIT binary patch literal 412887 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 (4_Gy_#pv1 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

-wOcXyKa( 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ⅆ}r$&XP0*M4(0He|5X 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 La>wG2=w16?S$B>i literal 0 HcmV?d00001 From 28229bf97bdb61562ca47e066c707beeccc50ef7 Mon Sep 17 00:00:00 2001 From: Yuan Tang Date: Thu, 22 May 2025 23:22:35 -0400 Subject: [PATCH 44/53] docs: Update link to Slack channel (#867) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 87ee41421..66b799578 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ Follow this [README](./test/e2e/epp/README.md) to learn more about running the i Our community meeting is weekly at Thursday 10AM PDT ([Zoom](https://zoom.us/j/9955436256?pwd=Z2FQWU1jeDZkVC9RRTN4TlZyZTBHZz09), [Meeting Notes](https://www.google.com/url?q=https://docs.google.com/document/d/1frfPE5L1sI3737rdQV04IcDGeOcGJj2ItjMg6z2SRH0/edit?usp%3Dsharing&sa=D&source=calendar&usd=2&usg=AOvVaw1pUVy7UN_2PMj8qJJcFm1U)). -We currently utilize the [#wg-serving](https://kubernetes.slack.com/?redir=%2Fmessages%2Fwg-serving) slack channel for communications. +We currently utilize the [#gateway-api-inference-extension](https://kubernetes.slack.com/?redir=%2Fmessages%2Fgateway-api-inference-extension) channel in Kubernetes Slack workspace for communications. Contributions are readily welcomed, follow the [dev guide](./docs/dev.md) to start contributing! From 5bc742583b455dd1609daec74d7136db95900c07 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Fri, 23 May 2025 08:12:35 +0300 Subject: [PATCH 45/53] Multi cycle scheduler (#862) * code review Signed-off-by: Nir Rozenbaum * minor change Signed-off-by: Nir Rozenbaum * add support for multi cycle scheduling Signed-off-by: Nir Rozenbaum * minor change Signed-off-by: Nir Rozenbaum * moved plugins under plugins dir Signed-off-by: Nir Rozenbaum * few more changes Signed-off-by: Nir Rozenbaum * moved RunCycle logic into SchedulerProfile Signed-off-by: Nir Rozenbaum * minor changes Signed-off-by: Nir Rozenbaum * linter Signed-off-by: Nir Rozenbaum * minor change in unit-test Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum --- cmd/epp/main.go | 19 +- pkg/epp/requestcontrol/director.go | 14 +- .../{plugins => framework}/plugins.go | 28 +- .../scheduling/framework/plugins/README.md | 15 + .../plugins/filter/decision_tree_filter.go | 14 +- .../plugins/filter/filter_test.go | 7 +- .../plugins/filter/least_kvcache_filter.go | 6 +- .../plugins/filter/least_queue_filter.go | 6 +- .../plugins/filter/lora_affinity_filter.go | 6 +- .../plugins/filter/low_queue_filter.go | 6 +- .../filter/sheddable_capacity_filter.go | 6 +- .../plugins/multi/prefix/indexer.go | 0 .../plugins/multi/prefix/indexer_test.go | 0 .../plugins/multi/prefix/plugin.go | 22 +- .../plugins/multi/prefix/plugin_test.go | 20 +- .../plugins/picker/max_score_picker.go | 6 +- .../plugins/picker/random_picker.go | 6 +- .../profile-picker/all_profiles_picker.go | 48 +++ .../{ => framework}/plugins/scorer/kvcache.go | 6 +- .../plugins/scorer/kvcache_test.go | 0 .../{ => framework}/plugins/scorer/queue.go | 6 +- .../plugins/scorer/queue_test.go | 0 .../scheduling/framework/scheduler_profile.go | 218 +++++++++++ .../framework/scheduler_profile_test.go | 284 +++++++++++++++ .../scorer => framework}/weighted_scorer.go | 10 +- pkg/epp/scheduling/plugins/README.md | 16 - pkg/epp/scheduling/scheduler.go | 150 ++------ pkg/epp/scheduling/scheduler_config.go | 102 +----- pkg/epp/scheduling/scheduler_test.go | 341 ++---------------- test/integration/epp/hermetic_test.go | 2 +- 30 files changed, 752 insertions(+), 612 deletions(-) rename pkg/epp/scheduling/{plugins => framework}/plugins.go (66%) create mode 100644 pkg/epp/scheduling/framework/plugins/README.md rename pkg/epp/scheduling/{ => framework}/plugins/filter/decision_tree_filter.go (93%) rename pkg/epp/scheduling/{ => framework}/plugins/filter/filter_test.go (98%) rename pkg/epp/scheduling/{ => framework}/plugins/filter/least_kvcache_filter.go (96%) rename pkg/epp/scheduling/{ => framework}/plugins/filter/least_queue_filter.go (96%) rename pkg/epp/scheduling/{ => framework}/plugins/filter/lora_affinity_filter.go (97%) rename pkg/epp/scheduling/{ => framework}/plugins/filter/low_queue_filter.go (95%) rename pkg/epp/scheduling/{ => framework}/plugins/filter/sheddable_capacity_filter.go (95%) rename pkg/epp/scheduling/{ => framework}/plugins/multi/prefix/indexer.go (100%) rename pkg/epp/scheduling/{ => framework}/plugins/multi/prefix/indexer_test.go (100%) rename pkg/epp/scheduling/{ => framework}/plugins/multi/prefix/plugin.go (93%) rename pkg/epp/scheduling/{ => framework}/plugins/multi/prefix/plugin_test.go (92%) rename pkg/epp/scheduling/{ => framework}/plugins/picker/max_score_picker.go (96%) rename pkg/epp/scheduling/{ => framework}/plugins/picker/random_picker.go (94%) create mode 100644 pkg/epp/scheduling/framework/plugins/profile-picker/all_profiles_picker.go rename pkg/epp/scheduling/{ => framework}/plugins/scorer/kvcache.go (93%) rename pkg/epp/scheduling/{ => framework}/plugins/scorer/kvcache_test.go (100%) rename pkg/epp/scheduling/{ => framework}/plugins/scorer/queue.go (96%) rename pkg/epp/scheduling/{ => framework}/plugins/scorer/queue_test.go (100%) create mode 100644 pkg/epp/scheduling/framework/scheduler_profile.go create mode 100644 pkg/epp/scheduling/framework/scheduler_profile_test.go rename pkg/epp/scheduling/{plugins/scorer => framework}/weighted_scorer.go (82%) delete mode 100644 pkg/epp/scheduling/plugins/README.md diff --git a/cmd/epp/main.go b/cmd/epp/main.go index 1f707666a..e023d6727 100644 --- a/cmd/epp/main.go +++ b/cmd/epp/main.go @@ -41,10 +41,12 @@ import ( "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics/collectors" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/filter" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/multi/prefix" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/picker" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/scorer" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework/plugins/filter" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework/plugins/multi/prefix" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework/plugins/picker" + profilepicker "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework/plugins/profile-picker" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework/plugins/scorer" runserver "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/server" envutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/env" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" @@ -202,20 +204,21 @@ func run() error { queueScorerWeight := envutil.GetEnvInt("QUEUE_SCORE_WEIGHT", scorer.DefaultQueueScorerWeight, setupLog) kvCacheScorerWeight := envutil.GetEnvInt("KV_CACHE_SCORE_WEIGHT", scorer.DefaultKVCacheScorerWeight, setupLog) - schedulerConfig := scheduling.NewSchedulerConfig(). + schedulerProfile := framework.NewSchedulerProfile(). WithFilters(filter.NewSheddableCapacityFilter()). - WithScorers(scorer.NewWeightedScorer(&scorer.QueueScorer{}, queueScorerWeight), - scorer.NewWeightedScorer(&scorer.KVCacheScorer{}, kvCacheScorerWeight)). + WithScorers(framework.NewWeightedScorer(&scorer.QueueScorer{}, queueScorerWeight), + framework.NewWeightedScorer(&scorer.KVCacheScorer{}, kvCacheScorerWeight)). WithPicker(picker.NewMaxScorePicker()) if prefixCacheScheduling == "true" { prefixScorerWeight := envutil.GetEnvInt("PREFIX_CACHE_SCORE_WEIGHT", prefix.DefaultScorerWeight, setupLog) - if err := schedulerConfig.AddPlugins(scorer.NewWeightedScorer(prefix.New(loadPrefixCacheConfig()), prefixScorerWeight)); err != nil { + if err := schedulerProfile.AddPlugins(framework.NewWeightedScorer(prefix.New(loadPrefixCacheConfig()), prefixScorerWeight)); err != nil { setupLog.Error(err, "Failed to register scheduler plugins") return err } } + schedulerConfig := scheduling.NewSchedulerConfig(profilepicker.NewAllProfilesPicker(), map[string]*framework.SchedulerProfile{"schedulerv2": schedulerProfile}) scheduler = scheduling.NewSchedulerWithConfig(datastore, schedulerConfig) } serverRunner := &runserver.ExtProcServerRunner{ diff --git a/pkg/epp/requestcontrol/director.go b/pkg/epp/requestcontrol/director.go index 85c8ee34f..78daf0d92 100644 --- a/pkg/epp/requestcontrol/director.go +++ b/pkg/epp/requestcontrol/director.go @@ -35,7 +35,7 @@ import ( ) type Scheduler interface { - Schedule(ctx context.Context, b *schedulingtypes.LLMRequest) (result *schedulingtypes.Result, err error) + Schedule(ctx context.Context, b *schedulingtypes.LLMRequest) (result map[string]*schedulingtypes.Result, err error) OnResponse(ctx context.Context, resp *schedulingtypes.LLMResponse, targetPodName string) } @@ -108,23 +108,27 @@ func (d *Director) HandleRequest(ctx context.Context, reqCtx *handlers.RequestCo } // Dispatch runs one or many scheduling cycles. -func (d *Director) Dispatch(ctx context.Context, llmReq *schedulingtypes.LLMRequest) ([]*schedulingtypes.Result, error) { +func (d *Director) Dispatch(ctx context.Context, llmReq *schedulingtypes.LLMRequest) (map[string]*schedulingtypes.Result, error) { var err error res, err := d.scheduler.Schedule(ctx, llmReq) if err != nil { return nil, errutil.Error{Code: errutil.InferencePoolResourceExhausted, Msg: fmt.Errorf("failed to find target pod: %w", err).Error()} } - return []*schedulingtypes.Result{res}, nil + return res, nil // TODO handle multi cycle result after defining the PostDispatch extension point } -func (d *Director) PostDispatch(ctx context.Context, reqCtx *handlers.RequestContext, results []*schedulingtypes.Result) (*handlers.RequestContext, error) { +func (d *Director) PostDispatch(ctx context.Context, reqCtx *handlers.RequestContext, results map[string]*schedulingtypes.Result) (*handlers.RequestContext, error) { logger := log.FromContext(ctx) // currently only get a single result. Will refactor to pluggably implement the PostSchedule if len(results) == 0 { return reqCtx, errutil.Error{Code: errutil.Internal, Msg: "results must be greater than zero"} } - targetPod := results[0].TargetPod.GetPod() + var targetPod *backend.Pod + // TODO should handle multi cycle results, this should be pluggable logic + for _, result := range results { + targetPod = result.TargetPod.GetPod() + } pool, err := d.datastore.PoolGet() if err != nil { diff --git a/pkg/epp/scheduling/plugins/plugins.go b/pkg/epp/scheduling/framework/plugins.go similarity index 66% rename from pkg/epp/scheduling/plugins/plugins.go rename to pkg/epp/scheduling/framework/plugins.go index f3412ab72..99c8389fa 100644 --- a/pkg/epp/scheduling/plugins/plugins.go +++ b/pkg/epp/scheduling/framework/plugins.go @@ -14,18 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -package plugins +package framework import ( "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" ) const ( - PreSchedulerPluginType = "PreSchedule" + ProfilePickerType = "ProfilePicker" + PreCyclePluginType = "PreCycle" FilterPluginType = "Filter" ScorerPluginType = "Scorer" - PostSchedulePluginType = "PostSchedule" PickerPluginType = "Picker" + PostCyclePluginType = "PostCycle" PostResponsePluginType = "PostResponse" ) @@ -36,11 +37,18 @@ type Plugin interface { Name() string } -// PreSchedule is called when the scheduler receives a new request. It can be used for various -// initialization work. -type PreSchedule interface { +// ProfilePicker selects the SchedulingProfiles to run from a list of candidate profiles, while taking into consideration the request properties +// and the previously executed SchedluderProfile cycles along with their results. +type ProfilePicker interface { Plugin - PreSchedule(ctx *types.SchedulingContext) + Pick(request *types.LLMRequest, profiles map[string]*SchedulerProfile, executionResults map[string]*types.Result) map[string]*SchedulerProfile +} + +// PreCycle is called when the scheduler receives a new request and invokes a SchedulerProfile cycle. +// It can be used for various initialization work. +type PreCycle interface { + Plugin + PreCycle(ctx *types.SchedulingContext) } // Filter defines the interface for filtering a list of pods based on context. @@ -62,10 +70,10 @@ type Picker interface { Pick(ctx *types.SchedulingContext, scoredPods []*types.ScoredPod) *types.Result } -// PostSchedule is called by the scheduler after it selects a targetPod for the request. -type PostSchedule interface { +// PostCycle is called by the scheduler after it selects a targetPod for the request in the SchedulerProfile cycle. +type PostCycle interface { Plugin - PostSchedule(ctx *types.SchedulingContext, res *types.Result) + PostCycle(ctx *types.SchedulingContext, res *types.Result) } // PostResponse is called by the scheduler after a successful response was sent. diff --git a/pkg/epp/scheduling/framework/plugins/README.md b/pkg/epp/scheduling/framework/plugins/README.md new file mode 100644 index 000000000..56ca315e6 --- /dev/null +++ b/pkg/epp/scheduling/framework/plugins/README.md @@ -0,0 +1,15 @@ +# Scheduling Plugins + +This package contains the scheduling plugin implementations. + +Plugins are organized by the following rule. Follow this rule when adding a new +plugin. + +``` +plugins/ +|__ filter/(Plugins that implement the Filter interface only.) +|__ scorer/ (Plugins that implement the Scorer interface only.) +|__ picker/(Plugins that implement the Picker interface only.) +|__ multi/ (Plugins that implement multiple plugin interfaces.) +|____prefix/ (Prefix cache aware scheduling plugin.) +``` diff --git a/pkg/epp/scheduling/plugins/filter/decision_tree_filter.go b/pkg/epp/scheduling/framework/plugins/filter/decision_tree_filter.go similarity index 93% rename from pkg/epp/scheduling/plugins/filter/decision_tree_filter.go rename to pkg/epp/scheduling/framework/plugins/filter/decision_tree_filter.go index 066a90d69..9baec0907 100644 --- a/pkg/epp/scheduling/plugins/filter/decision_tree_filter.go +++ b/pkg/epp/scheduling/framework/plugins/filter/decision_tree_filter.go @@ -17,31 +17,31 @@ limitations under the License. package filter import ( - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) -// compile-time type validation -var _ plugins.Filter = &DecisionTreeFilter{} +// compile-time type assertion +var _ framework.Filter = &DecisionTreeFilter{} // DecisionTreeFilter applies current fitler, and then recursively applies next filters // depending success or failure of the current filter. // It can be used to construct a flow chart algorithm. type DecisionTreeFilter struct { - Current plugins.Filter + Current framework.Filter // NextOnSuccess filter will be applied after successfully applying the current filter. // The filtered results will be passed to the next filter. - NextOnSuccess plugins.Filter + NextOnSuccess framework.Filter // NextOnFailure filter will be applied if current filter results in no pods. // The original input will be passed to the next filter. - NextOnFailure plugins.Filter + NextOnFailure framework.Filter // NextOnSuccessOrFailure is a convenience field to configure the next filter regardless of the // success or failure of the current filter. // NOTE: When using NextOnSuccessOrFailure, both nextOnSuccess and nextOnFailure SHOULD be nil. // However if that's not the case, nextOnSuccess and nextOnFailure will be used, instead of // NextOnSuccessOrFailure, in the success and failure scenarios, respectively. - NextOnSuccessOrFailure plugins.Filter + NextOnSuccessOrFailure framework.Filter } // Name returns the name of the filter. diff --git a/pkg/epp/scheduling/plugins/filter/filter_test.go b/pkg/epp/scheduling/framework/plugins/filter/filter_test.go similarity index 98% rename from pkg/epp/scheduling/plugins/filter/filter_test.go rename to pkg/epp/scheduling/framework/plugins/filter/filter_test.go index 3f844740a..e9064b5ec 100644 --- a/pkg/epp/scheduling/plugins/filter/filter_test.go +++ b/pkg/epp/scheduling/framework/plugins/filter/filter_test.go @@ -26,10 +26,13 @@ import ( "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/config" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" ) +// compile-time type assertion +var _ framework.Filter = &filterAll{} + type filterAll struct{} func (f *filterAll) Name() string { @@ -44,7 +47,7 @@ func TestFilter(t *testing.T) { tests := []struct { name string req *types.LLMRequest - filter plugins.Filter + filter framework.Filter input []types.Pod output []types.Pod }{ diff --git a/pkg/epp/scheduling/plugins/filter/least_kvcache_filter.go b/pkg/epp/scheduling/framework/plugins/filter/least_kvcache_filter.go similarity index 96% rename from pkg/epp/scheduling/plugins/filter/least_kvcache_filter.go rename to pkg/epp/scheduling/framework/plugins/filter/least_kvcache_filter.go index ee7d82486..32942272e 100644 --- a/pkg/epp/scheduling/plugins/filter/least_kvcache_filter.go +++ b/pkg/epp/scheduling/framework/plugins/filter/least_kvcache_filter.go @@ -19,12 +19,12 @@ package filter import ( "math" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" ) -// compile-time type validation -var _ plugins.Filter = &LeastKVCacheFilter{} +// compile-time type assertion +var _ framework.Filter = &LeastKVCacheFilter{} // NewLeastKVCacheFilter initializes a new LeastKVCacheFilter and returns its pointer. func NewLeastKVCacheFilter() *LeastKVCacheFilter { diff --git a/pkg/epp/scheduling/plugins/filter/least_queue_filter.go b/pkg/epp/scheduling/framework/plugins/filter/least_queue_filter.go similarity index 96% rename from pkg/epp/scheduling/plugins/filter/least_queue_filter.go rename to pkg/epp/scheduling/framework/plugins/filter/least_queue_filter.go index 7e0bfc32a..1f24d194e 100644 --- a/pkg/epp/scheduling/plugins/filter/least_queue_filter.go +++ b/pkg/epp/scheduling/framework/plugins/filter/least_queue_filter.go @@ -19,12 +19,12 @@ package filter import ( "math" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" ) -// compile-time type validation -var _ plugins.Filter = &LeastQueueFilter{} +// compile-time type assertion +var _ framework.Filter = &LeastQueueFilter{} // NewLeastQueueFilter initializes a new LeastQueueFilter and returns its pointer. func NewLeastQueueFilter() *LeastQueueFilter { diff --git a/pkg/epp/scheduling/plugins/filter/lora_affinity_filter.go b/pkg/epp/scheduling/framework/plugins/filter/lora_affinity_filter.go similarity index 97% rename from pkg/epp/scheduling/plugins/filter/lora_affinity_filter.go rename to pkg/epp/scheduling/framework/plugins/filter/lora_affinity_filter.go index bc1e55b03..d6110d09d 100644 --- a/pkg/epp/scheduling/plugins/filter/lora_affinity_filter.go +++ b/pkg/epp/scheduling/framework/plugins/filter/lora_affinity_filter.go @@ -21,12 +21,12 @@ import ( "time" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/config" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" ) -// compile-time type validation -var _ plugins.Filter = &LoraAffinityFilter{} +// compile-time type assertion +var _ framework.Filter = &LoraAffinityFilter{} // NewLoraAffinityFilter initializes a new LoraAffinityFilter and returns its pointer. func NewLoraAffinityFilter() *LoraAffinityFilter { diff --git a/pkg/epp/scheduling/plugins/filter/low_queue_filter.go b/pkg/epp/scheduling/framework/plugins/filter/low_queue_filter.go similarity index 95% rename from pkg/epp/scheduling/plugins/filter/low_queue_filter.go rename to pkg/epp/scheduling/framework/plugins/filter/low_queue_filter.go index ce7d3523a..fdff41f26 100644 --- a/pkg/epp/scheduling/plugins/filter/low_queue_filter.go +++ b/pkg/epp/scheduling/framework/plugins/filter/low_queue_filter.go @@ -18,12 +18,12 @@ package filter import ( "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/config" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" ) -// compile-time type validation -var _ plugins.Filter = &LowQueueFilter{} +// compile-time type assertion +var _ framework.Filter = &LowQueueFilter{} // NewLowQueueFilter initializes a new LowQueueFilter and returns its pointer. func NewLowQueueFilter() *LowQueueFilter { diff --git a/pkg/epp/scheduling/plugins/filter/sheddable_capacity_filter.go b/pkg/epp/scheduling/framework/plugins/filter/sheddable_capacity_filter.go similarity index 95% rename from pkg/epp/scheduling/plugins/filter/sheddable_capacity_filter.go rename to pkg/epp/scheduling/framework/plugins/filter/sheddable_capacity_filter.go index cdc3355fd..555de2c0e 100644 --- a/pkg/epp/scheduling/plugins/filter/sheddable_capacity_filter.go +++ b/pkg/epp/scheduling/framework/plugins/filter/sheddable_capacity_filter.go @@ -18,12 +18,12 @@ package filter import ( "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/config" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" ) -// compile-time type validation -var _ plugins.Filter = &SheddableCapacityFilter{} +// compile-time type assertion +var _ framework.Filter = &SheddableCapacityFilter{} // NewSheddableCapacityFilter initializes a new SheddableCapacityFilter and returns its pointer. func NewSheddableCapacityFilter() *SheddableCapacityFilter { diff --git a/pkg/epp/scheduling/plugins/multi/prefix/indexer.go b/pkg/epp/scheduling/framework/plugins/multi/prefix/indexer.go similarity index 100% rename from pkg/epp/scheduling/plugins/multi/prefix/indexer.go rename to pkg/epp/scheduling/framework/plugins/multi/prefix/indexer.go diff --git a/pkg/epp/scheduling/plugins/multi/prefix/indexer_test.go b/pkg/epp/scheduling/framework/plugins/multi/prefix/indexer_test.go similarity index 100% rename from pkg/epp/scheduling/plugins/multi/prefix/indexer_test.go rename to pkg/epp/scheduling/framework/plugins/multi/prefix/indexer_test.go diff --git a/pkg/epp/scheduling/plugins/multi/prefix/plugin.go b/pkg/epp/scheduling/framework/plugins/multi/prefix/plugin.go similarity index 93% rename from pkg/epp/scheduling/plugins/multi/prefix/plugin.go rename to pkg/epp/scheduling/framework/plugins/multi/prefix/plugin.go index b07f085db..c3a28592e 100644 --- a/pkg/epp/scheduling/plugins/multi/prefix/plugin.go +++ b/pkg/epp/scheduling/framework/plugins/multi/prefix/plugin.go @@ -23,7 +23,7 @@ import ( "github.com/cespare/xxhash/v2" k8stypes "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) @@ -88,7 +88,7 @@ func (s ServerID) String() string { return k8stypes.NamespacedName(s).String() } -// compile-time type validation +// compile-time type assertion var _ types.StateData = &schedulingContextState{} // This is the state of this plugin to be used during a scheduling cycle. @@ -113,10 +113,10 @@ func (s *schedulingContextState) Clone() types.StateData { } } -// compile-time type validation -var _ plugins.PreSchedule = &Plugin{} -var _ plugins.Scorer = &Plugin{} -var _ plugins.PostSchedule = &Plugin{} +// compile-time type assertion +var _ framework.PreCycle = &Plugin{} +var _ framework.Scorer = &Plugin{} +var _ framework.PostCycle = &Plugin{} // New initializes a new prefix Plugin and returns its pointer. func New(config Config) *Plugin { @@ -132,8 +132,8 @@ func (m *Plugin) Name() string { return "prefix-cache" } -// PreSchedule initializes the prefix plugin state for the current scheduling cycle. -func (m *Plugin) PreSchedule(ctx *types.SchedulingContext) { +// PreCycle initializes the prefix plugin state for the current scheduling cycle. +func (m *Plugin) PreCycle(ctx *types.SchedulingContext) { hashes := hashPrompt(ctx, m.HashBlockSize, m.MaxPrefixBlocksToMatch) state := &schedulingContextState{ PrefixHashes: hashes, @@ -141,11 +141,11 @@ func (m *Plugin) PreSchedule(ctx *types.SchedulingContext) { } ctx.CycleState.Write(types.StateKey(m.Name()), state) - ctx.Logger.V(logutil.TRACE).Info(fmt.Sprintf("PreSchedule, cached servers: %+v", state.PrefixCacheServers), "hashes", state.PrefixHashes) + ctx.Logger.V(logutil.TRACE).Info(fmt.Sprintf("PreCycle, cached servers: %+v", state.PrefixCacheServers), "hashes", state.PrefixHashes) } -// PostSchedule records in the plugin cache the result of the scheduling selection. -func (m *Plugin) PostSchedule(ctx *types.SchedulingContext, res *types.Result) { +// PostCycle records in the plugin cache the result of the scheduling selection. +func (m *Plugin) PostCycle(ctx *types.SchedulingContext, res *types.Result) { targetPod := res.TargetPod.GetPod() state, err := m.getPrefixState(ctx.CycleState) if err != nil { diff --git a/pkg/epp/scheduling/plugins/multi/prefix/plugin_test.go b/pkg/epp/scheduling/framework/plugins/multi/prefix/plugin_test.go similarity index 92% rename from pkg/epp/scheduling/plugins/multi/prefix/plugin_test.go rename to pkg/epp/scheduling/framework/plugins/multi/prefix/plugin_test.go index 7e4e218ec..65e324c99 100644 --- a/pkg/epp/scheduling/plugins/multi/prefix/plugin_test.go +++ b/pkg/epp/scheduling/framework/plugins/multi/prefix/plugin_test.go @@ -28,7 +28,7 @@ func TestPrefixPlugin(t *testing.T) { Prompt: "aaaaaa", } ctx := types.NewSchedulingContext(context.Background(), req1, nil, pods) - plugin.PreSchedule(ctx) + plugin.PreCycle(ctx) state, err := plugin.getPrefixState(ctx.CycleState) assert.NoError(t, err) t.Logf("Hashes %+v, cached servers: %+v", state.PrefixHashes, state.PrefixCacheServers) @@ -43,7 +43,7 @@ func TestPrefixPlugin(t *testing.T) { assert.Equal(t, float64(0), scores[pod2], "score for pod2") // Simulate pod1 was picked. - plugin.PostSchedule(ctx, &types.Result{TargetPod: pod1}) + plugin.PostCycle(ctx, &types.Result{TargetPod: pod1}) // Second request doesn't share any prefix with first one. It should be added to the cache but // the pod score should be 0. @@ -52,7 +52,7 @@ func TestPrefixPlugin(t *testing.T) { Prompt: "bbbbbb", } ctx = types.NewSchedulingContext(context.Background(), req2, nil, pods) - plugin.PreSchedule(ctx) + plugin.PreCycle(ctx) state, err = plugin.getPrefixState(ctx.CycleState) assert.NoError(t, err) t.Logf("Hashes %+v, cached servers: %+v", state.PrefixHashes, state.PrefixCacheServers) @@ -67,7 +67,7 @@ func TestPrefixPlugin(t *testing.T) { assert.Equal(t, float64(0), scores[pod2], "score for pod2") // Simulate pod2 was picked. - plugin.PostSchedule(ctx, &types.Result{TargetPod: pod2}) + plugin.PostCycle(ctx, &types.Result{TargetPod: pod2}) // Third request shares partial prefix with first one. req3 := &types.LLMRequest{ @@ -75,7 +75,7 @@ func TestPrefixPlugin(t *testing.T) { Prompt: "aaaabbbb", } ctx = types.NewSchedulingContext(context.Background(), req3, nil, pods) - plugin.PreSchedule(ctx) + plugin.PreCycle(ctx) state, err = plugin.getPrefixState(ctx.CycleState) assert.NoError(t, err) t.Logf("Hashes %+v, cached servers: %+v", state.PrefixHashes, state.PrefixCacheServers) @@ -89,7 +89,7 @@ func TestPrefixPlugin(t *testing.T) { assert.Equal(t, float64(2)/float64(3), scores[pod1], "score should be 2/3 - the model and the first prefix block match") assert.Equal(t, float64(0), scores[pod2], "score for pod2") - plugin.PostSchedule(ctx, &types.Result{TargetPod: pod1}) + plugin.PostCycle(ctx, &types.Result{TargetPod: pod1}) // 4th request is same as req3 except the model is different, still no match. req4 := &types.LLMRequest{ @@ -97,7 +97,7 @@ func TestPrefixPlugin(t *testing.T) { Prompt: "aaaabbbb", } ctx = types.NewSchedulingContext(context.Background(), req4, nil, pods) - plugin.PreSchedule(ctx) + plugin.PreCycle(ctx) state, err = plugin.getPrefixState(ctx.CycleState) assert.NoError(t, err) t.Logf("Hashes %+v, cached servers: %+v", state.PrefixHashes, state.PrefixCacheServers) @@ -111,7 +111,7 @@ func TestPrefixPlugin(t *testing.T) { assert.Equal(t, float64(0), scores[pod1], "score for pod1") assert.Equal(t, float64(0), scores[pod2], "score for pod2") - plugin.PostSchedule(ctx, &types.Result{TargetPod: pod1}) + plugin.PostCycle(ctx, &types.Result{TargetPod: pod1}) // 5th request shares partial prefix with 3rd one. req5 := &types.LLMRequest{ @@ -119,7 +119,7 @@ func TestPrefixPlugin(t *testing.T) { Prompt: "aaaabbbbcccc", } ctx = types.NewSchedulingContext(context.Background(), req5, nil, pods) - plugin.PreSchedule(ctx) + plugin.PreCycle(ctx) state, err = plugin.getPrefixState(ctx.CycleState) assert.NoError(t, err) t.Logf("Hashes %+v, cached servers: %+v", state.PrefixHashes, state.PrefixCacheServers) @@ -133,5 +133,5 @@ func TestPrefixPlugin(t *testing.T) { assert.Equal(t, 0.75, scores[pod1], "score should be 0.75 - the model and the first 2 prefix blocks match") assert.Equal(t, float64(0), scores[pod2], "score for pod2") - plugin.PostSchedule(ctx, &types.Result{TargetPod: pod1}) + plugin.PostCycle(ctx, &types.Result{TargetPod: pod1}) } diff --git a/pkg/epp/scheduling/plugins/picker/max_score_picker.go b/pkg/epp/scheduling/framework/plugins/picker/max_score_picker.go similarity index 96% rename from pkg/epp/scheduling/plugins/picker/max_score_picker.go rename to pkg/epp/scheduling/framework/plugins/picker/max_score_picker.go index e85aee5fe..f206956c1 100644 --- a/pkg/epp/scheduling/plugins/picker/max_score_picker.go +++ b/pkg/epp/scheduling/framework/plugins/picker/max_score_picker.go @@ -19,13 +19,13 @@ package picker import ( "fmt" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) -// compile-time type validation -var _ plugins.Picker = &MaxScorePicker{} +// compile-time type assertion +var _ framework.Picker = &MaxScorePicker{} // NewMaxScorePicker initializes a new MaxScorePicker and returns its pointer. func NewMaxScorePicker() *MaxScorePicker { diff --git a/pkg/epp/scheduling/plugins/picker/random_picker.go b/pkg/epp/scheduling/framework/plugins/picker/random_picker.go similarity index 94% rename from pkg/epp/scheduling/plugins/picker/random_picker.go rename to pkg/epp/scheduling/framework/plugins/picker/random_picker.go index 1d12198ce..6a50da0f0 100644 --- a/pkg/epp/scheduling/plugins/picker/random_picker.go +++ b/pkg/epp/scheduling/framework/plugins/picker/random_picker.go @@ -20,13 +20,13 @@ import ( "fmt" "math/rand" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) -// compile-time type validation -var _ plugins.Picker = &RandomPicker{} +// compile-time type assertion +var _ framework.Picker = &RandomPicker{} // NewRandomPicker initializes a new RandomPicker and returns its pointer. func NewRandomPicker() *RandomPicker { diff --git a/pkg/epp/scheduling/framework/plugins/profile-picker/all_profiles_picker.go b/pkg/epp/scheduling/framework/plugins/profile-picker/all_profiles_picker.go new file mode 100644 index 000000000..eb9c74103 --- /dev/null +++ b/pkg/epp/scheduling/framework/plugins/profile-picker/all_profiles_picker.go @@ -0,0 +1,48 @@ +/* +Copyright 2025 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 profilepicker + +import ( + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" +) + +// compile-time type assertion +var _ framework.ProfilePicker = &AllProfilesPicker{} + +// NewAllProfilesPicker initializes a new AllProfilesPicker and returns its pointer. +func NewAllProfilesPicker() *AllProfilesPicker { + return &AllProfilesPicker{} +} + +// AllProfilesPicker picks all profiles always. +type AllProfilesPicker struct{} + +// Name returns the name of the Profiles Picker. +func (p *AllProfilesPicker) Name() string { + return "all-profiles" +} + +// Pick selects the SchedulingProfiles to run from the list of candidate profiles, while taking into consideration the request properties and the +// previously executed cycles along with their results. +func (p *AllProfilesPicker) Pick(request *types.LLMRequest, profiles map[string]*framework.SchedulerProfile, executionResults map[string]*types.Result) map[string]*framework.SchedulerProfile { + if len(profiles) == len(executionResults) { // all profiles have been executed already in previous call + return map[string]*framework.SchedulerProfile{} + } + // return all profiles + return profiles +} diff --git a/pkg/epp/scheduling/plugins/scorer/kvcache.go b/pkg/epp/scheduling/framework/plugins/scorer/kvcache.go similarity index 93% rename from pkg/epp/scheduling/plugins/scorer/kvcache.go rename to pkg/epp/scheduling/framework/plugins/scorer/kvcache.go index de6f87b0e..916de3d15 100644 --- a/pkg/epp/scheduling/plugins/scorer/kvcache.go +++ b/pkg/epp/scheduling/framework/plugins/scorer/kvcache.go @@ -17,7 +17,7 @@ limitations under the License. package scorer import ( - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" ) @@ -25,8 +25,8 @@ const ( DefaultKVCacheScorerWeight = 1 ) -// compile-time type validation -var _ plugins.Scorer = &KVCacheScorer{} +// compile-time type assertion +var _ framework.Scorer = &KVCacheScorer{} // KVCacheScorer scores list of candidate pods based on KV cache utilization. type KVCacheScorer struct{} diff --git a/pkg/epp/scheduling/plugins/scorer/kvcache_test.go b/pkg/epp/scheduling/framework/plugins/scorer/kvcache_test.go similarity index 100% rename from pkg/epp/scheduling/plugins/scorer/kvcache_test.go rename to pkg/epp/scheduling/framework/plugins/scorer/kvcache_test.go diff --git a/pkg/epp/scheduling/plugins/scorer/queue.go b/pkg/epp/scheduling/framework/plugins/scorer/queue.go similarity index 96% rename from pkg/epp/scheduling/plugins/scorer/queue.go rename to pkg/epp/scheduling/framework/plugins/scorer/queue.go index ac87f0b9e..93f0a4d4c 100644 --- a/pkg/epp/scheduling/plugins/scorer/queue.go +++ b/pkg/epp/scheduling/framework/plugins/scorer/queue.go @@ -19,7 +19,7 @@ package scorer import ( "math" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" ) @@ -27,8 +27,8 @@ const ( DefaultQueueScorerWeight = 1 ) -// compile-time type validation -var _ plugins.Scorer = &QueueScorer{} +// compile-time type assertion +var _ framework.Scorer = &QueueScorer{} // QueueScorer scores list of candidate pods based on the pod's waiting queue size. // the less waiting queue size the pod has, the higher score it will get (since it's more available to serve new request). diff --git a/pkg/epp/scheduling/plugins/scorer/queue_test.go b/pkg/epp/scheduling/framework/plugins/scorer/queue_test.go similarity index 100% rename from pkg/epp/scheduling/plugins/scorer/queue_test.go rename to pkg/epp/scheduling/framework/plugins/scorer/queue_test.go diff --git a/pkg/epp/scheduling/framework/scheduler_profile.go b/pkg/epp/scheduling/framework/scheduler_profile.go new file mode 100644 index 000000000..6809624fc --- /dev/null +++ b/pkg/epp/scheduling/framework/scheduler_profile.go @@ -0,0 +1,218 @@ +/* +Copyright 2025 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 framework + +import ( + "fmt" + "time" + + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" + errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/error" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" +) + +// NewSchedulerProfile creates a new SchedulerProfile object and returns its pointer. +func NewSchedulerProfile() *SchedulerProfile { + return &SchedulerProfile{ + preCyclePlugins: []PreCycle{}, + filters: []Filter{}, + scorers: []*WeightedScorer{}, + postCyclePlugins: []PostCycle{}, + PostResponsePlugins: []PostResponse{}, + // picker remains nil since profile doesn't support multiple pickers + } +} + +// SchedulerProfile provides a profile configuration for the scheduler which influence routing decisions. +type SchedulerProfile struct { + preCyclePlugins []PreCycle + filters []Filter + scorers []*WeightedScorer + picker Picker + postCyclePlugins []PostCycle + PostResponsePlugins []PostResponse // TODO this field should get out of the scheduler +} + +// WithPreCyclePlugins sets the given plugins as the PreCycle plugins. +// If the SchedulerProfile has PreCycle plugins, this call replaces the existing plugins with the given ones. +func (p *SchedulerProfile) WithPreCyclePlugins(plugins ...PreCycle) *SchedulerProfile { + p.preCyclePlugins = plugins + return p +} + +// WithFilters sets the given filter plugins as the Filter plugins. +// if the SchedulerProfile has Filter plugins, this call replaces the existing plugins with the given ones. +func (p *SchedulerProfile) WithFilters(filters ...Filter) *SchedulerProfile { + p.filters = filters + return p +} + +// WithScorers sets the given scorer plugins as the Scorer plugins. +// if the SchedulerProfile has Scorer plugins, this call replaces the existing plugins with the given ones. +func (p *SchedulerProfile) WithScorers(scorers ...*WeightedScorer) *SchedulerProfile { + p.scorers = scorers + return p +} + +// WithPicker sets the given picker plugins as the Picker plugin. +// if the SchedulerProfile has Picker plugin, this call replaces the existing plugin with the given one. +func (p *SchedulerProfile) WithPicker(picker Picker) *SchedulerProfile { + p.picker = picker + return p +} + +// WithPostCyclePlugins sets the given plugins as the PostCycle plugins. +// If the SchedulerProfile has PostCycle plugins, this call replaces the existing plugins with the given ones. +func (p *SchedulerProfile) WithPostCyclePlugins(plugins ...PostCycle) *SchedulerProfile { + p.postCyclePlugins = plugins + return p +} + +// AddPlugins adds the given plugins to all scheduler plugins according to the interfaces each plugin implements. +// A plugin may implement more than one scheduler plugin interface. +// Special Case: In order to add a scorer, one must use the scorer.NewWeightedScorer function in order to provide a weight. +// if a scorer implements more than one interface, supplying a WeightedScorer is sufficient. The function will take the internal +// scorer object and register it to all interfaces it implements. +func (p *SchedulerProfile) AddPlugins(pluginObjects ...Plugin) error { + for _, plugin := range pluginObjects { + if weightedScorer, ok := plugin.(*WeightedScorer); ok { + p.scorers = append(p.scorers, weightedScorer) + plugin = weightedScorer.Scorer // if we got WeightedScorer, unwrap the plugin + } else if scorer, ok := plugin.(Scorer); ok { // if we got a Scorer instead of WeightedScorer that's an error. + return fmt.Errorf("failed to register scorer '%s' without a weight. follow function documentation to register a scorer", scorer.Name()) + } + if preCyclePlugin, ok := plugin.(PreCycle); ok { + p.preCyclePlugins = append(p.preCyclePlugins, preCyclePlugin) + } + if filter, ok := plugin.(Filter); ok { + p.filters = append(p.filters, filter) + } + if picker, ok := plugin.(Picker); ok { + if p.picker != nil { + return fmt.Errorf("failed to set '%s' as picker, already have a registered picker plugin '%s'", picker.Name(), p.picker.Name()) + } + p.picker = picker + } + if postCyclePlugin, ok := plugin.(PostCycle); ok { + p.postCyclePlugins = append(p.postCyclePlugins, postCyclePlugin) + } + if postResponsePlugin, ok := plugin.(PostResponse); ok { + p.PostResponsePlugins = append(p.PostResponsePlugins, postResponsePlugin) + } + } + return nil +} + +// RunCycle runs a SchedulerProfile cycle. In other words, it invokes all the SchedulerProfile plugins in this +// order - PreCyclePlugins, Filters, Scorers, Picker, PostCyclePlugins. After completing all, it returns the result. +func (p *SchedulerProfile) RunCycle(ctx *types.SchedulingContext) (*types.Result, error) { + p.runPreCyclePlugins(ctx) + + pods := p.runFilterPlugins(ctx) + if len(pods) == 0 { + return nil, errutil.Error{Code: errutil.Internal, Msg: "no pods available for the given request"} + } + // if we got here, there is at least one pod to score + weightedScorePerPod := p.runScorerPlugins(ctx, pods) + + result := p.runPickerPlugin(ctx, weightedScorePerPod) + + p.runPostCyclePlugins(ctx, result) + + return result, nil +} + +func (p *SchedulerProfile) runPreCyclePlugins(ctx *types.SchedulingContext) { + for _, plugin := range p.preCyclePlugins { + ctx.Logger.V(logutil.DEBUG).Info("Running pre-cycle plugin", "plugin", plugin.Name()) + before := time.Now() + plugin.PreCycle(ctx) + metrics.RecordSchedulerPluginProcessingLatency(PreCyclePluginType, plugin.Name(), time.Since(before)) + } +} + +func (p *SchedulerProfile) runFilterPlugins(ctx *types.SchedulingContext) []types.Pod { + loggerDebug := ctx.Logger.V(logutil.DEBUG) + filteredPods := ctx.PodsSnapshot + loggerDebug.Info("Before running filter plugins", "pods", filteredPods) + + for _, filter := range p.filters { + loggerDebug.Info("Running filter plugin", "plugin", filter.Name()) + before := time.Now() + filteredPods = filter.Filter(ctx, filteredPods) + metrics.RecordSchedulerPluginProcessingLatency(FilterPluginType, filter.Name(), time.Since(before)) + loggerDebug.Info("Filter plugin result", "plugin", filter.Name(), "pods", filteredPods) + if len(filteredPods) == 0 { + break + } + } + loggerDebug.Info("After running filter plugins") + + return filteredPods +} + +func (p *SchedulerProfile) runScorerPlugins(ctx *types.SchedulingContext, pods []types.Pod) map[types.Pod]float64 { + loggerDebug := ctx.Logger.V(logutil.DEBUG) + loggerDebug.Info("Before running scorer plugins", "pods", pods) + + weightedScorePerPod := make(map[types.Pod]float64, len(pods)) + for _, pod := range pods { + weightedScorePerPod[pod] = float64(0) // initialize weighted score per pod with 0 value + } + // Iterate through each scorer in the chain and accumulate the weighted scores. + for _, scorer := range p.scorers { + loggerDebug.Info("Running scorer", "scorer", scorer.Name()) + before := time.Now() + scores := scorer.Score(ctx, pods) + metrics.RecordSchedulerPluginProcessingLatency(ScorerPluginType, scorer.Name(), time.Since(before)) + for pod, score := range scores { // weight is relative to the sum of weights + weightedScorePerPod[pod] += score * float64(scorer.Weight()) + } + loggerDebug.Info("After running scorer", "scorer", scorer.Name()) + } + loggerDebug.Info("After running scorer plugins") + + return weightedScorePerPod +} + +func (p *SchedulerProfile) runPickerPlugin(ctx *types.SchedulingContext, weightedScorePerPod map[types.Pod]float64) *types.Result { + loggerDebug := ctx.Logger.V(logutil.DEBUG) + scoredPods := make([]*types.ScoredPod, len(weightedScorePerPod)) + i := 0 + for pod, score := range weightedScorePerPod { + scoredPods[i] = &types.ScoredPod{Pod: pod, Score: score} + i++ + } + + loggerDebug.Info("Before running picker plugin", "pods weighted score", fmt.Sprint(weightedScorePerPod)) + before := time.Now() + result := p.picker.Pick(ctx, scoredPods) + metrics.RecordSchedulerPluginProcessingLatency(PickerPluginType, p.picker.Name(), time.Since(before)) + loggerDebug.Info("After running picker plugin", "result", result) + + return result +} + +func (p *SchedulerProfile) runPostCyclePlugins(ctx *types.SchedulingContext, res *types.Result) { + for _, plugin := range p.postCyclePlugins { + ctx.Logger.V(logutil.DEBUG).Info("Running post-cycle plugin", "plugin", plugin.Name()) + before := time.Now() + plugin.PostCycle(ctx, res) + metrics.RecordSchedulerPluginProcessingLatency(PostCyclePluginType, plugin.Name(), time.Since(before)) + } +} diff --git a/pkg/epp/scheduling/framework/scheduler_profile_test.go b/pkg/epp/scheduling/framework/scheduler_profile_test.go new file mode 100644 index 000000000..d212f6040 --- /dev/null +++ b/pkg/epp/scheduling/framework/scheduler_profile_test.go @@ -0,0 +1,284 @@ +/* +Copyright 2025 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 framework + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + k8stypes "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" + backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" // Import config for thresholds + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" +) + +func TestSchedulePlugins(t *testing.T) { + tp1 := &testPlugin{ + NameRes: "test1", + ScoreRes: 0.3, + FilterRes: []k8stypes.NamespacedName{{Name: "pod1"}, {Name: "pod2"}, {Name: "pod3"}}, + } + tp2 := &testPlugin{ + NameRes: "test2", + ScoreRes: 0.8, + FilterRes: []k8stypes.NamespacedName{{Name: "pod1"}, {Name: "pod2"}}, + } + tp_filterAll := &testPlugin{ + NameRes: "filter all", + FilterRes: []k8stypes.NamespacedName{}, + } + pickerPlugin := &testPlugin{ + NameRes: "picker", + PickRes: k8stypes.NamespacedName{Name: "pod1"}, + } + + tests := []struct { + name string + profile *SchedulerProfile + input []backendmetrics.PodMetrics + wantTargetPod k8stypes.NamespacedName + targetPodScore float64 + // Number of expected pods to score (after filter) + numPodsToScore int + err bool + }{ + { + name: "all plugins executed successfully, all scorers with same weight", + profile: NewSchedulerProfile(). + WithPreCyclePlugins(tp1, tp2). + WithFilters(tp1, tp2). + WithScorers(NewWeightedScorer(tp1, 1), NewWeightedScorer(tp2, 1)). + WithPicker(pickerPlugin). + WithPostCyclePlugins(tp1, tp2), + input: []backendmetrics.PodMetrics{ + &backendmetrics.FakePodMetrics{Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, + &backendmetrics.FakePodMetrics{Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}}, + &backendmetrics.FakePodMetrics{Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}}, + }, + wantTargetPod: k8stypes.NamespacedName{Name: "pod1"}, + targetPodScore: 1.1, + numPodsToScore: 2, + err: false, + }, + { + name: "all plugins executed successfully, different scorers weights", + profile: NewSchedulerProfile(). + WithPreCyclePlugins(tp1, tp2). + WithFilters(tp1, tp2). + WithScorers(NewWeightedScorer(tp1, 60), NewWeightedScorer(tp2, 40)). + WithPicker(pickerPlugin). + WithPostCyclePlugins(tp1, tp2), + input: []backendmetrics.PodMetrics{ + &backendmetrics.FakePodMetrics{Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, + &backendmetrics.FakePodMetrics{Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}}, + &backendmetrics.FakePodMetrics{Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}}, + }, + wantTargetPod: k8stypes.NamespacedName{Name: "pod1"}, + targetPodScore: 50, + numPodsToScore: 2, + err: false, + }, + { + name: "filter all", + profile: NewSchedulerProfile(). + WithPreCyclePlugins(tp1, tp2). + WithFilters(tp1, tp_filterAll). + WithScorers(NewWeightedScorer(tp1, 1), NewWeightedScorer(tp2, 1)). + WithPicker(pickerPlugin). + WithPostCyclePlugins(tp1, tp2), + input: []backendmetrics.PodMetrics{ + &backendmetrics.FakePodMetrics{Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, + &backendmetrics.FakePodMetrics{Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}}, + &backendmetrics.FakePodMetrics{Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}}, + }, + numPodsToScore: 0, + err: true, // no available pods to server after filter all + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Reset all plugins before each new test case. + for _, plugin := range test.profile.preCyclePlugins { + plugin.(*testPlugin).reset() + } + for _, plugin := range test.profile.filters { + plugin.(*testPlugin).reset() + } + for _, plugin := range test.profile.scorers { + plugin.Scorer.(*testPlugin).reset() + } + test.profile.picker.(*testPlugin).reset() + for _, plugin := range test.profile.postCyclePlugins { + plugin.(*testPlugin).reset() + } + + // Initialize the scheduling context + req := &types.LLMRequest{ + TargetModel: "test-model", + RequestId: uuid.NewString(), + } + schedulingContext := types.NewSchedulingContext(context.Background(), req, nil, types.ToSchedulerPodMetrics(test.input)) + // Run profile cycle + got, err := test.profile.RunCycle(schedulingContext) + + // Validate error state + if test.err != (err != nil) { + t.Fatalf("Unexpected error, got %v, want %v", err, test.err) + } + + if err != nil { + return + } + + // Validate output + wantPod := &types.PodMetrics{ + Pod: &backend.Pod{NamespacedName: test.wantTargetPod, Labels: make(map[string]string)}, + } + wantRes := &types.Result{ + TargetPod: wantPod, + } + + if diff := cmp.Diff(wantRes, got); diff != "" { + t.Errorf("Unexpected output (-want +got): %v", diff) + } + // Validate plugin execution counts dynamically + for _, plugin := range test.profile.preCyclePlugins { + tp, _ := plugin.(*testPlugin) + if tp.PreScheduleCallCount != 1 { + t.Errorf("Plugin %s PreSchedule() called %d times, expected 1", plugin.Name(), tp.PreScheduleCallCount) + } + } + for _, plugin := range test.profile.filters { + tp, _ := plugin.(*testPlugin) + if tp.FilterCallCount != 1 { + t.Errorf("Plugin %s Filter() called %d times, expected 1", plugin.Name(), tp.FilterCallCount) + } + } + for _, plugin := range test.profile.scorers { + tp, _ := plugin.Scorer.(*testPlugin) + if tp.ScoreCallCount != 1 { + t.Errorf("Plugin %s Score() called %d times, expected 1", plugin.Name(), tp.ScoreCallCount) + } + if test.numPodsToScore != tp.NumOfScoredPods { + t.Errorf("Plugin %s Score() called with %d pods, expected %d", plugin.Name(), tp.NumOfScoredPods, test.numPodsToScore) + } + } + tp, _ := test.profile.picker.(*testPlugin) + if tp.NumOfPickerCandidates != test.numPodsToScore { + t.Errorf("Picker plugin %s Pick() called with %d candidates, expected %d", tp.Name(), tp.NumOfPickerCandidates, tp.NumOfScoredPods) + } + if tp.PickCallCount != 1 { + t.Errorf("Picker plugin %s Pick() called %d times, expected 1", tp.Name(), tp.PickCallCount) + } + if tp.WinnderPodScore != test.targetPodScore { + t.Errorf("winnder pod score %v, expected %v", tp.WinnderPodScore, test.targetPodScore) + } + for _, plugin := range test.profile.postCyclePlugins { + tp, _ := plugin.(*testPlugin) + if tp.PostScheduleCallCount != 1 { + t.Errorf("Plugin %s PostSchedule() called %d times, expected 1", plugin.Name(), tp.PostScheduleCallCount) + } + } + }) + } +} + +// testPlugin is an implementation useful in unit tests. +type testPlugin struct { + NameRes string + ScoreCallCount int + NumOfScoredPods int + ScoreRes float64 + FilterCallCount int + FilterRes []k8stypes.NamespacedName + PreScheduleCallCount int + PostScheduleCallCount int + PickCallCount int + NumOfPickerCandidates int + PickRes k8stypes.NamespacedName + WinnderPodScore float64 +} + +func (tp *testPlugin) Name() string { return tp.NameRes } + +func (tp *testPlugin) PreCycle(ctx *types.SchedulingContext) { + tp.PreScheduleCallCount++ +} + +func (tp *testPlugin) Filter(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { + tp.FilterCallCount++ + return findPods(ctx, tp.FilterRes...) + +} + +func (tp *testPlugin) Score(ctx *types.SchedulingContext, pods []types.Pod) map[types.Pod]float64 { + tp.ScoreCallCount++ + scoredPods := make(map[types.Pod]float64, len(pods)) + for _, pod := range pods { + scoredPods[pod] += tp.ScoreRes + } + tp.NumOfScoredPods = len(scoredPods) + return scoredPods +} + +func (tp *testPlugin) Pick(ctx *types.SchedulingContext, scoredPods []*types.ScoredPod) *types.Result { + tp.PickCallCount++ + tp.NumOfPickerCandidates = len(scoredPods) + pod := findPods(ctx, tp.PickRes)[0] + tp.WinnderPodScore = getPodScore(scoredPods, pod) + return &types.Result{TargetPod: pod} +} + +func (tp *testPlugin) PostCycle(ctx *types.SchedulingContext, res *types.Result) { + tp.PostScheduleCallCount++ +} + +func (tp *testPlugin) reset() { + tp.PreScheduleCallCount = 0 + tp.FilterCallCount = 0 + tp.ScoreCallCount = 0 + tp.NumOfScoredPods = 0 + tp.PostScheduleCallCount = 0 + tp.PickCallCount = 0 + tp.NumOfPickerCandidates = 0 +} + +func findPods(ctx *types.SchedulingContext, names ...k8stypes.NamespacedName) []types.Pod { + res := []types.Pod{} + for _, pod := range ctx.PodsSnapshot { + for _, name := range names { + if pod.GetPod().NamespacedName.String() == name.String() { + res = append(res, pod) + } + } + } + return res +} + +func getPodScore(scoredPods []*types.ScoredPod, selectedPod types.Pod) float64 { + finalScore := 0.0 + for _, scoredPod := range scoredPods { + if scoredPod.GetPod().NamespacedName.String() == selectedPod.GetPod().NamespacedName.String() { + finalScore = scoredPod.Score + break + } + } + return finalScore +} diff --git a/pkg/epp/scheduling/plugins/scorer/weighted_scorer.go b/pkg/epp/scheduling/framework/weighted_scorer.go similarity index 82% rename from pkg/epp/scheduling/plugins/scorer/weighted_scorer.go rename to pkg/epp/scheduling/framework/weighted_scorer.go index 1e83b6c83..3b8d80a42 100644 --- a/pkg/epp/scheduling/plugins/scorer/weighted_scorer.go +++ b/pkg/epp/scheduling/framework/weighted_scorer.go @@ -14,14 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -package scorer - -import ( - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" -) +package framework // NewWeightedScorer initializes a new WeightedScorer and returns its pointer. -func NewWeightedScorer(scorer plugins.Scorer, weight int) *WeightedScorer { +func NewWeightedScorer(scorer Scorer, weight int) *WeightedScorer { return &WeightedScorer{ Scorer: scorer, weight: weight, @@ -30,7 +26,7 @@ func NewWeightedScorer(scorer plugins.Scorer, weight int) *WeightedScorer { // WeightedScorer is a struct that encapsulates a scorer with its weight. type WeightedScorer struct { - plugins.Scorer + Scorer weight int } diff --git a/pkg/epp/scheduling/plugins/README.md b/pkg/epp/scheduling/plugins/README.md deleted file mode 100644 index 80118ddd9..000000000 --- a/pkg/epp/scheduling/plugins/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# Scheduling Plugins - -This package contains the scheduling plugin interface definitions and -implementations. - -Plugins are organized by the following rule. Follow this rule when adding a new -plugin. - -``` -plugins/ -|__ filter/(Plugins that only implement the Filter interface.) -|__ scorer/ (Plugins that only implement the Scorer interface.) -|__ picker/(Plugins that only implement the Picker interface.) -|__ multi/ (Plugins that implement multiple plugin interfaces.) -|____prefix/ (Prefix cache aware scheduling plugin.) -``` diff --git a/pkg/epp/scheduling/scheduler.go b/pkg/epp/scheduling/scheduler.go index 87591b2bc..f2ac743fc 100644 --- a/pkg/epp/scheduling/scheduler.go +++ b/pkg/epp/scheduling/scheduler.go @@ -25,12 +25,11 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/filter" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/picker" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/scorer" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework/plugins/filter" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework/plugins/picker" + profilepicker "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework/plugins/profile-picker" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" - errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/error" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) @@ -65,34 +64,28 @@ func NewScheduler(datastore Datastore) *Scheduler { }, } - defaultConfig := NewSchedulerConfig(). + defaultProfile := framework.NewSchedulerProfile(). WithFilters(filter.NewSheddableCapacityFilter(), lowLatencyFilter). WithPicker(&picker.RandomPicker{}) - return NewSchedulerWithConfig(datastore, defaultConfig) + profilePicker := profilepicker.NewAllProfilesPicker() + + return NewSchedulerWithConfig(datastore, NewSchedulerConfig(profilePicker, map[string]*framework.SchedulerProfile{"default": defaultProfile})) } // NewSchedulerWithConfig returns a new scheduler with the given scheduler plugins configuration. func NewSchedulerWithConfig(datastore Datastore, config *SchedulerConfig) *Scheduler { return &Scheduler{ - datastore: datastore, - preSchedulePlugins: config.preSchedulePlugins, - filters: config.filters, - scorers: config.scorers, - picker: config.picker, - postSchedulePlugins: config.postSchedulePlugins, - postResponsePlugins: config.postResponsePlugins, + datastore: datastore, + profilePicker: config.profilePicker, + profiles: config.profiles, } } type Scheduler struct { - datastore Datastore - preSchedulePlugins []plugins.PreSchedule - filters []plugins.Filter - scorers []*scorer.WeightedScorer - picker plugins.Picker - postSchedulePlugins []plugins.PostSchedule - postResponsePlugins []plugins.PostResponse + datastore Datastore + profilePicker framework.ProfilePicker + profiles map[string]*framework.SchedulerProfile } type Datastore interface { @@ -100,7 +93,7 @@ type Datastore interface { } // Schedule finds the target pod based on metrics and the requested lora adapter. -func (s *Scheduler) Schedule(ctx context.Context, req *types.LLMRequest) (*types.Result, error) { +func (s *Scheduler) Schedule(ctx context.Context, req *types.LLMRequest) (map[string]*types.Result, error) { logger := log.FromContext(ctx).WithValues("request", req) loggerDebug := logger.V(logutil.DEBUG) @@ -111,104 +104,36 @@ func (s *Scheduler) Schedule(ctx context.Context, req *types.LLMRequest) (*types // Snapshot pod metrics from the datastore to: // 1. Reduce concurrent access to the datastore. - // 2. Ensure consistent data during the scheduling operation of a request. + // 2. Ensure consistent data during the scheduling operation of a request between all scheduling cycles. sCtx := types.NewSchedulingContext(ctx, req, nil, types.ToSchedulerPodMetrics(s.datastore.PodGetAll())) loggerDebug.Info(fmt.Sprintf("Scheduling a request, Metrics: %+v", sCtx.PodsSnapshot)) - s.runPreSchedulePlugins(sCtx) - - pods := s.runFilterPlugins(sCtx) - if len(pods) == 0 { - return nil, errutil.Error{Code: errutil.Internal, Msg: "no pods available for the given request"} - } - // if we got here, there is at least one pod to score - weightedScorePerPod := s.runScorerPlugins(sCtx, pods) - - result := s.runPickerPlugin(sCtx, weightedScorePerPod) - - s.runPostSchedulePlugins(sCtx, result) - - return result, nil -} + profileExecutionResults := map[string]*types.Result{} -func (s *Scheduler) runPreSchedulePlugins(ctx *types.SchedulingContext) { - for _, plugin := range s.preSchedulePlugins { - ctx.Logger.V(logutil.DEBUG).Info("Running pre-schedule plugin", "plugin", plugin.Name()) + for { // get the next set of profiles to run iteratively based on the request and the previous execution results before := time.Now() - plugin.PreSchedule(ctx) - metrics.RecordSchedulerPluginProcessingLatency(plugins.PreSchedulerPluginType, plugin.Name(), time.Since(before)) - } -} - -func (s *Scheduler) runFilterPlugins(ctx *types.SchedulingContext) []types.Pod { - loggerDebug := ctx.Logger.V(logutil.DEBUG) - filteredPods := ctx.PodsSnapshot - loggerDebug.Info("Before running filter plugins", "pods", filteredPods) - - for _, filter := range s.filters { - loggerDebug.Info("Running filter plugin", "plugin", filter.Name()) - before := time.Now() - filteredPods = filter.Filter(ctx, filteredPods) - metrics.RecordSchedulerPluginProcessingLatency(plugins.FilterPluginType, filter.Name(), time.Since(before)) - loggerDebug.Info("Filter plugin result", "plugin", filter.Name(), "pods", filteredPods) - if len(filteredPods) == 0 { + profiles := s.profilePicker.Pick(req, s.profiles, profileExecutionResults) + metrics.RecordSchedulerPluginProcessingLatency(framework.ProfilePickerType, s.profilePicker.Name(), time.Since(before)) + if len(profiles) == 0 { // profile picker didn't pick any profile to run break } - } - loggerDebug.Info("After running filter plugins") - return filteredPods -} - -func (s *Scheduler) runScorerPlugins(ctx *types.SchedulingContext, pods []types.Pod) map[types.Pod]float64 { - loggerDebug := ctx.Logger.V(logutil.DEBUG) - loggerDebug.Info("Before running scorer plugins", "pods", pods) + for name, profile := range profiles { + // run the selected profiles and collect results (current code runs all profiles) + profileExecutionResult, err := profile.RunCycle(sCtx) + if err != nil { + return nil, fmt.Errorf("failed to run all required scheduling profiles - %w", err) + } - weightedScorePerPod := make(map[types.Pod]float64, len(pods)) - for _, pod := range pods { - weightedScorePerPod[pod] = float64(0) // initialize weighted score per pod with 0 value - } - // Iterate through each scorer in the chain and accumulate the weighted scores. - for _, weightedScorer := range s.scorers { - loggerDebug.Info("Running scorer", "scorer", weightedScorer.Name()) - before := time.Now() - scores := weightedScorer.Score(ctx, pods) - metrics.RecordSchedulerPluginProcessingLatency(plugins.ScorerPluginType, weightedScorer.Name(), time.Since(before)) - for pod, score := range scores { // weight is relative to the sum of weights - weightedScorePerPod[pod] += score * float64(weightedScorer.Weight()) + profileExecutionResults[name] = profileExecutionResult } - loggerDebug.Info("After running scorer", "scorer", weightedScorer.Name()) } - loggerDebug.Info("After running scorer plugins") - - return weightedScorePerPod -} -func (s *Scheduler) runPickerPlugin(ctx *types.SchedulingContext, weightedScorePerPod map[types.Pod]float64) *types.Result { - loggerDebug := ctx.Logger.V(logutil.DEBUG) - scoredPods := make([]*types.ScoredPod, len(weightedScorePerPod)) - i := 0 - for pod, score := range weightedScorePerPod { - scoredPods[i] = &types.ScoredPod{Pod: pod, Score: score} - i++ + if len(profileExecutionResults) == 0 { + return nil, fmt.Errorf("failed to run any SchedulingProfile for the request - %s", req) } - loggerDebug.Info("Before running picker plugin", "pods weighted score", fmt.Sprint(weightedScorePerPod)) - before := time.Now() - result := s.picker.Pick(ctx, scoredPods) - metrics.RecordSchedulerPluginProcessingLatency(plugins.PickerPluginType, s.picker.Name(), time.Since(before)) - loggerDebug.Info("After running picker plugin", "result", result) - - return result -} - -func (s *Scheduler) runPostSchedulePlugins(ctx *types.SchedulingContext, res *types.Result) { - for _, plugin := range s.postSchedulePlugins { - ctx.Logger.V(logutil.DEBUG).Info("Running post-schedule plugin", "plugin", plugin.Name()) - before := time.Now() - plugin.PostSchedule(ctx, res) - metrics.RecordSchedulerPluginProcessingLatency(plugins.PostSchedulePluginType, plugin.Name(), time.Since(before)) - } + return profileExecutionResults, nil } // OnResponse is invoked during the processing of a response from an inference pod. It will invoke @@ -228,14 +153,19 @@ func (s *Scheduler) OnResponse(ctx context.Context, resp *types.LLMResponse, tar sCtx := types.NewSchedulingContext(ctx, nil, resp, pods) - s.runPostResponsePlugins(sCtx, targetPod) + // WORKAROUND until PostResponse is out of Scheduler + profileExecutionResults := map[string]*types.Result{} + profiles := s.profilePicker.Pick(nil, s.profiles, profileExecutionResults) // all profiles + for _, profile := range profiles { + s.runPostResponsePlugins(sCtx, targetPod, profile) + } } -func (s *Scheduler) runPostResponsePlugins(ctx *types.SchedulingContext, targetPod types.Pod) { - for _, plugin := range s.postResponsePlugins { +func (s *Scheduler) runPostResponsePlugins(ctx *types.SchedulingContext, targetPod types.Pod, profile *framework.SchedulerProfile) { + for _, plugin := range profile.PostResponsePlugins { ctx.Logger.V(logutil.DEBUG).Info("Running post-response plugin", "plugin", plugin.Name()) before := time.Now() plugin.PostResponse(ctx, targetPod) - metrics.RecordSchedulerPluginProcessingLatency(plugins.PostResponsePluginType, plugin.Name(), time.Since(before)) + metrics.RecordSchedulerPluginProcessingLatency(framework.PostResponsePluginType, plugin.Name(), time.Since(before)) } } diff --git a/pkg/epp/scheduling/scheduler_config.go b/pkg/epp/scheduling/scheduler_config.go index 06a23f469..4008b7002 100644 --- a/pkg/epp/scheduling/scheduler_config.go +++ b/pkg/epp/scheduling/scheduler_config.go @@ -16,108 +16,18 @@ limitations under the License. package scheduling -import ( - "fmt" - - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/scorer" -) +import "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework" // NewSchedulerConfig creates a new SchedulerConfig object and returns its pointer. -func NewSchedulerConfig() *SchedulerConfig { +func NewSchedulerConfig(profilePicker framework.ProfilePicker, profiles map[string]*framework.SchedulerProfile) *SchedulerConfig { return &SchedulerConfig{ - preSchedulePlugins: []plugins.PreSchedule{}, - filters: []plugins.Filter{}, - scorers: []*scorer.WeightedScorer{}, - postSchedulePlugins: []plugins.PostSchedule{}, - postResponsePlugins: []plugins.PostResponse{}, - // picker remains nil since config doesn't support multiple pickers + profilePicker: profilePicker, + profiles: profiles, } } // SchedulerConfig provides a configuration for the scheduler which influence routing decisions. type SchedulerConfig struct { - preSchedulePlugins []plugins.PreSchedule - filters []plugins.Filter - scorers []*scorer.WeightedScorer - picker plugins.Picker - postSchedulePlugins []plugins.PostSchedule - postResponsePlugins []plugins.PostResponse -} - -// WithPreSchedulePlugins sets the given plugins as the PreSchedule plugins. -// If the SchedulerConfig has PreSchedule plugins, this call replaces the existing plugins with the given ones. -func (c *SchedulerConfig) WithPreSchedulePlugins(plugins ...plugins.PreSchedule) *SchedulerConfig { - c.preSchedulePlugins = plugins - return c -} - -// WithFilters sets the given filter plugins as the Filter plugins. -// if the SchedulerConfig has Filter plugins, this call replaces the existing plugins with the given ones. -func (c *SchedulerConfig) WithFilters(filters ...plugins.Filter) *SchedulerConfig { - c.filters = filters - return c -} - -// WithScorers sets the given scorer plugins as the Scorer plugins. -// if the SchedulerConfig has Scorer plugins, this call replaces the existing plugins with the given ones. -func (c *SchedulerConfig) WithScorers(scorers ...*scorer.WeightedScorer) *SchedulerConfig { - c.scorers = scorers - return c -} - -// WithPicker sets the given picker plugins as the Picker plugin. -// if the SchedulerConfig has Picker plugin, this call replaces the existing plugin with the given one. -func (c *SchedulerConfig) WithPicker(picker plugins.Picker) *SchedulerConfig { - c.picker = picker - return c -} - -// WithPostSchedulePlugins sets the given plugins as the PostSchedule plugins. -// If the SchedulerConfig has PostSchedule plugins, this call replaces the existing plugins with the given ones. -func (c *SchedulerConfig) WithPostSchedulePlugins(plugins ...plugins.PostSchedule) *SchedulerConfig { - c.postSchedulePlugins = plugins - return c -} - -// WithPostResponsePlugins sets the given plugins as the PostResponse plugins. -// If the SchedulerConfig has PostResponse plugins, this call replaces the existing plugins with the given ones. -func (c *SchedulerConfig) WithPostResponsePlugins(plugins ...plugins.PostResponse) *SchedulerConfig { - c.postResponsePlugins = plugins - return c -} - -// AddPlugins adds the given plugins to all scheduler plugins according to the interfaces each plugin implements. -// A plugin may implement more than one scheduler plugin interface. -// Special Case: In order to add a scorer, one must use the scorer.NewWeightedScorer function in order to provide a weight. -// if a scorer implements more than one interface, supplying a WeightedScorer is sufficient. The function will take the internal -// scorer object and register it to all interfaces it implements. -func (c *SchedulerConfig) AddPlugins(pluginObjects ...plugins.Plugin) error { - for _, plugin := range pluginObjects { - if weightedScorer, ok := plugin.(*scorer.WeightedScorer); ok { - c.scorers = append(c.scorers, weightedScorer) - plugin = weightedScorer.Scorer // if we got WeightedScorer, unwrap the plugin - } else if scorer, ok := plugin.(plugins.Scorer); ok { // if we got a Scorer instead of WeightedScorer that's an error. - return fmt.Errorf("failed to register scorer '%s' without a weight. follow function documentation to register a scorer", scorer.Name()) - } - if preSchedulePlugin, ok := plugin.(plugins.PreSchedule); ok { - c.preSchedulePlugins = append(c.preSchedulePlugins, preSchedulePlugin) - } - if filter, ok := plugin.(plugins.Filter); ok { - c.filters = append(c.filters, filter) - } - if picker, ok := plugin.(plugins.Picker); ok { - if c.picker != nil { - return fmt.Errorf("failed to set '%s' as picker, already have a registered picker plugin '%s'", picker.Name(), c.picker.Name()) - } - c.picker = picker - } - if postSchedulePlugin, ok := plugin.(plugins.PostSchedule); ok { - c.postSchedulePlugins = append(c.postSchedulePlugins, postSchedulePlugin) - } - if postResponsePlugin, ok := plugin.(plugins.PostResponse); ok { - c.postResponsePlugins = append(c.postResponsePlugins, postResponsePlugin) - } - } - return nil + profilePicker framework.ProfilePicker + profiles map[string]*framework.SchedulerProfile } diff --git a/pkg/epp/scheduling/scheduler_test.go b/pkg/epp/scheduling/scheduler_test.go index 1c6e6495c..bb3647277 100644 --- a/pkg/epp/scheduling/scheduler_test.go +++ b/pkg/epp/scheduling/scheduler_test.go @@ -25,8 +25,8 @@ import ( k8stypes "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" // Import config for thresholds - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/plugins/scorer" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework" + profilepicker "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework/plugins/profile-picker" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" ) @@ -36,7 +36,7 @@ func TestSchedule(t *testing.T) { name string req *types.LLMRequest input []*backendmetrics.FakePodMetrics - wantRes *types.Result + wantRes map[string]*types.Result err bool }{ { @@ -46,8 +46,9 @@ func TestSchedule(t *testing.T) { RequestId: uuid.NewString(), Critical: true, }, - input: []*backendmetrics.FakePodMetrics{}, - err: true, + input: []*backendmetrics.FakePodMetrics{}, + wantRes: nil, + err: true, }, { name: "critical request", @@ -95,19 +96,21 @@ func TestSchedule(t *testing.T) { }, }, }, - wantRes: &types.Result{ - TargetPod: &types.ScoredPod{ - Pod: &types.PodMetrics{ - Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}, Labels: make(map[string]string)}, - MetricsState: &backendmetrics.MetricsState{ - WaitingQueueSize: 3, - KVCacheUsagePercent: 0.1, - MaxActiveModels: 2, - ActiveModels: map[string]int{ - "foo": 1, - "critical": 1, + wantRes: map[string]*types.Result{ + "default": { + TargetPod: &types.ScoredPod{ + Pod: &types.PodMetrics{ + Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}, Labels: make(map[string]string)}, + MetricsState: &backendmetrics.MetricsState{ + WaitingQueueSize: 3, + KVCacheUsagePercent: 0.1, + MaxActiveModels: 2, + ActiveModels: map[string]int{ + "foo": 1, + "critical": 1, + }, + WaitingModels: map[string]int{}, }, - WaitingModels: map[string]int{}, }, }, }, @@ -158,19 +161,21 @@ func TestSchedule(t *testing.T) { }, }, }, - wantRes: &types.Result{ - TargetPod: &types.ScoredPod{ - Pod: &types.PodMetrics{ - Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}, Labels: make(map[string]string)}, - MetricsState: &backendmetrics.MetricsState{ - WaitingQueueSize: 0, - KVCacheUsagePercent: 0.2, - MaxActiveModels: 2, - ActiveModels: map[string]int{ - "foo": 1, - "bar": 1, + wantRes: map[string]*types.Result{ + "default": { + TargetPod: &types.ScoredPod{ + Pod: &types.PodMetrics{ + Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}, Labels: make(map[string]string)}, + MetricsState: &backendmetrics.MetricsState{ + WaitingQueueSize: 0, + KVCacheUsagePercent: 0.2, + MaxActiveModels: 2, + ActiveModels: map[string]int{ + "foo": 1, + "bar": 1, + }, + WaitingModels: map[string]int{}, }, - WaitingModels: map[string]int{}, }, }, }, @@ -242,192 +247,6 @@ func TestSchedule(t *testing.T) { } } -func TestSchedulePlugins(t *testing.T) { - tp1 := &TestPlugin{ - NameRes: "test1", - ScoreRes: 0.3, - FilterRes: []k8stypes.NamespacedName{{Name: "pod1"}, {Name: "pod2"}, {Name: "pod3"}}, - } - tp2 := &TestPlugin{ - NameRes: "test2", - ScoreRes: 0.8, - FilterRes: []k8stypes.NamespacedName{{Name: "pod1"}, {Name: "pod2"}}, - } - tp_filterAll := &TestPlugin{ - NameRes: "filter all", - FilterRes: []k8stypes.NamespacedName{}, - } - pickerPlugin := &TestPlugin{ - NameRes: "picker", - PickRes: k8stypes.NamespacedName{Name: "pod1"}, - } - - tests := []struct { - name string - config SchedulerConfig - input []*backendmetrics.FakePodMetrics - wantTargetPod k8stypes.NamespacedName - targetPodScore float64 - // Number of expected pods to score (after filter) - numPodsToScore int - err bool - }{ - { - name: "all plugins executed successfully, all scorers with same weight", - config: SchedulerConfig{ - preSchedulePlugins: []plugins.PreSchedule{tp1, tp2}, - filters: []plugins.Filter{tp1, tp2}, - scorers: []*scorer.WeightedScorer{ - scorer.NewWeightedScorer(tp1, 1), - scorer.NewWeightedScorer(tp2, 1), - }, - picker: pickerPlugin, - postSchedulePlugins: []plugins.PostSchedule{tp1, tp2}, - }, - input: []*backendmetrics.FakePodMetrics{ - {Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, - {Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}}, - {Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}}, - }, - wantTargetPod: k8stypes.NamespacedName{Name: "pod1"}, - targetPodScore: 1.1, - numPodsToScore: 2, - err: false, - }, - { - name: "all plugins executed successfully, different scorers weights", - config: SchedulerConfig{ - preSchedulePlugins: []plugins.PreSchedule{tp1, tp2}, - filters: []plugins.Filter{tp1, tp2}, - scorers: []*scorer.WeightedScorer{ - scorer.NewWeightedScorer(tp1, 60), - scorer.NewWeightedScorer(tp2, 40), - }, - picker: pickerPlugin, - postSchedulePlugins: []plugins.PostSchedule{tp1, tp2}, - }, - input: []*backendmetrics.FakePodMetrics{ - {Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, - {Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}}, - {Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}}, - }, - wantTargetPod: k8stypes.NamespacedName{Name: "pod1"}, - targetPodScore: 50, - numPodsToScore: 2, - err: false, - }, - { - name: "filter all", - config: SchedulerConfig{ - preSchedulePlugins: []plugins.PreSchedule{tp1, tp2}, - filters: []plugins.Filter{tp1, tp_filterAll}, - scorers: []*scorer.WeightedScorer{ - scorer.NewWeightedScorer(tp1, 1), - scorer.NewWeightedScorer(tp2, 1), - }, - picker: pickerPlugin, - postSchedulePlugins: []plugins.PostSchedule{tp1, tp2}, - }, - input: []*backendmetrics.FakePodMetrics{ - {Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, - {Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod2"}}}, - {Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod3"}}}, - }, - numPodsToScore: 0, - err: true, // no available pods to server after filter all - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - // Reset all plugins before each new test case. - for _, plugin := range test.config.preSchedulePlugins { - plugin.(*TestPlugin).reset() - } - for _, plugin := range test.config.filters { - plugin.(*TestPlugin).reset() - } - for _, plugin := range test.config.scorers { - plugin.Scorer.(*TestPlugin).reset() - } - test.config.picker.(*TestPlugin).reset() - for _, plugin := range test.config.postSchedulePlugins { - plugin.(*TestPlugin).reset() - } - - // Initialize the scheduler - scheduler := NewSchedulerWithConfig(&fakeDataStore{pods: test.input}, &test.config) - - req := &types.LLMRequest{ - TargetModel: "test-model", - RequestId: uuid.NewString(), - } - got, err := scheduler.Schedule(context.Background(), req) - - // Validate error state - if test.err != (err != nil) { - t.Fatalf("Unexpected error, got %v, want %v", err, test.err) - } - - if err != nil { - return - } - - // Validate output - wantPod := &types.PodMetrics{ - Pod: &backend.Pod{NamespacedName: test.wantTargetPod, Labels: make(map[string]string)}, - } - wantRes := &types.Result{TargetPod: wantPod} - if diff := cmp.Diff(wantRes, got); diff != "" { - t.Errorf("Unexpected output (-want +got): %v", diff) - } - - // Validate plugin execution counts dynamically - for _, plugin := range test.config.preSchedulePlugins { - tp, _ := plugin.(*TestPlugin) - if tp.PreScheduleCallCount != 1 { - t.Errorf("Plugin %s PreSchedule() called %d times, expected 1", plugin.Name(), tp.PreScheduleCallCount) - } - } - - for _, plugin := range test.config.filters { - tp, _ := plugin.(*TestPlugin) - if tp.FilterCallCount != 1 { - t.Errorf("Plugin %s Filter() called %d times, expected 1", plugin.Name(), tp.FilterCallCount) - } - } - - for _, plugin := range test.config.scorers { - tp, _ := plugin.Scorer.(*TestPlugin) - if tp.ScoreCallCount != 1 { - t.Errorf("Plugin %s Score() called %d times, expected 1", plugin.Name(), tp.ScoreCallCount) - } - if test.numPodsToScore != tp.NumOfScoredPods { - t.Errorf("Plugin %s Score() called with %d pods, expected %d", plugin.Name(), tp.NumOfScoredPods, test.numPodsToScore) - } - } - - tp, _ := test.config.picker.(*TestPlugin) - if tp.NumOfPickerCandidates != test.numPodsToScore { - t.Errorf("Picker plugin %s Pick() called with %d candidates, expected %d", tp.Name(), tp.NumOfPickerCandidates, tp.NumOfScoredPods) - } - if tp.PickCallCount != 1 { - t.Errorf("Picker plugin %s Pick() called %d times, expected 1", tp.Name(), tp.PickCallCount) - } - if tp.WinnderPodScore != test.targetPodScore { - t.Errorf("winnder pod score %v, expected %v", tp.WinnderPodScore, test.targetPodScore) - } - - for _, plugin := range test.config.postSchedulePlugins { - tp, _ := plugin.(*TestPlugin) - if tp.PostScheduleCallCount != 1 { - t.Errorf("Plugin %s PostSchedule() called %d times, expected 1", plugin.Name(), tp.PostScheduleCallCount) - } - } - }) - } -} - func TestPostResponse(t *testing.T) { pr1 := &testPostResponse{ NameRes: "pr1", @@ -439,15 +258,15 @@ func TestPostResponse(t *testing.T) { tests := []struct { name string - config SchedulerConfig + config *framework.SchedulerProfile input []*backendmetrics.FakePodMetrics responseHeaders map[string]string wantUpdatedHeaders map[string]string }{ { name: "Simple postResponse test", - config: SchedulerConfig{ - postResponsePlugins: []plugins.PostResponse{pr1}, + config: &framework.SchedulerProfile{ + PostResponsePlugins: []framework.PostResponse{pr1}, }, input: []*backendmetrics.FakePodMetrics{ {Pod: &backend.Pod{NamespacedName: k8stypes.NamespacedName{Name: "pod1"}}}, @@ -459,7 +278,8 @@ func TestPostResponse(t *testing.T) { } for _, test := range tests { - scheduler := NewSchedulerWithConfig(&fakeDataStore{pods: test.input}, &test.config) + schedulerConfig := NewSchedulerConfig(profilepicker.NewAllProfilesPicker(), map[string]*framework.SchedulerProfile{"default": test.config}) + scheduler := NewSchedulerWithConfig(&fakeDataStore{pods: test.input}, schedulerConfig) headers := map[string]string{} for k, v := range test.responseHeaders { @@ -493,66 +313,6 @@ func (fds *fakeDataStore) PodGetAll() []backendmetrics.PodMetrics { return pm } -// TestPlugin is an implementation useful in unit tests. -type TestPlugin struct { - NameRes string - ScoreCallCount int - NumOfScoredPods int - ScoreRes float64 - FilterCallCount int - FilterRes []k8stypes.NamespacedName - PreScheduleCallCount int - PostScheduleCallCount int - PickCallCount int - NumOfPickerCandidates int - PickRes k8stypes.NamespacedName - WinnderPodScore float64 -} - -func (tp *TestPlugin) Name() string { return tp.NameRes } - -func (tp *TestPlugin) PreSchedule(ctx *types.SchedulingContext) { - tp.PreScheduleCallCount++ -} - -func (tp *TestPlugin) Filter(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { - tp.FilterCallCount++ - return findPods(ctx, tp.FilterRes...) - -} - -func (tp *TestPlugin) Score(ctx *types.SchedulingContext, pods []types.Pod) map[types.Pod]float64 { - tp.ScoreCallCount++ - scoredPods := make(map[types.Pod]float64, len(pods)) - for _, pod := range pods { - scoredPods[pod] += tp.ScoreRes - } - tp.NumOfScoredPods = len(scoredPods) - return scoredPods -} - -func (tp *TestPlugin) Pick(ctx *types.SchedulingContext, scoredPods []*types.ScoredPod) *types.Result { - tp.PickCallCount++ - tp.NumOfPickerCandidates = len(scoredPods) - pod := findPods(ctx, tp.PickRes)[0] - tp.WinnderPodScore = getPodScore(scoredPods, pod) - return &types.Result{TargetPod: pod} -} - -func (tp *TestPlugin) PostSchedule(ctx *types.SchedulingContext, res *types.Result) { - tp.PostScheduleCallCount++ -} - -func (tp *TestPlugin) reset() { - tp.PreScheduleCallCount = 0 - tp.FilterCallCount = 0 - tp.ScoreCallCount = 0 - tp.NumOfScoredPods = 0 - tp.PostScheduleCallCount = 0 - tp.PickCallCount = 0 - tp.NumOfPickerCandidates = 0 -} - type testPostResponse struct { NameRes string ReceivedResponseHeaders map[string]string @@ -569,26 +329,3 @@ func (pr *testPostResponse) PostResponse(ctx *types.SchedulingContext, pod types ctx.Resp.Headers[key] = value } } - -func findPods(ctx *types.SchedulingContext, names ...k8stypes.NamespacedName) []types.Pod { - res := []types.Pod{} - for _, pod := range ctx.PodsSnapshot { - for _, name := range names { - if pod.GetPod().NamespacedName.String() == name.String() { - res = append(res, pod) - } - } - } - return res -} - -func getPodScore(scoredPods []*types.ScoredPod, selectedPod types.Pod) float64 { - finalScore := 0.0 - for _, scoredPod := range scoredPods { - if scoredPod.GetPod().NamespacedName.String() == selectedPod.GetPod().NamespacedName.String() { - finalScore = scoredPod.Score - break - } - } - return finalScore -} diff --git a/test/integration/epp/hermetic_test.go b/test/integration/epp/hermetic_test.go index 4ea56c9d1..1be06430c 100644 --- a/test/integration/epp/hermetic_test.go +++ b/test/integration/epp/hermetic_test.go @@ -474,7 +474,7 @@ func TestFullDuplexStreamed_KubeInferenceModelRequest(t *testing.T) { Status: &envoyTypePb.HttpStatus{ Code: envoyTypePb.StatusCode_TooManyRequests, }, - Body: []byte("inference gateway: InferencePoolResourceExhausted - failed to find target pod: inference gateway: Internal - no pods available for the given request"), + Body: []byte("inference gateway: InferencePoolResourceExhausted - failed to find target pod: failed to run all required scheduling profiles - inference gateway: Internal - no pods available for the given request"), }, }, }, From 48b6c97d5fe93b0da7b35d94eff756c5dee66ac0 Mon Sep 17 00:00:00 2001 From: sina chavoshi Date: Tue, 27 May 2025 09:30:17 -0700 Subject: [PATCH 46/53] feat(conformance): Add test for HTTPRouteInvalidInferencePoolRef (#807) * Add inferencepool_lifecycle test. * Resolve setup issues and enable InferencePool test * correct Lint error Multiplication of durations * Fix missing containerPort, is missing * change gateway name from "gateway-conformance-app" to "conformance-gateway" * clarify why K8s types are needed. * Update conformance/conformance.go Co-authored-by: Lior Lieberman * Update conformance/conformance.go Co-authored-by: Lior Lieberman * remove for loop when adding SupportedFeatures * remove exessive logging * Update conformance/conformance.go Co-authored-by: Lior Lieberman * move excess debug logs behind debug flag. * remove CONFORMANCE.GO prefix from logs. * change the pull logic and use default value from GatewayMustHaveAddress * fix mt.Sprintf can be replaced with string concatenation * add a function for logDebug * factor out ensureGatewayAvailableAndReady * removed todo comment in helper.go * remove CONFORMANCE.GO from log * Add InferencePoolLifecycle test * update comments in helper.go * Initial commit for InferencePoolNoMatchingPodsRouteStatus test * resolve lint issue. * error messages, should not be capitalized or end with punctuation * Add inferencepool_lifecycle test. * Resolve setup issues and enable InferencePool test * removed todo comment in helper.go * Add InferencePoolLifecycle test * update comments in helper.go * remove Conformanc.go from log message * Remove lifecycle test. * Removed unused helper methods ( inference pool must have selector & must be deleted) * add back HTTPRouteMustHaveParentStatusConditions * Set timeout values as constant * change timeout.go to timing.go * remove duplicate log * remove excess comments and logs * add comment / todo for Reconciled * Update conformance/utils/kubernetes/helpers.go Co-authored-by: Rob Scott * change test to HTTPRouteInvalidInferencePoolRef * use TODO: instead of TODO() * yaml and todos based on code review --------- Co-authored-by: Lior Lieberman Co-authored-by: Rob Scott --- conformance/conformance.go | 8 ++ .../httproute_invalid_inferencepool_ref.go | 74 +++++++++++++++++++ .../httproute_invalid_inferencepool_ref.yaml | 31 ++++++++ .../tests/basic/inferencepool_accepted.go | 4 +- 4 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 conformance/tests/basic/httproute_invalid_inferencepool_ref.go create mode 100644 conformance/tests/basic/httproute_invalid_inferencepool_ref.yaml diff --git a/conformance/conformance.go b/conformance/conformance.go index 4cff6bb18..66b8c0969 100644 --- a/conformance/conformance.go +++ b/conformance/conformance.go @@ -77,10 +77,18 @@ const ( // Future profiles will cover EPP and ModelServer layers. const GatewayLayerProfileName confsuite.ConformanceProfileName = "Gateway" +// TODO(#863) Create a dedicated share location for feature names similar to +// sigs.k8s.io/gateway-api/pkg/features and change the tests from +// string casting the feature name to referencing the shared feature names. + +// Conformance specific features +const SupportInferencePool features.FeatureName = "SupportInferencePool" + // InferenceCoreFeatures defines the core features that implementations // of the "Gateway" profile for the Inference Extension MUST support. var InferenceCoreFeatures = sets.New( features.SupportGateway, // This is needed to ensure manifest gets applied during setup. + SupportInferencePool, ) var GatewayLayerProfile = confsuite.ConformanceProfile{ diff --git a/conformance/tests/basic/httproute_invalid_inferencepool_ref.go b/conformance/tests/basic/httproute_invalid_inferencepool_ref.go new file mode 100644 index 000000000..4893b8155 --- /dev/null +++ b/conformance/tests/basic/httproute_invalid_inferencepool_ref.go @@ -0,0 +1,74 @@ +/* +Copyright 2025 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. +*/ + +// TODO(#864) refactor the structure to put all tests directly under tests instead of creating subfolders. +package basic + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + "sigs.k8s.io/gateway-api/conformance/utils/kubernetes" + "sigs.k8s.io/gateway-api/conformance/utils/suite" + "sigs.k8s.io/gateway-api/pkg/features" + + "sigs.k8s.io/gateway-api-inference-extension/conformance/tests" +) + +func init() { + tests.ConformanceTests = append(tests.ConformanceTests, HTTPRouteInvalidInferencePoolRef) +} + +var HTTPRouteInvalidInferencePoolRef = suite.ConformanceTest{ + ShortName: "HTTPRouteInvalidInferencePoolRef", + Description: "Tests HTTPRoute status when it references an InferencePool that does not exist.", + Manifests: []string{"tests/basic/httproute_invalid_inferencepool_ref.yaml"}, + Features: []features.FeatureName{ + features.FeatureName("SupportInferencePool"), + features.SupportGateway, + }, + Test: func(t *testing.T, s *suite.ConformanceTestSuite) { + const ( + appBackendNamespace = "gateway-conformance-app-backend" + infraNamespace = "gateway-conformance-infra" + routeName = "httproute-to-non-existent-pool" + gatewayName = "conformance-gateway" + ) + routeNN := types.NamespacedName{Name: routeName, Namespace: appBackendNamespace} + gatewayNN := types.NamespacedName{Name: gatewayName, Namespace: infraNamespace} + + t.Run("HTTPRoute should have Accepted=True and ResolvedRefs=False for non-existent InferencePool", func(t *testing.T) { + acceptedCondition := metav1.Condition{ + Type: string(gatewayv1.RouteConditionAccepted), + Status: metav1.ConditionTrue, + Reason: string(gatewayv1.RouteReasonAccepted), + } + kubernetes.HTTPRouteMustHaveCondition(t, s.Client, s.TimeoutConfig, routeNN, gatewayNN, acceptedCondition) + + resolvedRefsCondition := metav1.Condition{ + Type: string(gatewayv1.RouteConditionResolvedRefs), + Status: metav1.ConditionFalse, + Reason: string(gatewayv1.RouteReasonBackendNotFound), + } + kubernetes.HTTPRouteMustHaveCondition(t, s.Client, s.TimeoutConfig, routeNN, gatewayNN, resolvedRefsCondition) + + t.Logf("Successfully verified HTTPRoute %s has conditions: Accepted=True and ResolvedRefs=False (Reason: BackendNotFound) for Gateway %s", + routeNN.String(), gatewayNN.String()) + }) + }, +} diff --git a/conformance/tests/basic/httproute_invalid_inferencepool_ref.yaml b/conformance/tests/basic/httproute_invalid_inferencepool_ref.yaml new file mode 100644 index 000000000..61bdb5474 --- /dev/null +++ b/conformance/tests/basic/httproute_invalid_inferencepool_ref.yaml @@ -0,0 +1,31 @@ +# httproute_invalid_inferencepool_ref.yaml +# This manifest defines an HTTPRoute that references an InferencePool +# by name ("non-existent-inference-pool") which is intentionally NOT defined. +# The test will verify that the HTTPRoute reflects an appropriate +# failure status because the referenced InferencePool backend cannot be found. + +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + # This name must match the 'routeNN.Name' in the Go test file. + name: httproute-to-non-existent-pool + # This namespace should be one created by the base manifests, + # typically where backend applications and their routes reside. + namespace: gateway-conformance-app-backend +spec: + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: conformance-gateway # Name of the shared Gateway from base manifests + namespace: gateway-conformance-infra # Namespace of the shared Gateway + sectionName: http + rules: + - backendRefs: + - group: inference.networking.x-k8s.io + kind: InferencePool + name: non-existent-inference-pool # Intentionally Non-Existing + port: 8080 + matches: + - path: + type: PathPrefix + value: /test-non-existent-pool diff --git a/conformance/tests/basic/inferencepool_accepted.go b/conformance/tests/basic/inferencepool_accepted.go index 15eb6d742..442dd2277 100644 --- a/conformance/tests/basic/inferencepool_accepted.go +++ b/conformance/tests/basic/inferencepool_accepted.go @@ -27,7 +27,7 @@ import ( // Import the tests package to append to ConformanceTests "sigs.k8s.io/gateway-api-inference-extension/conformance/tests" - infrakubernetes "sigs.k8s.io/gateway-api-inference-extension/conformance/utils/kubernetes" + k8sutils "sigs.k8s.io/gateway-api-inference-extension/conformance/utils/kubernetes" ) func init() { @@ -54,7 +54,7 @@ var InferencePoolAccepted = suite.ConformanceTest{ Status: metav1.ConditionTrue, Reason: "", // "" means we don't strictly check the Reason for this basic test. } - infrakubernetes.InferencePoolMustHaveCondition(t, s.Client, poolNN, acceptedCondition) + k8sutils.InferencePoolMustHaveCondition(t, s.Client, poolNN, acceptedCondition) }) }, } From 440ca87f7fb4e0e89a4c0f4ac70ee2abd5a50fe8 Mon Sep 17 00:00:00 2001 From: sina chavoshi Date: Wed, 28 May 2025 07:46:17 -0700 Subject: [PATCH 47/53] feat(conformance): tests for inferencepool_resolvedrefs_condition (#832) * WIP tests for inferencepool_resolvedrefs_condition * update condition check * Add helper method for inf pool parrent status check * update manifests * update the test to match manifest * fix yaml files. * add SupportInferencePool * Add a helper function for HTTPRouteMustBeAcceptedAndResolved * Add a helper method InferencePoolMustBeAcceptedByParent * add todo for ensure http requests are routed correctly #865 * remove extra space --- .../resources/manifests/manifests.yaml | 23 ++- .../tests/basic/inferencepool_accepted.go | 5 +- .../inferencepool_resolvedrefs_condition.go | 104 ++++++++++++++ .../inferencepool_resolvedrefs_condition.yaml | 131 ++++++++++++++++++ conformance/utils/kubernetes/helpers.go | 106 +++++++++++++- 5 files changed, 365 insertions(+), 4 deletions(-) create mode 100644 conformance/tests/basic/inferencepool_resolvedrefs_condition.go create mode 100644 conformance/tests/basic/inferencepool_resolvedrefs_condition.yaml diff --git a/conformance/resources/manifests/manifests.yaml b/conformance/resources/manifests/manifests.yaml index 190a8845e..b2ca4890a 100644 --- a/conformance/resources/manifests/manifests.yaml +++ b/conformance/resources/manifests/manifests.yaml @@ -23,7 +23,7 @@ metadata: gateway-conformance: backend --- -# Namespace for simple web server backends. This is expected by +# Namespace for simple web server backends. This is expected by # the upstream conformance suite's Setup method. apiVersion: v1 kind: Namespace @@ -50,8 +50,27 @@ spec: protocol: HTTP allowedRoutes: namespaces: - from: All + from: All kinds: # Allows HTTPRoutes to attach, which can then reference InferencePools. - group: gateway.networking.k8s.io kind: HTTPRoute + +--- +# --- Conformance Secondary Gateway Definition --- +# A second generic Gateway resource for tests requiring multiple Gateways. +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: conformance-secondary-gateway + namespace: gateway-conformance-infra +spec: + gatewayClassName: "{GATEWAY_CLASS_NAME}" + listeners: + - name: http + port: 80 + protocol: HTTP + hostname: "secondary.example.com" # Distinct hostname to differentiate from conformance-gateway + allowedRoutes: + namespaces: + from: All diff --git a/conformance/tests/basic/inferencepool_accepted.go b/conformance/tests/basic/inferencepool_accepted.go index 442dd2277..d74f73829 100644 --- a/conformance/tests/basic/inferencepool_accepted.go +++ b/conformance/tests/basic/inferencepool_accepted.go @@ -41,7 +41,10 @@ var InferencePoolAccepted = suite.ConformanceTest{ ShortName: "InferencePoolAccepted", Description: "A minimal InferencePool resource should be accepted by the controller and report an Accepted condition", Manifests: []string{"tests/basic/inferencepool_accepted.yaml"}, - Features: []features.FeatureName{}, + Features: []features.FeatureName{ + features.FeatureName("SupportInferencePool"), + features.SupportGateway, + }, Test: func(t *testing.T, s *suite.ConformanceTestSuite) { // created by the associated manifest file. poolNN := types.NamespacedName{Name: "inferencepool-basic-accepted", Namespace: "gateway-conformance-app-backend"} diff --git a/conformance/tests/basic/inferencepool_resolvedrefs_condition.go b/conformance/tests/basic/inferencepool_resolvedrefs_condition.go new file mode 100644 index 000000000..86ca54263 --- /dev/null +++ b/conformance/tests/basic/inferencepool_resolvedrefs_condition.go @@ -0,0 +1,104 @@ +/* +Copyright 2025 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 basic + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + "sigs.k8s.io/gateway-api/conformance/utils/suite" + "sigs.k8s.io/gateway-api/pkg/features" + + "sigs.k8s.io/gateway-api-inference-extension/conformance/tests" + k8sutils "sigs.k8s.io/gateway-api-inference-extension/conformance/utils/kubernetes" +) + +func init() { + tests.ConformanceTests = append(tests.ConformanceTests, InferencePoolParentStatus) +} + +var InferencePoolParentStatus = suite.ConformanceTest{ + ShortName: "InferencePoolResolvedRefsCondition", + Description: "Verify that an InferencePool correctly updates its parent-specific status (e.g., Accepted condition) when referenced by HTTPRoutes attached to shared Gateways, and clears parent statuses when no longer referenced.", + Manifests: []string{"tests/basic/inferencepool_resolvedrefs_condition.yaml"}, + Features: []features.FeatureName{ + features.FeatureName("SupportInferencePool"), + features.SupportGateway, + }, + Test: func(t *testing.T, s *suite.ConformanceTestSuite) { + const ( + appBackendNamespace = "gateway-conformance-app-backend" + infraNamespace = "gateway-conformance-infra" + poolName = "multi-gateway-pool" + sharedGateway1Name = "conformance-gateway" + sharedGateway2Name = "conformance-secondary-gateway" + httpRoute1Name = "httproute-for-gw1" + httpRoute2Name = "httproute-for-gw2" + ) + + poolNN := types.NamespacedName{Name: poolName, Namespace: appBackendNamespace} + httpRoute1NN := types.NamespacedName{Name: httpRoute1Name, Namespace: appBackendNamespace} + httpRoute2NN := types.NamespacedName{Name: httpRoute2Name, Namespace: appBackendNamespace} + gateway1NN := types.NamespacedName{Name: sharedGateway1Name, Namespace: infraNamespace} + gateway2NN := types.NamespacedName{Name: sharedGateway2Name, Namespace: infraNamespace} + + k8sutils.HTTPRouteMustBeAcceptedAndResolved(t, s.Client, s.TimeoutConfig, httpRoute1NN, gateway1NN) + k8sutils.HTTPRouteMustBeAcceptedAndResolved(t, s.Client, s.TimeoutConfig, httpRoute2NN, gateway2NN) + + t.Run("InferencePool should show Accepted:True by parents when referenced by multiple HTTPRoutes", func(t *testing.T) { + k8sutils.InferencePoolMustBeAcceptedByParent(t, s.Client, poolNN) + // TODO(#865) ensure requests are correctly routed to this InferencePool. + t.Logf("InferencePool %s has parent status Accepted:True as expected with two references.", poolNN.String()) + }) + + t.Run("Delete httproute-for-gw1", func(t *testing.T) { + httproute1 := &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: httpRoute1NN.Name, Namespace: httpRoute1NN.Namespace}, + } + t.Logf("Deleting HTTPRoute %s", httpRoute1NN.String()) + require.NoError(t, s.Client.Delete(context.TODO(), httproute1), "failed to delete httproute-for-gw1") + time.Sleep(s.TimeoutConfig.GatewayMustHaveCondition) + }) + + t.Run("InferencePool should still show Accepted:True by parent after one HTTPRoute is deleted", func(t *testing.T) { + k8sutils.InferencePoolMustBeAcceptedByParent(t, s.Client, poolNN) + // TODO(#865) ensure requests are correctly routed to this InferencePool. + t.Logf("InferencePool %s still has parent status Accepted:True as expected with one reference remaining.", poolNN.String()) + }) + + t.Run("Delete httproute-for-gw2", func(t *testing.T) { + httproute2 := &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: httpRoute2NN.Name, Namespace: httpRoute2NN.Namespace}, + } + t.Logf("Deleting HTTPRoute %s", httpRoute2NN.String()) + require.NoError(t, s.Client.Delete(context.TODO(), httproute2), "failed to delete httproute-for-gw2") + }) + + t.Run("InferencePool should have no parent statuses after all HTTPRoutes are deleted", func(t *testing.T) { + t.Logf("Waiting for InferencePool %s to have no parent statuses.", poolNN.String()) + k8sutils.InferencePoolMustHaveNoParents(t, s.Client, poolNN) + t.Logf("InferencePool %s correctly shows no parent statuses, indicating it's no longer referenced.", poolNN.String()) + }) + + t.Logf("InferencePoolResolvedRefsCondition completed.") + }, +} diff --git a/conformance/tests/basic/inferencepool_resolvedrefs_condition.yaml b/conformance/tests/basic/inferencepool_resolvedrefs_condition.yaml new file mode 100644 index 000000000..008893117 --- /dev/null +++ b/conformance/tests/basic/inferencepool_resolvedrefs_condition.yaml @@ -0,0 +1,131 @@ +# conformance/tests/basic/inferencepool_resolvedrefs_condition.yaml + +# This manifest defines the initial resources for the +# inferencepool_resolvedrefs_condition.go conformance test. + +# --- Backend Deployment (using agnhost echo server) --- +# This Deployment provides Pods for the InferencePool to select. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: infra-backend-deployment + namespace: gateway-conformance-app-backend + labels: + app: infra-backend +spec: + replicas: 1 + selector: + matchLabels: + app: infra-backend + template: + metadata: + labels: + app: infra-backend + spec: + containers: + - name: agnhost-echo + image: k8s.gcr.io/e2e-test-images/agnhost:2.39 + args: + - serve-hostname + - --port=8080 + ports: + - name: http + containerPort: 8080 + readinessProbe: + httpGet: + path: / + port: 8080 + initialDelaySeconds: 3 + periodSeconds: 5 + failureThreshold: 2 + +--- +# --- Backend Service --- +# Service for the infra-backend-deployment. +apiVersion: v1 +kind: Service +metadata: + name: infra-backend-svc + namespace: gateway-conformance-app-backend +spec: + selector: + app: infra-backend + ports: + - name: http + protocol: TCP + port: 8080 + targetPort: 8080 + - name: epp + port: 9002 + targetPort: 9002 + +--- +# --- InferencePool Definition --- +apiVersion: inference.networking.x-k8s.io/v1alpha2 +kind: InferencePool +metadata: + name: multi-gateway-pool # Name used in the Go test + namespace: gateway-conformance-app-backend # Defined in base manifests.yaml +spec: + # --- Selector (Required) --- + # Selects the Pods belonging to this pool. + selector: + app: "infra-backend" + # --- Target Port (Required) --- + targetPortNumber: 8080 + extensionRef: + name: infra-backend-svc + +--- +# --- HTTPRoute for Gateway 1 (conformance-gateway) --- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: httproute-for-gw1 + namespace: gateway-conformance-app-backend +spec: + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: conformance-gateway + namespace: gateway-conformance-infra + sectionName: http + hostnames: + - "gw1.example.com" + rules: + - backendRefs: + - group: inference.networking.x-k8s.io + kind: InferencePool + name: multi-gateway-pool + port: 8080 + matches: + - path: + type: PathPrefix + value: /conformance-gateway-test + +--- +# --- HTTPRoute for Gateway 2 (conformance-secondary-gateway) --- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: httproute-for-gw2 + namespace: gateway-conformance-app-backend +spec: + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: conformance-secondary-gateway + namespace: gateway-conformance-infra + sectionName: http + hostnames: + - "secondary.example.com" + rules: + - backendRefs: + - group: inference.networking.x-k8s.io + kind: InferencePool + name: multi-gateway-pool + port: 8080 + matches: + - path: + type: PathPrefix + value: /gateway-2-test diff --git a/conformance/utils/kubernetes/helpers.go b/conformance/utils/kubernetes/helpers.go index fbe24b577..a862cfbd7 100644 --- a/conformance/utils/kubernetes/helpers.go +++ b/conformance/utils/kubernetes/helpers.go @@ -34,8 +34,12 @@ import ( // Import the Inference Extension API types inferenceapi "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" // Adjust if your API version is different - // Import necessary utilities from the core Gateway API conformance suite + // Import local config for Inference Extension "sigs.k8s.io/gateway-api-inference-extension/conformance/utils/config" + // Import necessary utilities from the core Gateway API conformance suite + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + gatewayapiconfig "sigs.k8s.io/gateway-api/conformance/utils/config" + gatewayk8sutils "sigs.k8s.io/gateway-api/conformance/utils/kubernetes" ) // checkCondition is a helper function similar to findConditionInList or CheckCondition @@ -152,3 +156,103 @@ func InferencePoolMustHaveCondition(t *testing.T, c client.Client, poolNN types. } t.Log(logMsg) } + +// InferencePoolMustHaveNoParents waits for the specified InferencePool resource +// to exist and report that it has no parent references in its status. +// This typically indicates it is no longer referenced by any Gateway API resources. +func InferencePoolMustHaveNoParents(t *testing.T, c client.Client, poolNN types.NamespacedName) { + t.Helper() + + var lastObservedPool *inferenceapi.InferencePool + var lastError error + var timeoutConfig config.InferenceExtensionTimeoutConfig = config.DefaultInferenceExtensionTimeoutConfig() + + ctx := context.Background() + waitErr := wait.PollUntilContextTimeout( + ctx, + + timeoutConfig.InferencePoolMustHaveConditionInterval, + timeoutConfig.InferencePoolMustHaveConditionTimeout, + true, + func(pollCtx context.Context) (bool, error) { + pool := &inferenceapi.InferencePool{} + err := c.Get(pollCtx, poolNN, pool) + if err != nil { + if apierrors.IsNotFound(err) { + t.Logf("InferencePool %s not found. Considering this as having no parents.", poolNN.String()) + lastError = nil + return true, nil + } + t.Logf("Error fetching InferencePool %s: %v. Retrying.", poolNN.String(), err) + lastError = err + return false, nil + } + lastObservedPool = pool + lastError = nil + + if len(pool.Status.Parents) == 0 { + t.Logf("InferencePool %s successfully has no parent statuses.", poolNN.String()) + return true, nil + } + t.Logf("InferencePool %s still has %d parent statuses. Waiting...", poolNN.String(), len(pool.Status.Parents)) + return false, nil + }) + + if waitErr != nil { + debugMsg := fmt.Sprintf("Timed out waiting for InferencePool %s to have no parent statuses.", poolNN.String()) + if lastError != nil { + debugMsg += fmt.Sprintf(" Last error during fetching: %v.", lastError) + } + if lastObservedPool != nil && len(lastObservedPool.Status.Parents) > 0 { + debugMsg += fmt.Sprintf(" Last observed InferencePool still had %d parent(s):", len(lastObservedPool.Status.Parents)) + } else if lastError == nil && (lastObservedPool == nil || len(lastObservedPool.Status.Parents) == 0) { + debugMsg += " Polling completed without timeout, but an unexpected waitErr occurred." + } + require.FailNow(t, debugMsg, waitErr) + } + t.Logf("Successfully verified that InferencePool %s has no parent statuses.", poolNN.String()) +} + +// HTTPRouteMustBeAcceptedAndResolved waits for the specified HTTPRoute +// to be Accepted and have its references resolved by the specified Gateway. +// It uses the upstream Gateway API's HTTPRouteMustHaveCondition helper. +func HTTPRouteMustBeAcceptedAndResolved(t *testing.T, c client.Client, timeoutConfig gatewayapiconfig.TimeoutConfig, routeNN, gatewayNN types.NamespacedName) { + t.Helper() + + acceptedCondition := metav1.Condition{ + Type: string(gatewayv1.RouteConditionAccepted), + Status: metav1.ConditionTrue, + Reason: string(gatewayv1.RouteReasonAccepted), + } + + resolvedRefsCondition := metav1.Condition{ + Type: string(gatewayv1.RouteConditionResolvedRefs), + Status: metav1.ConditionTrue, + Reason: string(gatewayv1.RouteReasonResolvedRefs), + } + + t.Logf("Waiting for HTTPRoute %s to be Accepted by Gateway %s", routeNN.String(), gatewayNN.String()) + gatewayk8sutils.HTTPRouteMustHaveCondition(t, c, timeoutConfig, routeNN, gatewayNN, acceptedCondition) + + t.Logf("Waiting for HTTPRoute %s to have ResolvedRefs by Gateway %s", routeNN.String(), gatewayNN.String()) + gatewayk8sutils.HTTPRouteMustHaveCondition(t, c, timeoutConfig, routeNN, gatewayNN, resolvedRefsCondition) + + t.Logf("HTTPRoute %s is now Accepted and has ResolvedRefs by Gateway %s", routeNN.String(), gatewayNN.String()) +} + +// InferencePoolMustBeAcceptedByParent waits for the specified InferencePool +// to report an Accepted condition with status True and reason "Accepted" +// from at least one of its parent Gateways. +func InferencePoolMustBeAcceptedByParent(t *testing.T, c client.Client, poolNN types.NamespacedName) { + t.Helper() + + acceptedByParentCondition := metav1.Condition{ + Type: string(gatewayv1.GatewayConditionAccepted), + Status: metav1.ConditionTrue, + Reason: string(gatewayv1.GatewayReasonAccepted), // Expecting the standard "Accepted" reason + } + + t.Logf("Waiting for InferencePool %s to be Accepted by a parent Gateway (Reason: %s)", poolNN.String(), gatewayv1.GatewayReasonAccepted) + InferencePoolMustHaveCondition(t, c, poolNN, acceptedByParentCondition) + t.Logf("InferencePool %s is Accepted by a parent Gateway (Reason: %s)", poolNN.String(), gatewayv1.GatewayReasonAccepted) +} From 856af6a18832499959f23c9b5acda745136888d6 Mon Sep 17 00:00:00 2001 From: Shotaro Kohama Date: Wed, 28 May 2025 08:02:18 -0700 Subject: [PATCH 48/53] Update `002-api-proposal/` to reflect `api/v1alpha2` inferencePool and InferenceModel (#870) * Update docs about InferencePool * Update docs about InferenceModel --- docs/proposals/002-api-proposal/README.md | 169 +++++++++++++++++----- 1 file changed, 133 insertions(+), 36 deletions(-) diff --git a/docs/proposals/002-api-proposal/README.md b/docs/proposals/002-api-proposal/README.md index f6d0c9e70..2eb1111cf 100644 --- a/docs/proposals/002-api-proposal/README.md +++ b/docs/proposals/002-api-proposal/README.md @@ -122,13 +122,94 @@ type InferencePool struct { metav1.ObjectMeta metav1.TypeMeta - Spec InferencePoolSpec + Spec InferencePoolSpec + Status InferencePoolStatus } type InferencePoolSpec struct { - // ModelServerSelector uses label selection to watch model server pods + // Selector defines a map of labels to watch model server pods // that should be included in the InferencePool. - ModelServerSelector map[string]string `json:"modelServerSelector,omitempty"` + // In some cases, implementations may translate this field to a Service selector, so this matches the simple + // map used for Service selectors instead of the full Kubernetes LabelSelector type. + // If sepecified, it will be applied to match the model server pods in the same namespace as the InferencePool. + // Cross namesoace selector is not supported. + Selector map[LabelKey]LabelValue `json:"selector"` + + // TargetPortNumber defines the port number to access the selected model servers. + // The number must be in the range 1 to 65535. + TargetPortNumber int32 `json:"targetPortNumber"` + + // EndpointPickerConfig specifies the configuration needed by the proxy to discover and connect to the endpoint + // picker service that picks endpoints for the requests routed to this pool. + EndpointPickerConfig `json:",inline"` +} + +// EndpointPickerConfig specifies the configuration needed by the proxy to discover and connect to the endpoint picker extension. +// This type is intended to be a union of mutually exclusive configuration options that we may add in the future. +type EndpointPickerConfig struct { + // Extension configures an endpoint picker as an extension service. + ExtensionRef *Extension `json:"extensionRef,omitempty"` +} + +// Extension specifies how to configure an extension that runs the endpoint picker. +type Extension struct { + // Reference is a reference to a service extension. + ExtensionReference `json:",inline"` + + // ExtensionConnection configures the connection between the gateway and the extension. + ExtensionConnection `json:",inline"` +} + +// ExtensionReference is a reference to the extension deployment. +type ExtensionReference struct { + // Group is the group of the referent. + // The default value is "", representing the Core API group. + Group *Group `json:"group,omitempty"` + + // Kind is the Kubernetes resource kind of the referent. For example + // "Service". + // + // Defaults to "Service" when not specified. + // + // ExternalName services can refer to CNAME DNS records that may live + // outside of the cluster and as such are difficult to reason about in + // terms of conformance. They also may not be safe to forward to (see + // CVE-2021-25740 for more information). Implementations MUST NOT + // support ExternalName Services. + Kind *Kind `json:"kind,omitempty"` + + // Name is the name of the referent. + Name ObjectName `json:"name"` + + // The port number on the service running the extension. When unspecified, + // implementations SHOULD infer a default value of 9002 when the Kind is + // Service. + PortNumber *PortNumber `json:"portNumber,omitempty"` +} + +// ExtensionConnection encapsulates options that configures the connection to the extension. +type ExtensionConnection struct { + // Configures how the gateway handles the case when the extension is not responsive. + // Defaults to failClose. + FailureMode *ExtensionFailureMode `json:"failureMode"` +} + +// ExtensionFailureMode defines the options for how the gateway handles the case when the extension is not +type ExtensionFailureMode string + + +// PoolStatus defines the observed state of InferencePool from a Gateway. +type PoolStatus struct { + // GatewayRef indicates the gateway that observed state of InferencePool. + GatewayRef corev1.ObjectReference `json:"parentRef"` + + // Conditions track the state of the InferencePool. + // + // Known condition types are: + // + // * "Accepted" + // * "ResolvedRefs" + Conditions []metav1.Condition `json:"conditions,omitempty"` } ``` @@ -147,6 +228,7 @@ type InferenceModel struct { metav1.TypeMeta Spec InferenceModelSpec + Status InferenceModelStatus } type InferenceModelSpec struct { @@ -172,8 +254,21 @@ type InferenceModelSpec struct { // If not specified, the target model name is defaulted to the ModelName parameter. // ModelName is often in reference to a LoRA adapter. TargetModels []TargetModel - // Reference to the InferencePool that the model registers to. It must exist in the same namespace. - PoolReference *LocalObjectReference + // PoolRef is a reference to the inference pool, the pool must exist in the same namespace. + PoolRef PoolObjectReference +} + +// PoolObjectReference identifies an API object within the namespace of the +// referrer. +type PoolObjectReference struct { + // Group is the group of the referent. + Group Group + + // Kind is kind of the referent. For example "InferencePool". + Kind Kind + + // Name is the name of the referent. + Name ObjectName } // Defines how important it is to serve the model compared to other models. @@ -181,13 +276,17 @@ type InferenceModelSpec struct { // This allows us to union this with a oneOf field in the future should we wish to adjust/extend this behavior. type Criticality string const ( - // Most important. Requests to this band will be shed last. - Critical Criticality = "Critical" - // More important than Sheddable, less important than Critical. - // Requests in this band will be shed before critical traffic. - Default Criticality = "Default" - // Least important. Requests to this band will be shed before all other bands. - Sheddable Criticality = "Sheddable" + // Critical defines the highest level of criticality. Requests to this band will be shed last. + Critical Criticality = "Critical" + + // Standard defines the base criticality level and is more important than Sheddable but less + // important than Critical. Requests in this band will be shed before critical traffic. + // Most models are expected to fall within this band. + Standard Criticality = "Standard" + + // Sheddable defines the lowest level of criticality. Requests to this band will be shed before + // all other bands. + Sheddable Criticality = "Sheddable" ) // TargetModel represents a deployed model or a LoRA adapter. The @@ -200,24 +299,16 @@ const ( type TargetModel struct { // The name of the adapter as expected by the ModelServer. Name string - // Weight is used to determine the percentage of traffic that should be + // Weight is used to determine the percentage of traffic that should be // sent to this target model when multiple versions of the model are specified. - Weight *int + Weight *int32 } -// LocalObjectReference identifies an API object within the namespace of the -// referrer. -type LocalObjectReference struct { - // Group is the group of the referent. - Group Group - - // Kind is kind of the referent. For example "InferencePool". - Kind Kind - - // Name is the name of the referent. - Name ObjectName +// InferenceModelStatus defines the observed state of InferenceModel +type InferenceModelStatus struct { + // Conditions track the state of the InferenceModel. + Conditions []metav1.Condition } - ``` ### Yaml Examples @@ -225,27 +316,32 @@ type LocalObjectReference struct { #### InferencePool(s) Here we create a pool that selects the appropriate pods ```yaml -apiVersion: inference.x-k8s.io/v1alpha1 +apiVersion: inference.x-k8s.io/v1alpha2 kind: InferencePool metadata: name: base-model-pool - modelServerSelector: - - app: llm-server +spec: + selector: + app: llm-server + targetNumber: 8080 + extensionRef: + name: infra-backend-v1-app ``` #### InferenceModel Here we consume the pool with two InferenceModels. Where `sql-code-assist` is both the name of the model and the name of the LoRA adapter on the model server. And `npc-bot` has a layer of indirection for those names, as well as a specified criticality. Both `sql-code-assist` and `npc-bot` have available LoRA adapters on the InferencePool and routing to each InferencePool happens earlier (at the K8s Gateway). ```yaml -apiVersion: inference.x-k8s.io/v1alpha1 +apiVersion: inference.x-k8s.io/v1alpha2 kind: InferenceModel metadata: name: sql-code-assist spec: modelName: sql-code-assist - poolRef: base-model-pool + poolRef: + name: base-model-pool --- -apiVersion: inference.x-k8s.io/v1alpha1 +apiVersion: inference.x-k8s.io/v1alpha2 kind: InferenceModel metadata: name: npc-bot @@ -253,11 +349,12 @@ spec: modelName: npc-bot criticality: Critical targetModels: - targetModelName: npc-bot-v1 + - name: npc-bot-v1 + weight: 50 + - name: npc-bot-v2 weight: 50 - targetModelName: npc-bot-v2 - weight: 50 - poolRef: base-model-pool + poolRef: + name: base-model-pool ``` From 60c4674c042619d9ca96348524a7a95adc807da1 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Wed, 28 May 2025 19:00:16 +0300 Subject: [PATCH 49/53] use namespacedname instead of name/namespace as separate args (#873) Signed-off-by: Nir Rozenbaum --- test/e2e/epp/e2e_test.go | 2 +- test/utils/wrappers.go | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/test/e2e/epp/e2e_test.go b/test/e2e/epp/e2e_test.go index 6fce63a39..57a1e9443 100644 --- a/test/e2e/epp/e2e_test.go +++ b/test/e2e/epp/e2e_test.go @@ -100,7 +100,7 @@ func newInferenceModel(ns string) *v1alpha2.InferenceModel { Weight: ptr.To(int32(100)), }, } - return testutils.MakeModelWrapper("inferencemodel-sample", ns). + return testutils.MakeModelWrapper(types.NamespacedName{Name: "inferencemodel-sample", Namespace: ns}). SetCriticality(v1alpha2.Critical). SetModelName(modelName). SetPoolRef(modelServerName). diff --git a/test/utils/wrappers.go b/test/utils/wrappers.go index 867118c15..4f12591a4 100644 --- a/test/utils/wrappers.go +++ b/test/utils/wrappers.go @@ -18,6 +18,7 @@ package utils import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" ) @@ -27,12 +28,12 @@ type InferenceModelWrapper struct { } // MakeModelWrapper creates a wrapper for an MakeModelWrapper. -func MakeModelWrapper(name, ns string) *InferenceModelWrapper { +func MakeModelWrapper(namespacedName types.NamespacedName) *InferenceModelWrapper { return &InferenceModelWrapper{ v1alpha2.InferenceModel{ ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: ns, + Name: namespacedName.Name, + Namespace: namespacedName.Namespace, }, Spec: v1alpha2.InferenceModelSpec{ ModelName: "", From 7c830cb1dda2bab2b677980dccf8606eb680cd18 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Wed, 28 May 2025 19:00:23 +0300 Subject: [PATCH 50/53] remove the PreCycle plugin from scheduler (#876) * remove the PreCycle plugin from scheduler Signed-off-by: Nir Rozenbaum * Apply suggestions from code review Co-authored-by: Cong Liu --------- Signed-off-by: Nir Rozenbaum Co-authored-by: Cong Liu --- pkg/epp/metrics/metrics_test.go | 5 --- ...heduler_plugin_processing_latencies_metric | 13 ------- pkg/epp/scheduling/framework/plugins.go | 8 ----- .../framework/plugins/multi/prefix/plugin.go | 35 +++++++------------ .../plugins/multi/prefix/plugin_test.go | 25 +++---------- .../scheduling/framework/scheduler_profile.go | 25 +------------ .../framework/scheduler_profile_test.go | 24 ++++--------- 7 files changed, 25 insertions(+), 110 deletions(-) diff --git a/pkg/epp/metrics/metrics_test.go b/pkg/epp/metrics/metrics_test.go index 8cee042eb..5dd97055d 100644 --- a/pkg/epp/metrics/metrics_test.go +++ b/pkg/epp/metrics/metrics_test.go @@ -571,11 +571,6 @@ func TestSchedulerPluginProcessingLatencies(t *testing.T) { { name: "multiple plugins", latencies: []pluginLatency{ - { - pluginType: "PreSchedule", - pluginName: "PluginA", - duration: 100 * time.Millisecond, - }, { pluginType: "PostSchedule", pluginName: "PluginB", diff --git a/pkg/epp/metrics/testdata/scheduler_plugin_processing_latencies_metric b/pkg/epp/metrics/testdata/scheduler_plugin_processing_latencies_metric index 669d64daa..38ac8a09d 100644 --- a/pkg/epp/metrics/testdata/scheduler_plugin_processing_latencies_metric +++ b/pkg/epp/metrics/testdata/scheduler_plugin_processing_latencies_metric @@ -1,18 +1,5 @@ # HELP inference_extension_scheduler_plugin_duration_seconds [ALPHA] Scheduler plugin processing latency distribution in seconds for each plugin type and plugin name. # TYPE inference_extension_scheduler_plugin_duration_seconds histogram -inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.0001"} 0 -inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.0002"} 0 -inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.0005"} 0 -inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.001"} 0 -inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.002"} 0 -inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.005"} 0 -inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.01"} 0 -inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.02"} 0 -inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.05"} 0 -inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="0.1"} 1 -inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginA",plugin_type="PreSchedule",le="+Inf"} 1 -inference_extension_scheduler_plugin_duration_seconds_sum{plugin_name="PluginA",plugin_type="PreSchedule"} 0.1 -inference_extension_scheduler_plugin_duration_seconds_count{plugin_name="PluginA",plugin_type="PreSchedule"} 1 inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.0001"} 0 inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.0002"} 0 inference_extension_scheduler_plugin_duration_seconds_bucket{plugin_name="PluginB",plugin_type="PostSchedule",le="0.0005"} 0 diff --git a/pkg/epp/scheduling/framework/plugins.go b/pkg/epp/scheduling/framework/plugins.go index 99c8389fa..68207abb0 100644 --- a/pkg/epp/scheduling/framework/plugins.go +++ b/pkg/epp/scheduling/framework/plugins.go @@ -22,7 +22,6 @@ import ( const ( ProfilePickerType = "ProfilePicker" - PreCyclePluginType = "PreCycle" FilterPluginType = "Filter" ScorerPluginType = "Scorer" PickerPluginType = "Picker" @@ -44,13 +43,6 @@ type ProfilePicker interface { Pick(request *types.LLMRequest, profiles map[string]*SchedulerProfile, executionResults map[string]*types.Result) map[string]*SchedulerProfile } -// PreCycle is called when the scheduler receives a new request and invokes a SchedulerProfile cycle. -// It can be used for various initialization work. -type PreCycle interface { - Plugin - PreCycle(ctx *types.SchedulingContext) -} - // Filter defines the interface for filtering a list of pods based on context. type Filter interface { Plugin diff --git a/pkg/epp/scheduling/framework/plugins/multi/prefix/plugin.go b/pkg/epp/scheduling/framework/plugins/multi/prefix/plugin.go index c3a28592e..562d8ce5a 100644 --- a/pkg/epp/scheduling/framework/plugins/multi/prefix/plugin.go +++ b/pkg/epp/scheduling/framework/plugins/multi/prefix/plugin.go @@ -114,7 +114,6 @@ func (s *schedulingContextState) Clone() types.StateData { } // compile-time type assertion -var _ framework.PreCycle = &Plugin{} var _ framework.Scorer = &Plugin{} var _ framework.PostCycle = &Plugin{} @@ -132,18 +131,6 @@ func (m *Plugin) Name() string { return "prefix-cache" } -// PreCycle initializes the prefix plugin state for the current scheduling cycle. -func (m *Plugin) PreCycle(ctx *types.SchedulingContext) { - hashes := hashPrompt(ctx, m.HashBlockSize, m.MaxPrefixBlocksToMatch) - state := &schedulingContextState{ - PrefixHashes: hashes, - PrefixCacheServers: m.matchLongestPrefix(ctx, hashes, DefaultNumServersToMatch), - } - - ctx.CycleState.Write(types.StateKey(m.Name()), state) - ctx.Logger.V(logutil.TRACE).Info(fmt.Sprintf("PreCycle, cached servers: %+v", state.PrefixCacheServers), "hashes", state.PrefixHashes) -} - // PostCycle records in the plugin cache the result of the scheduling selection. func (m *Plugin) PostCycle(ctx *types.SchedulingContext, res *types.Result) { targetPod := res.TargetPod.GetPod() @@ -160,13 +147,20 @@ func (m *Plugin) PostCycle(ctx *types.SchedulingContext, res *types.Result) { // Score returns the scoring result for the given list of pods based on context. func (m *Plugin) Score(ctx *types.SchedulingContext, pods []types.Pod) map[types.Pod]float64 { - scores := make(map[types.Pod]float64, len(pods)) - - state, err := m.getPrefixState(ctx.CycleState) - if err != nil { - ctx.Logger.Error(err, "failed to read prefix plugin cycle state") - return scores + // pre score step, hashing prompt and find longest prefix match. + hashes := hashPrompt(ctx, m.HashBlockSize, m.MaxPrefixBlocksToMatch) + numServers := DefaultNumServersToMatch + if numServers > len(pods) { + numServers = len(pods) + } + state := &schedulingContextState{ + PrefixHashes: hashes, + PrefixCacheServers: m.matchLongestPrefix(ctx, hashes, numServers), } + ctx.CycleState.Write(types.StateKey(m.Name()), state) + ctx.Logger.V(logutil.TRACE).Info(fmt.Sprintf("cached servers: %+v", state.PrefixCacheServers), "hashes", state.PrefixHashes) + // calculate the scores of pods + scores := make(map[types.Pod]float64, len(pods)) total := len(state.PrefixHashes) podScoreFunc := func(pod types.Pod) float64 { @@ -185,9 +179,6 @@ func (m *Plugin) Score(ctx *types.SchedulingContext, pods []types.Pod) map[types // matchLongestPrefix returns a map of servers and length of prefix that each server caches. func (m *Plugin) matchLongestPrefix(ctx *types.SchedulingContext, hashes []BlockHash, numServers int) map[ServerID]int { - if numServers > len(ctx.PodsSnapshot) { - numServers = len(ctx.PodsSnapshot) - } res := make(map[ServerID]int) // Use a greedy strategy to search from the longest prefix. // NOTE: It's possible to further optimize this with a binary search. diff --git a/pkg/epp/scheduling/framework/plugins/multi/prefix/plugin_test.go b/pkg/epp/scheduling/framework/plugins/multi/prefix/plugin_test.go index 65e324c99..4d8ecfdaf 100644 --- a/pkg/epp/scheduling/framework/plugins/multi/prefix/plugin_test.go +++ b/pkg/epp/scheduling/framework/plugins/multi/prefix/plugin_test.go @@ -28,7 +28,7 @@ func TestPrefixPlugin(t *testing.T) { Prompt: "aaaaaa", } ctx := types.NewSchedulingContext(context.Background(), req1, nil, pods) - plugin.PreCycle(ctx) + scores := plugin.Score(ctx, pods) state, err := plugin.getPrefixState(ctx.CycleState) assert.NoError(t, err) t.Logf("Hashes %+v, cached servers: %+v", state.PrefixHashes, state.PrefixCacheServers) @@ -36,9 +36,6 @@ func TestPrefixPlugin(t *testing.T) { // Total hashes = 2 (the first one is for the model) assert.Equal(t, 2, len(state.PrefixHashes), "number of hashes is incorrect") assert.Equal(t, 0, len(state.PrefixCacheServers), "there shouldn't be any cached servers") - - // Updated to use the new Score method signature - scores := plugin.Score(ctx, pods) assert.Equal(t, float64(0), scores[pod1], "score for pod1") assert.Equal(t, float64(0), scores[pod2], "score for pod2") @@ -52,7 +49,7 @@ func TestPrefixPlugin(t *testing.T) { Prompt: "bbbbbb", } ctx = types.NewSchedulingContext(context.Background(), req2, nil, pods) - plugin.PreCycle(ctx) + scores = plugin.Score(ctx, pods) state, err = plugin.getPrefixState(ctx.CycleState) assert.NoError(t, err) t.Logf("Hashes %+v, cached servers: %+v", state.PrefixHashes, state.PrefixCacheServers) @@ -60,9 +57,6 @@ func TestPrefixPlugin(t *testing.T) { // Total hashes = 2 (the first one is for the model) assert.Equal(t, 2, len(state.PrefixHashes), "number of hashes is incorrect") assert.Equal(t, 0, len(state.PrefixCacheServers), "there shouldn't be any cached servers") - - // Updated to use the new Score method signature - scores = plugin.Score(ctx, pods) assert.Equal(t, float64(0), scores[pod1], "score for pod1") assert.Equal(t, float64(0), scores[pod2], "score for pod2") @@ -75,7 +69,7 @@ func TestPrefixPlugin(t *testing.T) { Prompt: "aaaabbbb", } ctx = types.NewSchedulingContext(context.Background(), req3, nil, pods) - plugin.PreCycle(ctx) + scores = plugin.Score(ctx, pods) state, err = plugin.getPrefixState(ctx.CycleState) assert.NoError(t, err) t.Logf("Hashes %+v, cached servers: %+v", state.PrefixHashes, state.PrefixCacheServers) @@ -83,9 +77,6 @@ func TestPrefixPlugin(t *testing.T) { // Total hashes = 3 (the first one is for the model) assert.Equal(t, 3, len(state.PrefixHashes), "number of hashes is incorrect") assert.Equal(t, 1, len(state.PrefixCacheServers), "pod1 should have cached the aaaa prefix") - - // Updated to use the new Score method signature - scores = plugin.Score(ctx, pods) assert.Equal(t, float64(2)/float64(3), scores[pod1], "score should be 2/3 - the model and the first prefix block match") assert.Equal(t, float64(0), scores[pod2], "score for pod2") @@ -97,7 +88,7 @@ func TestPrefixPlugin(t *testing.T) { Prompt: "aaaabbbb", } ctx = types.NewSchedulingContext(context.Background(), req4, nil, pods) - plugin.PreCycle(ctx) + scores = plugin.Score(ctx, pods) state, err = plugin.getPrefixState(ctx.CycleState) assert.NoError(t, err) t.Logf("Hashes %+v, cached servers: %+v", state.PrefixHashes, state.PrefixCacheServers) @@ -105,9 +96,6 @@ func TestPrefixPlugin(t *testing.T) { // Total hashes = 3 (the first one is for the model) assert.Equal(t, 3, len(state.PrefixHashes), "number of hashes is incorrect") assert.Equal(t, 0, len(state.PrefixCacheServers), "pod1 should have cached the aaaa prefix") - - // Updated to use the new Score method signature - scores = plugin.Score(ctx, pods) assert.Equal(t, float64(0), scores[pod1], "score for pod1") assert.Equal(t, float64(0), scores[pod2], "score for pod2") @@ -119,7 +107,7 @@ func TestPrefixPlugin(t *testing.T) { Prompt: "aaaabbbbcccc", } ctx = types.NewSchedulingContext(context.Background(), req5, nil, pods) - plugin.PreCycle(ctx) + scores = plugin.Score(ctx, pods) state, err = plugin.getPrefixState(ctx.CycleState) assert.NoError(t, err) t.Logf("Hashes %+v, cached servers: %+v", state.PrefixHashes, state.PrefixCacheServers) @@ -127,9 +115,6 @@ func TestPrefixPlugin(t *testing.T) { // Total hashes = 4 (the first one is for the model) assert.Equal(t, 4, len(state.PrefixHashes), "number of hashes is incorrect") assert.Equal(t, 1, len(state.PrefixCacheServers), "pod1 should have cached the aaaa prefix") - - // Updated to use the new Score method signature - scores = plugin.Score(ctx, pods) assert.Equal(t, 0.75, scores[pod1], "score should be 0.75 - the model and the first 2 prefix blocks match") assert.Equal(t, float64(0), scores[pod2], "score for pod2") diff --git a/pkg/epp/scheduling/framework/scheduler_profile.go b/pkg/epp/scheduling/framework/scheduler_profile.go index 6809624fc..4506ae38a 100644 --- a/pkg/epp/scheduling/framework/scheduler_profile.go +++ b/pkg/epp/scheduling/framework/scheduler_profile.go @@ -29,7 +29,6 @@ import ( // NewSchedulerProfile creates a new SchedulerProfile object and returns its pointer. func NewSchedulerProfile() *SchedulerProfile { return &SchedulerProfile{ - preCyclePlugins: []PreCycle{}, filters: []Filter{}, scorers: []*WeightedScorer{}, postCyclePlugins: []PostCycle{}, @@ -40,7 +39,6 @@ func NewSchedulerProfile() *SchedulerProfile { // SchedulerProfile provides a profile configuration for the scheduler which influence routing decisions. type SchedulerProfile struct { - preCyclePlugins []PreCycle filters []Filter scorers []*WeightedScorer picker Picker @@ -48,13 +46,6 @@ type SchedulerProfile struct { PostResponsePlugins []PostResponse // TODO this field should get out of the scheduler } -// WithPreCyclePlugins sets the given plugins as the PreCycle plugins. -// If the SchedulerProfile has PreCycle plugins, this call replaces the existing plugins with the given ones. -func (p *SchedulerProfile) WithPreCyclePlugins(plugins ...PreCycle) *SchedulerProfile { - p.preCyclePlugins = plugins - return p -} - // WithFilters sets the given filter plugins as the Filter plugins. // if the SchedulerProfile has Filter plugins, this call replaces the existing plugins with the given ones. func (p *SchedulerProfile) WithFilters(filters ...Filter) *SchedulerProfile { @@ -96,9 +87,6 @@ func (p *SchedulerProfile) AddPlugins(pluginObjects ...Plugin) error { } else if scorer, ok := plugin.(Scorer); ok { // if we got a Scorer instead of WeightedScorer that's an error. return fmt.Errorf("failed to register scorer '%s' without a weight. follow function documentation to register a scorer", scorer.Name()) } - if preCyclePlugin, ok := plugin.(PreCycle); ok { - p.preCyclePlugins = append(p.preCyclePlugins, preCyclePlugin) - } if filter, ok := plugin.(Filter); ok { p.filters = append(p.filters, filter) } @@ -119,10 +107,8 @@ func (p *SchedulerProfile) AddPlugins(pluginObjects ...Plugin) error { } // RunCycle runs a SchedulerProfile cycle. In other words, it invokes all the SchedulerProfile plugins in this -// order - PreCyclePlugins, Filters, Scorers, Picker, PostCyclePlugins. After completing all, it returns the result. +// order - Filters, Scorers, Picker, PostCyclePlugins. After completing all, it returns the result. func (p *SchedulerProfile) RunCycle(ctx *types.SchedulingContext) (*types.Result, error) { - p.runPreCyclePlugins(ctx) - pods := p.runFilterPlugins(ctx) if len(pods) == 0 { return nil, errutil.Error{Code: errutil.Internal, Msg: "no pods available for the given request"} @@ -137,15 +123,6 @@ func (p *SchedulerProfile) RunCycle(ctx *types.SchedulingContext) (*types.Result return result, nil } -func (p *SchedulerProfile) runPreCyclePlugins(ctx *types.SchedulingContext) { - for _, plugin := range p.preCyclePlugins { - ctx.Logger.V(logutil.DEBUG).Info("Running pre-cycle plugin", "plugin", plugin.Name()) - before := time.Now() - plugin.PreCycle(ctx) - metrics.RecordSchedulerPluginProcessingLatency(PreCyclePluginType, plugin.Name(), time.Since(before)) - } -} - func (p *SchedulerProfile) runFilterPlugins(ctx *types.SchedulingContext) []types.Pod { loggerDebug := ctx.Logger.V(logutil.DEBUG) filteredPods := ctx.PodsSnapshot diff --git a/pkg/epp/scheduling/framework/scheduler_profile_test.go b/pkg/epp/scheduling/framework/scheduler_profile_test.go index d212f6040..9c18ea4af 100644 --- a/pkg/epp/scheduling/framework/scheduler_profile_test.go +++ b/pkg/epp/scheduling/framework/scheduler_profile_test.go @@ -61,7 +61,6 @@ func TestSchedulePlugins(t *testing.T) { { name: "all plugins executed successfully, all scorers with same weight", profile: NewSchedulerProfile(). - WithPreCyclePlugins(tp1, tp2). WithFilters(tp1, tp2). WithScorers(NewWeightedScorer(tp1, 1), NewWeightedScorer(tp2, 1)). WithPicker(pickerPlugin). @@ -79,7 +78,6 @@ func TestSchedulePlugins(t *testing.T) { { name: "all plugins executed successfully, different scorers weights", profile: NewSchedulerProfile(). - WithPreCyclePlugins(tp1, tp2). WithFilters(tp1, tp2). WithScorers(NewWeightedScorer(tp1, 60), NewWeightedScorer(tp2, 40)). WithPicker(pickerPlugin). @@ -97,7 +95,6 @@ func TestSchedulePlugins(t *testing.T) { { name: "filter all", profile: NewSchedulerProfile(). - WithPreCyclePlugins(tp1, tp2). WithFilters(tp1, tp_filterAll). WithScorers(NewWeightedScorer(tp1, 1), NewWeightedScorer(tp2, 1)). WithPicker(pickerPlugin). @@ -115,9 +112,6 @@ func TestSchedulePlugins(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { // Reset all plugins before each new test case. - for _, plugin := range test.profile.preCyclePlugins { - plugin.(*testPlugin).reset() - } for _, plugin := range test.profile.filters { plugin.(*testPlugin).reset() } @@ -159,12 +153,6 @@ func TestSchedulePlugins(t *testing.T) { t.Errorf("Unexpected output (-want +got): %v", diff) } // Validate plugin execution counts dynamically - for _, plugin := range test.profile.preCyclePlugins { - tp, _ := plugin.(*testPlugin) - if tp.PreScheduleCallCount != 1 { - t.Errorf("Plugin %s PreSchedule() called %d times, expected 1", plugin.Name(), tp.PreScheduleCallCount) - } - } for _, plugin := range test.profile.filters { tp, _ := plugin.(*testPlugin) if tp.FilterCallCount != 1 { @@ -200,6 +188,12 @@ func TestSchedulePlugins(t *testing.T) { } } +// compile-time type assertion +var _ Filter = &testPlugin{} +var _ Scorer = &testPlugin{} +var _ Picker = &testPlugin{} +var _ PostCycle = &testPlugin{} + // testPlugin is an implementation useful in unit tests. type testPlugin struct { NameRes string @@ -208,7 +202,6 @@ type testPlugin struct { ScoreRes float64 FilterCallCount int FilterRes []k8stypes.NamespacedName - PreScheduleCallCount int PostScheduleCallCount int PickCallCount int NumOfPickerCandidates int @@ -218,10 +211,6 @@ type testPlugin struct { func (tp *testPlugin) Name() string { return tp.NameRes } -func (tp *testPlugin) PreCycle(ctx *types.SchedulingContext) { - tp.PreScheduleCallCount++ -} - func (tp *testPlugin) Filter(ctx *types.SchedulingContext, pods []types.Pod) []types.Pod { tp.FilterCallCount++ return findPods(ctx, tp.FilterRes...) @@ -251,7 +240,6 @@ func (tp *testPlugin) PostCycle(ctx *types.SchedulingContext, res *types.Result) } func (tp *testPlugin) reset() { - tp.PreScheduleCallCount = 0 tp.FilterCallCount = 0 tp.ScoreCallCount = 0 tp.NumOfScoredPods = 0 From a1b7f59f03d94614fd020cd53aa69c9cc4c41c89 Mon Sep 17 00:00:00 2001 From: sina chavoshi Date: Wed, 28 May 2025 14:10:17 -0700 Subject: [PATCH 51/53] feat(conformance): Update InferencePoolResolvedRefsCondition test for E2E request validation (#866) * WIP tests for inferencepool_resolvedrefs_condition * update condition check * Add helper method for inf pool parrent status check * update manifests * update the test to match manifest * fix yaml files. * add SupportInferencePool * Add a helper function for HTTPRouteMustBeAcceptedAndResolved * Add a helper method InferencePoolMustBeAcceptedByParent * add todo for ensure http requests are routed correctly #865 * Add http tests * update to use echo server instead * fix echo server port. * Add env var to include namespace and pod name for echo server resposne. * factor out the common HTTPResponse builder * shorten wait time * remove extra space * fix yaml formatting * clean up yaml file remove white space and optional fields. * change naming convention to primary secondary consistently. * add helper method for "MakeRequestAndExpectNotFound/Success * use config instead of inferenceconfig --- .../resources/manifests/manifests.yaml | 4 - .../inferencepool_resolvedrefs_condition.go | 124 +++++++++++++----- .../inferencepool_resolvedrefs_condition.yaml | 64 ++++----- conformance/utils/config/timing.go | 8 +- conformance/utils/kubernetes/helpers.go | 15 +++ conformance/utils/traffic/traffic.go | 103 ++++++++++++--- 6 files changed, 231 insertions(+), 87 deletions(-) diff --git a/conformance/resources/manifests/manifests.yaml b/conformance/resources/manifests/manifests.yaml index b2ca4890a..e25c29466 100644 --- a/conformance/resources/manifests/manifests.yaml +++ b/conformance/resources/manifests/manifests.yaml @@ -11,7 +11,6 @@ metadata: name: gateway-conformance-infra labels: gateway-conformance: infra - --- # Namespace for application backends (potentially simulating model servers # or where InferencePools might reside in some tests). @@ -21,7 +20,6 @@ metadata: name: gateway-conformance-app-backend labels: gateway-conformance: backend - --- # Namespace for simple web server backends. This is expected by # the upstream conformance suite's Setup method. @@ -31,7 +29,6 @@ metadata: name: gateway-conformance-web-backend labels: gateway-conformance: web-backend - --- # A basic Gateway resource that allows HTTPRoutes from the same namespace. # Tests can use this as a parent reference for routes that target InferencePools. @@ -55,7 +52,6 @@ spec: # Allows HTTPRoutes to attach, which can then reference InferencePools. - group: gateway.networking.k8s.io kind: HTTPRoute - --- # --- Conformance Secondary Gateway Definition --- # A second generic Gateway resource for tests requiring multiple Gateways. diff --git a/conformance/tests/basic/inferencepool_resolvedrefs_condition.go b/conformance/tests/basic/inferencepool_resolvedrefs_condition.go index 86ca54263..25d67f89a 100644 --- a/conformance/tests/basic/inferencepool_resolvedrefs_condition.go +++ b/conformance/tests/basic/inferencepool_resolvedrefs_condition.go @@ -29,7 +29,9 @@ import ( "sigs.k8s.io/gateway-api/pkg/features" "sigs.k8s.io/gateway-api-inference-extension/conformance/tests" + "sigs.k8s.io/gateway-api-inference-extension/conformance/utils/config" k8sutils "sigs.k8s.io/gateway-api-inference-extension/conformance/utils/kubernetes" + trafficutils "sigs.k8s.io/gateway-api-inference-extension/conformance/utils/traffic" ) func init() { @@ -46,59 +48,115 @@ var InferencePoolParentStatus = suite.ConformanceTest{ }, Test: func(t *testing.T, s *suite.ConformanceTestSuite) { const ( - appBackendNamespace = "gateway-conformance-app-backend" - infraNamespace = "gateway-conformance-infra" - poolName = "multi-gateway-pool" - sharedGateway1Name = "conformance-gateway" - sharedGateway2Name = "conformance-secondary-gateway" - httpRoute1Name = "httproute-for-gw1" - httpRoute2Name = "httproute-for-gw2" + appBackendNamespace = "gateway-conformance-app-backend" + infraNamespace = "gateway-conformance-infra" + poolName = "multi-gateway-pool" + sharedPrimaryGatewayName = "conformance-gateway" + sharedSecondaryGatewayName = "conformance-secondary-gateway" + httpRoutePrimaryName = "httproute-for-primary-gw" + httpRouteSecondaryName = "httproute-for-secondary-gw" + hostnamePrimaryGw = "primary.example.com" + pathPrimaryGw = "/primary-gateway-test" + hostnameSecondaryGw = "secondary.example.com" + pathSecondaryGw = "/secondary-gateway-test" + backendServicePodName = "infra-backend-deployment" ) poolNN := types.NamespacedName{Name: poolName, Namespace: appBackendNamespace} - httpRoute1NN := types.NamespacedName{Name: httpRoute1Name, Namespace: appBackendNamespace} - httpRoute2NN := types.NamespacedName{Name: httpRoute2Name, Namespace: appBackendNamespace} - gateway1NN := types.NamespacedName{Name: sharedGateway1Name, Namespace: infraNamespace} - gateway2NN := types.NamespacedName{Name: sharedGateway2Name, Namespace: infraNamespace} + httpRoutePrimaryNN := types.NamespacedName{Name: httpRoutePrimaryName, Namespace: appBackendNamespace} + httpRouteSecondaryNN := types.NamespacedName{Name: httpRouteSecondaryName, Namespace: appBackendNamespace} + gatewayPrimaryNN := types.NamespacedName{Name: sharedPrimaryGatewayName, Namespace: infraNamespace} + gatewaySecondaryNN := types.NamespacedName{Name: sharedSecondaryGatewayName, Namespace: infraNamespace} - k8sutils.HTTPRouteMustBeAcceptedAndResolved(t, s.Client, s.TimeoutConfig, httpRoute1NN, gateway1NN) - k8sutils.HTTPRouteMustBeAcceptedAndResolved(t, s.Client, s.TimeoutConfig, httpRoute2NN, gateway2NN) + inferenceTimeoutConfig := config.DefaultInferenceExtensionTimeoutConfig() - t.Run("InferencePool should show Accepted:True by parents when referenced by multiple HTTPRoutes", func(t *testing.T) { + k8sutils.HTTPRouteMustBeAcceptedAndResolved(t, s.Client, s.TimeoutConfig, httpRoutePrimaryNN, gatewayPrimaryNN) + k8sutils.HTTPRouteMustBeAcceptedAndResolved(t, s.Client, s.TimeoutConfig, httpRouteSecondaryNN, gatewaySecondaryNN) + + gwPrimaryAddr := k8sutils.GetGatewayEndpoint(t, s.Client, s.TimeoutConfig, gatewayPrimaryNN) + gwSecondaryAddr := k8sutils.GetGatewayEndpoint(t, s.Client, s.TimeoutConfig, gatewaySecondaryNN) + + t.Run("InferencePool should show Accepted:True by parents and be routable via multiple HTTPRoutes", func(t *testing.T) { k8sutils.InferencePoolMustBeAcceptedByParent(t, s.Client, poolNN) - // TODO(#865) ensure requests are correctly routed to this InferencePool. t.Logf("InferencePool %s has parent status Accepted:True as expected with two references.", poolNN.String()) + + trafficutils.MakeRequestAndExpectSuccess( + t, + s.RoundTripper, + s.TimeoutConfig, + gwPrimaryAddr, + hostnamePrimaryGw, + pathPrimaryGw, + backendServicePodName, + appBackendNamespace, + ) + + trafficutils.MakeRequestAndExpectSuccess( + t, + s.RoundTripper, + s.TimeoutConfig, + gwSecondaryAddr, + hostnameSecondaryGw, + pathSecondaryGw, + backendServicePodName, + appBackendNamespace, + ) }) - t.Run("Delete httproute-for-gw1", func(t *testing.T) { - httproute1 := &gatewayv1.HTTPRoute{ - ObjectMeta: metav1.ObjectMeta{Name: httpRoute1NN.Name, Namespace: httpRoute1NN.Namespace}, + t.Run("Delete httproute-for-primary-gw and verify InferencePool status and routing via secondary gw", func(t *testing.T) { + httpRoutePrimary := &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: httpRoutePrimaryNN.Name, Namespace: httpRoutePrimaryNN.Namespace}, } - t.Logf("Deleting HTTPRoute %s", httpRoute1NN.String()) - require.NoError(t, s.Client.Delete(context.TODO(), httproute1), "failed to delete httproute-for-gw1") - time.Sleep(s.TimeoutConfig.GatewayMustHaveCondition) - }) + t.Logf("Deleting HTTPRoute %s", httpRoutePrimaryNN.String()) + require.NoError(t, s.Client.Delete(context.TODO(), httpRoutePrimary), "failed to delete httproute-for-primary-gw") + + t.Logf("Waiting for %v for Gateway conditions to update after deleting HTTPRoute %s", inferenceTimeoutConfig.HTTPRouteDeletionReconciliationTimeout, httpRoutePrimaryNN.String()) // + time.Sleep(inferenceTimeoutConfig.HTTPRouteDeletionReconciliationTimeout) // - t.Run("InferencePool should still show Accepted:True by parent after one HTTPRoute is deleted", func(t *testing.T) { k8sutils.InferencePoolMustBeAcceptedByParent(t, s.Client, poolNN) - // TODO(#865) ensure requests are correctly routed to this InferencePool. t.Logf("InferencePool %s still has parent status Accepted:True as expected with one reference remaining.", poolNN.String()) + + trafficutils.MakeRequestAndExpectSuccess( + t, + s.RoundTripper, + s.TimeoutConfig, + gwSecondaryAddr, + hostnameSecondaryGw, + pathSecondaryGw, + backendServicePodName, + appBackendNamespace, + ) + + trafficutils.MakeRequestAndExpectNotFound( + t, + s.RoundTripper, + s.TimeoutConfig, + gwPrimaryAddr, + hostnamePrimaryGw, + pathPrimaryGw, + ) }) - t.Run("Delete httproute-for-gw2", func(t *testing.T) { - httproute2 := &gatewayv1.HTTPRoute{ - ObjectMeta: metav1.ObjectMeta{Name: httpRoute2NN.Name, Namespace: httpRoute2NN.Namespace}, + t.Run("Delete httproute-for-secondary-gw and verify InferencePool has no parent statuses and is not routable", func(t *testing.T) { + httpRouteSecondary := &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: httpRouteSecondaryNN.Name, Namespace: httpRouteSecondaryNN.Namespace}, } - t.Logf("Deleting HTTPRoute %s", httpRoute2NN.String()) - require.NoError(t, s.Client.Delete(context.TODO(), httproute2), "failed to delete httproute-for-gw2") - }) + t.Logf("Deleting HTTPRoute %s", httpRouteSecondaryNN.String()) + require.NoError(t, s.Client.Delete(context.TODO(), httpRouteSecondary), "failed to delete httproute-for-secondary-gw") - t.Run("InferencePool should have no parent statuses after all HTTPRoutes are deleted", func(t *testing.T) { - t.Logf("Waiting for InferencePool %s to have no parent statuses.", poolNN.String()) k8sutils.InferencePoolMustHaveNoParents(t, s.Client, poolNN) t.Logf("InferencePool %s correctly shows no parent statuses, indicating it's no longer referenced.", poolNN.String()) + + trafficutils.MakeRequestAndExpectNotFound( + t, + s.RoundTripper, + s.TimeoutConfig, + gwSecondaryAddr, + hostnameSecondaryGw, + pathSecondaryGw, + ) }) - t.Logf("InferencePoolResolvedRefsCondition completed.") + t.Logf("InferencePoolResolvedRefsCondition test completed.") }, } diff --git a/conformance/tests/basic/inferencepool_resolvedrefs_condition.yaml b/conformance/tests/basic/inferencepool_resolvedrefs_condition.yaml index 008893117..4b58f6798 100644 --- a/conformance/tests/basic/inferencepool_resolvedrefs_condition.yaml +++ b/conformance/tests/basic/inferencepool_resolvedrefs_condition.yaml @@ -3,7 +3,7 @@ # This manifest defines the initial resources for the # inferencepool_resolvedrefs_condition.go conformance test. -# --- Backend Deployment (using agnhost echo server) --- +# --- Backend Deployment (using standard Gateway API echoserver) --- # This Deployment provides Pods for the InferencePool to select. apiVersion: apps/v1 kind: Deployment @@ -13,7 +13,6 @@ metadata: labels: app: infra-backend spec: - replicas: 1 selector: matchLabels: app: infra-backend @@ -23,22 +22,30 @@ spec: app: infra-backend spec: containers: - - name: agnhost-echo - image: k8s.gcr.io/e2e-test-images/agnhost:2.39 - args: - - serve-hostname - - --port=8080 + - name: echoserver + image: gcr.io/k8s-staging-gateway-api/echo-basic:v20240412-v1.0.0-394-g40c666fd ports: - - name: http - containerPort: 8080 + - containerPort: 3000 readinessProbe: httpGet: path: / - port: 8080 + port: 3000 initialDelaySeconds: 3 periodSeconds: 5 failureThreshold: 2 - + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP --- # --- Backend Service --- # Service for the infra-backend-deployment. @@ -52,36 +59,30 @@ spec: app: infra-backend ports: - name: http - protocol: TCP - port: 8080 - targetPort: 8080 + port: 3000 + targetPort: 3000 - name: epp port: 9002 targetPort: 9002 - --- # --- InferencePool Definition --- apiVersion: inference.networking.x-k8s.io/v1alpha2 kind: InferencePool metadata: - name: multi-gateway-pool # Name used in the Go test - namespace: gateway-conformance-app-backend # Defined in base manifests.yaml + name: multi-gateway-pool + namespace: gateway-conformance-app-backend spec: - # --- Selector (Required) --- - # Selects the Pods belonging to this pool. selector: app: "infra-backend" - # --- Target Port (Required) --- - targetPortNumber: 8080 + targetPortNumber: 3000 extensionRef: name: infra-backend-svc - --- -# --- HTTPRoute for Gateway 1 (conformance-gateway) --- +# --- HTTPRoute for Primary Gateway (conformance-gateway) --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: - name: httproute-for-gw1 + name: httproute-for-primary-gw namespace: gateway-conformance-app-backend spec: parentRefs: @@ -91,24 +92,23 @@ spec: namespace: gateway-conformance-infra sectionName: http hostnames: - - "gw1.example.com" + - "primary.example.com" rules: - backendRefs: - group: inference.networking.x-k8s.io kind: InferencePool name: multi-gateway-pool - port: 8080 + port: 3000 matches: - path: type: PathPrefix - value: /conformance-gateway-test - + value: /primary-gateway-test --- -# --- HTTPRoute for Gateway 2 (conformance-secondary-gateway) --- +# --- HTTPRoute for Secondary Gateway (conformance-secondary-gateway) --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: - name: httproute-for-gw2 + name: httproute-for-secondary-gw namespace: gateway-conformance-app-backend spec: parentRefs: @@ -124,8 +124,8 @@ spec: - group: inference.networking.x-k8s.io kind: InferencePool name: multi-gateway-pool - port: 8080 + port: 3000 matches: - path: type: PathPrefix - value: /gateway-2-test + value: /secondary-gateway-test diff --git a/conformance/utils/config/timing.go b/conformance/utils/config/timing.go index 95769d24a..f5d4eeb52 100644 --- a/conformance/utils/config/timing.go +++ b/conformance/utils/config/timing.go @@ -37,13 +37,19 @@ type InferenceExtensionTimeoutConfig struct { // GatewayObjectPollInterval is the polling interval used when waiting for a Gateway object to appear. GatewayObjectPollInterval time.Duration + + // HTTPRouteDeletionReconciliationTimeout is the time to wait for controllers to reconcile + // state after an HTTPRoute is deleted, before checking dependent resources or traffic. + HTTPRouteDeletionReconciliationTimeout time.Duration } +// DefaultInferenceExtensionTimeoutConfig returns a new InferenceExtensionTimeoutConfig with default values. func DefaultInferenceExtensionTimeoutConfig() InferenceExtensionTimeoutConfig { return InferenceExtensionTimeoutConfig{ - TimeoutConfig: gatewayconfig.DefaultTimeoutConfig(), + TimeoutConfig: gatewayconfig.DefaultTimeoutConfig(), // Initialize embedded struct InferencePoolMustHaveConditionTimeout: 300 * time.Second, InferencePoolMustHaveConditionInterval: 10 * time.Second, GatewayObjectPollInterval: 5 * time.Second, + HTTPRouteDeletionReconciliationTimeout: 5 * time.Second, } } diff --git a/conformance/utils/kubernetes/helpers.go b/conformance/utils/kubernetes/helpers.go index a862cfbd7..2e866ca62 100644 --- a/conformance/utils/kubernetes/helpers.go +++ b/conformance/utils/kubernetes/helpers.go @@ -256,3 +256,18 @@ func InferencePoolMustBeAcceptedByParent(t *testing.T, c client.Client, poolNN t InferencePoolMustHaveCondition(t, c, poolNN, acceptedByParentCondition) t.Logf("InferencePool %s is Accepted by a parent Gateway (Reason: %s)", poolNN.String(), gatewayv1.GatewayReasonAccepted) } + +// GetGatewayEndpoint waits for the specified Gateway to have at least one address +// and returns the address in "host:port" format. +// It leverages the upstream Gateway API's WaitForGatewayAddress. +func GetGatewayEndpoint(t *testing.T, k8sClient client.Client, timeoutConfig gatewayapiconfig.TimeoutConfig, gatewayNN types.NamespacedName) string { + t.Helper() + + t.Logf("Waiting for Gateway %s/%s to get an address...", gatewayNN.Namespace, gatewayNN.Name) + gwAddr, err := gatewayk8sutils.WaitForGatewayAddress(t, k8sClient, timeoutConfig, gatewayk8sutils.NewGatewayRef(gatewayNN)) + require.NoError(t, err, "failed to get Gateway address for %s", gatewayNN.String()) + require.NotEmpty(t, gwAddr, "Gateway %s has no address", gatewayNN.String()) + + t.Logf("Gateway %s/%s has address: %s", gatewayNN.Namespace, gatewayNN.Name, gwAddr) + return gwAddr +} diff --git a/conformance/utils/traffic/traffic.go b/conformance/utils/traffic/traffic.go index 4f13f980a..21b4b4bdf 100644 --- a/conformance/utils/traffic/traffic.go +++ b/conformance/utils/traffic/traffic.go @@ -1,22 +1,91 @@ -/* -Copyright 2025 The Kubernetes Authors. +// Add these functions to conformance/utils/traffic/traffic.go -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 +package traffic - http://www.apache.org/licenses/LICENSE-2.0 +import ( + "net/http" + "testing" -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. -*/ + gwconfig "sigs.k8s.io/gateway-api/conformance/utils/config" + gwhttp "sigs.k8s.io/gateway-api/conformance/utils/http" + "sigs.k8s.io/gateway-api/conformance/utils/roundtripper" +) -// Package traffic contains helper functions specifically for generating, -// sending, and validating network traffic related to inference workloads -// within the Gateway API Inference Extension conformance tests. -package traffic +// BuildExpectedHTTPResponse constructs a gwhttp.ExpectedResponse for common test scenarios. +// For 200 OK responses, it sets up ExpectedRequest to check Host and Path. +// For other status codes (like 404), ExpectedRequest is nil as detailed backend checks are usually skipped by CompareRequest. +func BuildExpectedHTTPResponse( + requestHost string, + requestPath string, + expectedStatusCode int, + backendName string, + backendNamespace string, +) gwhttp.ExpectedResponse { + resp := gwhttp.ExpectedResponse{ + Request: gwhttp.Request{ + Host: requestHost, + Path: requestPath, + Method: "GET", + }, + Response: gwhttp.Response{ + StatusCode: expectedStatusCode, + }, + Backend: backendName, + Namespace: backendNamespace, + } + + if expectedStatusCode == http.StatusOK { + resp.ExpectedRequest = &gwhttp.ExpectedRequest{ + Request: gwhttp.Request{ + Host: requestHost, + Path: requestPath, + Method: "GET", + }, + } + } + return resp +} + +// MakeRequestAndExpectSuccess is a helper function that builds an expected success (200 OK) response +// and then calls MakeRequestAndExpectEventuallyConsistentResponse. +func MakeRequestAndExpectSuccess( + t *testing.T, + r roundtripper.RoundTripper, + timeoutConfig gwconfig.TimeoutConfig, + gatewayAddress string, + requestHost string, + requestPath string, + backendName string, + backendNamespace string, +) { + t.Helper() + expectedResponse := BuildExpectedHTTPResponse( + requestHost, + requestPath, + http.StatusOK, + backendName, + backendNamespace, + ) + gwhttp.MakeRequestAndExpectEventuallyConsistentResponse(t, r, timeoutConfig, gatewayAddress, expectedResponse) +} -// TODO: Add helpers for specific inference protocols or request patterns as needed. +// MakeRequestAndExpectNotFound is a helper function that builds an expected not found (404) response +// and then calls MakeRequestAndExpectEventuallyConsistentResponse. +func MakeRequestAndExpectNotFound( + t *testing.T, + r roundtripper.RoundTripper, + timeoutConfig gwconfig.TimeoutConfig, + gatewayAddress string, + requestHost string, + requestPath string, +) { + t.Helper() + expectedResponse := BuildExpectedHTTPResponse( + requestHost, + requestPath, + http.StatusNotFound, + "", // Backend name not relevant for 404 + "", // Backend namespace not relevant for 404 + ) + gwhttp.MakeRequestAndExpectEventuallyConsistentResponse(t, r, timeoutConfig, gatewayAddress, expectedResponse) +} From 8d4c23f115af058d63d2714fc8d9874a32a13927 Mon Sep 17 00:00:00 2001 From: Nir Rozenbaum Date: Thu, 29 May 2025 03:58:16 +0300 Subject: [PATCH 52/53] minor changes to saturation detector (#882) * small changes to saturation detector Signed-off-by: Nir Rozenbaum * var rename Signed-off-by: Nir Rozenbaum --------- Signed-off-by: Nir Rozenbaum --- pkg/epp/saturationdetector/config.go | 17 ++- .../saturationdetector/saturationdetector.go | 74 ++++------ .../saturationdetector_test.go | 138 +++++++++--------- 3 files changed, 104 insertions(+), 125 deletions(-) diff --git a/pkg/epp/saturationdetector/config.go b/pkg/epp/saturationdetector/config.go index 6ca8d8b88..78a5833e4 100644 --- a/pkg/epp/saturationdetector/config.go +++ b/pkg/epp/saturationdetector/config.go @@ -42,8 +42,7 @@ const ( EnvSdMetricsStalenessThreshold = "SD_METRICS_STALENESS_THRESHOLD" ) -// LoadConfigFromEnv loads SaturationDetector Config from environment -// variables. +// LoadConfigFromEnv loads SaturationDetector Config from environment variables. func LoadConfigFromEnv() *Config { // Use a default logger for initial configuration loading. logger := log.Log.WithName("saturation-detector-config") @@ -51,11 +50,21 @@ func LoadConfigFromEnv() *Config { cfg := &Config{} cfg.QueueDepthThreshold = envutil.GetEnvInt(EnvSdQueueDepthThreshold, DefaultQueueDepthThreshold, logger) + if cfg.QueueDepthThreshold <= 0 { + cfg.QueueDepthThreshold = DefaultQueueDepthThreshold + } + cfg.KVCacheUtilThreshold = envutil.GetEnvFloat(EnvSdKVCacheUtilThreshold, DefaultKVCacheUtilThreshold, logger) + if cfg.KVCacheUtilThreshold <= 0 || cfg.KVCacheUtilThreshold >= 1 { + cfg.KVCacheUtilThreshold = DefaultKVCacheUtilThreshold + } + cfg.MetricsStalenessThreshold = envutil.GetEnvDuration(EnvSdMetricsStalenessThreshold, DefaultMetricsStalenessThreshold, logger) + if cfg.MetricsStalenessThreshold <= 0 { + cfg.MetricsStalenessThreshold = DefaultMetricsStalenessThreshold + } // NewDetector validates the config and assigns defaults. - logger.Info("SaturationDetector configuration loaded from env", - "config", fmt.Sprintf("%+v", cfg)) + logger.Info("SaturationDetector configuration loaded from env", "config", fmt.Sprintf("%+v", cfg)) return cfg } diff --git a/pkg/epp/saturationdetector/saturationdetector.go b/pkg/epp/saturationdetector/saturationdetector.go index ccd0ce598..b5d92867f 100644 --- a/pkg/epp/saturationdetector/saturationdetector.go +++ b/pkg/epp/saturationdetector/saturationdetector.go @@ -41,16 +41,6 @@ import ( logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" ) -// clock allows mocking time in tests. -type clock interface { - now() time.Time -} - -// realClock provides the real time. -type realClock struct{} - -func (c realClock) now() time.Time { return time.Now() } - const ( // loggerName is the name to use for loggers created by this package. loggerName = "SaturationDetector" @@ -92,29 +82,25 @@ type Datastore interface { // more beneficial. type Detector struct { datastore Datastore - config Config - clock clock + config *Config } // NewDetector creates a new SaturationDetector. // The datastore is expected to provide access to live/recently-updated pod // metrics. // The config provides the thresholds for determining saturation. -func NewDetector(config Config, datastore Datastore, logger logr.Logger) (*Detector, error) { +func NewDetector(config *Config, datastore Datastore, logger logr.Logger) (*Detector, error) { if datastore == nil { return nil, ErrNilDatastore } - if config.MetricsStalenessThreshold <= 0 { - config.MetricsStalenessThreshold = DefaultMetricsStalenessThreshold - } logger.WithName(loggerName).V(logutil.DEFAULT).Info("Creating new SaturationDetector", "queueDepthThreshold", config.QueueDepthThreshold, "kvCacheUtilThreshold", config.KVCacheUtilThreshold, "metricsStalenessThreshold", config.MetricsStalenessThreshold.String()) + return &Detector{ datastore: datastore, config: config, - clock: realClock{}, }, nil } @@ -137,8 +123,6 @@ func (d *Detector) IsSaturated(ctx context.Context) bool { return true } - now := d.clock.now() - foundPodWithGoodCapacity := false for _, podMetric := range allPodsMetrics { metrics := podMetric.GetMetrics() podNn := "unknown-pod" @@ -147,44 +131,38 @@ func (d *Detector) IsSaturated(ctx context.Context) bool { } if metrics == nil { - logger.V(logutil.VERBOSE).Info("Pod has nil metrics, skipping for saturation check", + logger.V(logutil.TRACE).Info("Pod has nil metrics, skipping for saturation check", "pod", podNn) continue } - // 1. Check for metric staleness - if now.Sub(metrics.UpdateTime) > d.config.MetricsStalenessThreshold { - logger.V(logutil.VERBOSE).Info("Pod metrics are stale, considered as not having good capacity", - "pod", podNn, - "updateTime", metrics.UpdateTime, - "stalenessThreshold", d.config.MetricsStalenessThreshold) + // Check for metric staleness + if time.Since(metrics.UpdateTime) > d.config.MetricsStalenessThreshold { + logger.V(logutil.TRACE).Info("Pod metrics are stale, considered as not having good capacity", + "pod", podNn, "updateTime", metrics.UpdateTime, "stalenessThreshold", d.config.MetricsStalenessThreshold) continue } - // 2. Check queue depth - isQueueGood := metrics.WaitingQueueSize <= d.config.QueueDepthThreshold - - // 3. Check KV cache utilization - isKVCacheGood := metrics.KVCacheUsagePercent <= d.config.KVCacheUtilThreshold - - if isQueueGood && isKVCacheGood { - logger.V(logutil.VERBOSE).Info("Found pod with good capacity", - "pod", podNn, - "waitingQueue", metrics.WaitingQueueSize, - "queueThreshold", d.config.QueueDepthThreshold, - "kvCacheUtil", metrics.KVCacheUsagePercent, - "kvCacheThreshold", d.config.KVCacheUtilThreshold) - foundPodWithGoodCapacity = true - // Found at least one pod with good capacity, so system is NOT saturated. - break + // Check queue depth + if metrics.WaitingQueueSize > d.config.QueueDepthThreshold { + logger.V(logutil.TRACE).Info("Pod WaitingQueueSize is above threshold, considered as not having good capacity", + "pod", podNn, "waitingQueueSize", metrics.WaitingQueueSize, "threshold", d.config.QueueDepthThreshold) + continue // WaitingQueueSize is above threshold, considered saturated. } - } - if !foundPodWithGoodCapacity { - logger.V(logutil.VERBOSE).Info("No pods found with good capacity; system is considered SATURATED.") - return true + // Check KV cache utilization + if metrics.KVCacheUsagePercent > d.config.KVCacheUtilThreshold { + logger.V(logutil.TRACE).Info("Pod KVCacheUsagePercent is above threshold, considered as not having good capacity", + "pod", podNn, "kvCacheUsagePercent", metrics.KVCacheUsagePercent, "threshold", d.config.KVCacheUtilThreshold) + continue // KVCacheUsagePercent is above threshold, considered saturated. + } + + logger.V(logutil.TRACE).Info("Found pod with good capacity", "pod", podNn, "waitingQueue", metrics.WaitingQueueSize, + "queueThreshold", d.config.QueueDepthThreshold, "kvCacheUtil", metrics.KVCacheUsagePercent, "kvCacheThreshold", d.config.KVCacheUtilThreshold) + + return false // Found at least one pod with good capacity, so system is NOT saturated. } - logger.V(logutil.VERBOSE).Info("System is considered NOT saturated (at least one pod has good capacity).") - return false + logger.V(logutil.VERBOSE).Info("No pods found with good capacity; system is considered SATURATED.") + return true } diff --git a/pkg/epp/saturationdetector/saturationdetector_test.go b/pkg/epp/saturationdetector/saturationdetector_test.go index d9810c9a1..80fbda4ba 100644 --- a/pkg/epp/saturationdetector/saturationdetector_test.go +++ b/pkg/epp/saturationdetector/saturationdetector_test.go @@ -19,7 +19,9 @@ package saturationdetector import ( "context" "errors" - "sync" + "fmt" + "os" + "strconv" "testing" "time" @@ -44,22 +46,6 @@ func (fds *mockDatastore) PodGetAll() []backendmetrics.PodMetrics { return pm } -// mockClock allows controlling time in tests. -type mockClock struct { - mu sync.RWMutex - time time.Time -} - -func newMockClock(t time.Time) *mockClock { - return &mockClock{time: t} -} - -func (c *mockClock) now() time.Time { - c.mu.RLock() - defer c.mu.RUnlock() - return c.time -} - func newMockPodMetrics(name string, metrics *backendmetrics.MetricsState) *backendmetrics.FakePodMetrics { return &backendmetrics.FakePodMetrics{ Pod: &backend.Pod{ @@ -73,61 +59,82 @@ func newMockPodMetrics(name string, metrics *backendmetrics.MetricsState) *backe func TestNewDetector(t *testing.T) { tests := []struct { - name string - config Config - datastore Datastore - expectError error - expectedStalenessThresh time.Duration + name string + config *Config + datastore Datastore + expectError error + expectedQueueDepthThreshold int + expectedKVCacheUtilThreshold float64 + expectedStalenessThreshold time.Duration }{ { name: "Valid config", - config: Config{ + config: &Config{ QueueDepthThreshold: 10, KVCacheUtilThreshold: 0.8, MetricsStalenessThreshold: 100 * time.Millisecond, }, - datastore: &mockDatastore{}, - expectError: nil, - expectedStalenessThresh: 100 * time.Millisecond, + datastore: &mockDatastore{}, + expectError: nil, + expectedQueueDepthThreshold: 10, + expectedKVCacheUtilThreshold: 0.8, + expectedStalenessThreshold: 100 * time.Millisecond, }, { - name: "Nil datastore", - config: Config{}, - datastore: nil, - expectError: ErrNilDatastore, - expectedStalenessThresh: DefaultMetricsStalenessThreshold, // Default will be set if error didn't occur first + name: "Nil datastore", + config: &Config{}, + datastore: nil, + expectError: ErrNilDatastore, }, { - name: "Zero staleness threshold uses default", - config: Config{ - QueueDepthThreshold: 5, - KVCacheUtilThreshold: 0.9, - MetricsStalenessThreshold: 0, // Should use default + name: "invalid thresholds, fallback to default", + config: &Config{ + QueueDepthThreshold: -1, + KVCacheUtilThreshold: -5, + MetricsStalenessThreshold: 0, + }, + datastore: &mockDatastore{}, + expectError: nil, + expectedQueueDepthThreshold: DefaultQueueDepthThreshold, + expectedKVCacheUtilThreshold: DefaultKVCacheUtilThreshold, + expectedStalenessThreshold: DefaultMetricsStalenessThreshold, + }, + { + name: "kv cache threshold above range, fallback to default", + config: &Config{ + QueueDepthThreshold: 10, + KVCacheUtilThreshold: 1.5, + MetricsStalenessThreshold: 100 * time.Millisecond, }, - datastore: &mockDatastore{}, - expectError: nil, - expectedStalenessThresh: DefaultMetricsStalenessThreshold, + datastore: &mockDatastore{}, + expectError: nil, + expectedQueueDepthThreshold: 10, + expectedKVCacheUtilThreshold: DefaultKVCacheUtilThreshold, + expectedStalenessThreshold: 100 * time.Millisecond, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - detector, err := NewDetector(tt.config, tt.datastore, logr.Discard()) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // validate configuration values are loaded from env vars properly, including the use of default values when provided value is invalid. + os.Setenv(EnvSdQueueDepthThreshold, strconv.Itoa(test.config.QueueDepthThreshold)) + os.Setenv(EnvSdKVCacheUtilThreshold, fmt.Sprintf("%v", test.config.KVCacheUtilThreshold)) + os.Setenv(EnvSdMetricsStalenessThreshold, test.config.MetricsStalenessThreshold.String()) + detector, err := NewDetector(LoadConfigFromEnv(), test.datastore, logr.Discard()) - if !errors.Is(err, tt.expectError) { - t.Errorf("NewDetector() error = %v, wantErr %v", err, tt.expectError) + if !errors.Is(err, test.expectError) { + t.Errorf("NewDetector() error = %v, wantErr %v", err, test.expectError) } if err == nil && detector != nil { - detector.clock = newMockClock(time.Now()) - if detector.config.MetricsStalenessThreshold != tt.expectedStalenessThresh { - t.Errorf("NewDetector() MetricsStalenessThreshold = %v, want %v", detector.config.MetricsStalenessThreshold, tt.expectedStalenessThresh) + if detector.config.QueueDepthThreshold != test.expectedQueueDepthThreshold { + t.Errorf("NewDetector() QueueDepthThreshold = %d, want %d", detector.config.QueueDepthThreshold, test.expectedQueueDepthThreshold) } - if detector.config.QueueDepthThreshold != tt.config.QueueDepthThreshold { - t.Errorf("NewDetector() QueueDepthThreshold = %d, want %d", detector.config.QueueDepthThreshold, tt.config.QueueDepthThreshold) + if detector.config.KVCacheUtilThreshold != test.expectedKVCacheUtilThreshold { + t.Errorf("NewDetector() KVCacheUtilThreshold = %f, want %f", detector.config.KVCacheUtilThreshold, test.expectedKVCacheUtilThreshold) } - if detector.config.KVCacheUtilThreshold != tt.config.KVCacheUtilThreshold { - t.Errorf("NewDetector() KVCacheUtilThreshold = %f, want %f", detector.config.KVCacheUtilThreshold, tt.config.KVCacheUtilThreshold) + if detector.config.MetricsStalenessThreshold != test.expectedStalenessThreshold { + t.Errorf("NewDetector() MetricsStalenessThreshold = %v, want %v", detector.config.MetricsStalenessThreshold, test.expectedStalenessThreshold) } } }) @@ -136,7 +143,7 @@ func TestNewDetector(t *testing.T) { func TestDetector_IsSaturated(t *testing.T) { baseTime := time.Now() - defaultConfig := Config{ + defaultConfig := &Config{ QueueDepthThreshold: 5, KVCacheUtilThreshold: 0.90, MetricsStalenessThreshold: 100 * time.Millisecond, @@ -144,7 +151,7 @@ func TestDetector_IsSaturated(t *testing.T) { tests := []struct { name string - config Config + config *Config pods []*backendmetrics.FakePodMetrics expectedSaturat bool }{ @@ -307,18 +314,6 @@ func TestDetector_IsSaturated(t *testing.T) { }, expectedSaturat: false, }, - { - name: "Metrics age exactly at staleness threshold", - config: defaultConfig, - pods: []*backendmetrics.FakePodMetrics{ - newMockPodMetrics("pod1", &backendmetrics.MetricsState{ - UpdateTime: baseTime.Add(-defaultConfig.MetricsStalenessThreshold), // Exactly at threshold (good) - WaitingQueueSize: 1, - KVCacheUsagePercent: 0.1, - }), - }, - expectedSaturat: false, - }, { name: "Metrics age just over staleness threshold", config: defaultConfig, @@ -333,18 +328,15 @@ func TestDetector_IsSaturated(t *testing.T) { }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockDS := &mockDatastore{pods: tt.pods} - - detector, err := NewDetector(tt.config, mockDS, logr.Discard()) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + detector, err := NewDetector(test.config, &mockDatastore{pods: test.pods}, logr.Discard()) if err != nil { t.Fatalf("NewDetector() failed: %v", err) } - detector.clock = newMockClock(baseTime) - if got := detector.IsSaturated(context.Background()); got != tt.expectedSaturat { - t.Errorf("IsSaturated() = %v, want %v", got, tt.expectedSaturat) + if got := detector.IsSaturated(context.Background()); got != test.expectedSaturat { + t.Errorf("IsSaturated() = %v, want %v", got, test.expectedSaturat) } }) } From 3491ddd956dc093eec0673b51339034d74151b12 Mon Sep 17 00:00:00 2001 From: Luke Van Drie Date: Tue, 1 Apr 2025 17:54:00 +0000 Subject: [PATCH 53/53] Add flow controller. --- cmd/epp/main.go | 48 +- pkg/epp/flowcontroller/config/config.go | 185 +++ pkg/epp/flowcontroller/config/config_test.go | 316 +++++ pkg/epp/flowcontroller/flowcontroller.go | 976 +++++++++++++++ pkg/epp/flowcontroller/flowcontroller_test.go | 780 ++++++++++++ pkg/epp/flowcontroller/flowitem.go | 159 +++ pkg/epp/flowcontroller/flowregistry.go | 926 +++++++++++++++ pkg/epp/flowcontroller/flowregistry_test.go | 1049 +++++++++++++++++ .../plugins/dispatch/interflow/README.md | 57 + .../interflow/bestheadpriorityscore.go | 106 ++ .../interflow/bestheadpriorityscore_test.go | 121 ++ .../dispatch/interflow/conformance_test.go | 98 ++ .../plugins/dispatch/interflow/factory.go | 61 + .../plugins/dispatch/intraflow/README.md | 57 + .../dispatch/intraflow/conformance_test.go | 89 ++ .../plugins/dispatch/intraflow/factory.go | 68 ++ .../plugins/dispatch/intraflow/fcfs.go | 70 ++ .../plugins/dispatch/intraflow/fcfs_test.go | 54 + .../plugins/preemption/interflow/README.md | 86 ++ .../preemption/interflow/conformance_test.go | 104 ++ .../plugins/preemption/interflow/factory.go | 59 + .../preemption/interflow/roundrobin.go | 95 ++ .../preemption/interflow/roundrobin_test.go | 106 ++ .../plugins/preemption/intraflow/README.md | 96 ++ .../preemption/intraflow/conformance_test.go | 115 ++ .../plugins/preemption/intraflow/factory.go | 59 + .../plugins/preemption/intraflow/tail.go | 73 ++ .../plugins/preemption/intraflow/tail_test.go | 52 + .../flowcontroller/plugins/queue/README.md | 92 ++ .../plugins/queue/conformance_test.go | 361 ++++++ .../flowcontroller/plugins/queue/factory.go | 64 + .../flowcontroller/plugins/queue/listqueue.go | 228 ++++ .../plugins/testing/mocks/mocks.go | 298 +++++ pkg/epp/flowcontroller/types/errors.go | 135 +++ pkg/epp/flowcontroller/types/flow.go | 173 +++ pkg/epp/flowcontroller/types/outcomes.go | 95 ++ pkg/epp/flowcontroller/types/policy.go | 194 +++ pkg/epp/flowcontroller/types/queue.go | 227 ++++ pkg/epp/flowcontroller/types/request.go | 113 ++ pkg/epp/requestcontrol/director.go | 96 +- pkg/epp/requestcontrol/director_test.go | 465 +++++--- pkg/epp/server/runserver.go | 16 +- pkg/epp/util/env/env.go | 11 + 43 files changed, 8433 insertions(+), 200 deletions(-) create mode 100644 pkg/epp/flowcontroller/config/config.go create mode 100644 pkg/epp/flowcontroller/config/config_test.go create mode 100644 pkg/epp/flowcontroller/flowcontroller.go create mode 100644 pkg/epp/flowcontroller/flowcontroller_test.go create mode 100644 pkg/epp/flowcontroller/flowitem.go create mode 100644 pkg/epp/flowcontroller/flowregistry.go create mode 100644 pkg/epp/flowcontroller/flowregistry_test.go create mode 100644 pkg/epp/flowcontroller/plugins/dispatch/interflow/README.md create mode 100644 pkg/epp/flowcontroller/plugins/dispatch/interflow/bestheadpriorityscore.go create mode 100644 pkg/epp/flowcontroller/plugins/dispatch/interflow/bestheadpriorityscore_test.go create mode 100644 pkg/epp/flowcontroller/plugins/dispatch/interflow/conformance_test.go create mode 100644 pkg/epp/flowcontroller/plugins/dispatch/interflow/factory.go create mode 100644 pkg/epp/flowcontroller/plugins/dispatch/intraflow/README.md create mode 100644 pkg/epp/flowcontroller/plugins/dispatch/intraflow/conformance_test.go create mode 100644 pkg/epp/flowcontroller/plugins/dispatch/intraflow/factory.go create mode 100644 pkg/epp/flowcontroller/plugins/dispatch/intraflow/fcfs.go create mode 100644 pkg/epp/flowcontroller/plugins/dispatch/intraflow/fcfs_test.go create mode 100644 pkg/epp/flowcontroller/plugins/preemption/interflow/README.md create mode 100644 pkg/epp/flowcontroller/plugins/preemption/interflow/conformance_test.go create mode 100644 pkg/epp/flowcontroller/plugins/preemption/interflow/factory.go create mode 100644 pkg/epp/flowcontroller/plugins/preemption/interflow/roundrobin.go create mode 100644 pkg/epp/flowcontroller/plugins/preemption/interflow/roundrobin_test.go create mode 100644 pkg/epp/flowcontroller/plugins/preemption/intraflow/README.md create mode 100644 pkg/epp/flowcontroller/plugins/preemption/intraflow/conformance_test.go create mode 100644 pkg/epp/flowcontroller/plugins/preemption/intraflow/factory.go create mode 100644 pkg/epp/flowcontroller/plugins/preemption/intraflow/tail.go create mode 100644 pkg/epp/flowcontroller/plugins/preemption/intraflow/tail_test.go create mode 100644 pkg/epp/flowcontroller/plugins/queue/README.md create mode 100644 pkg/epp/flowcontroller/plugins/queue/conformance_test.go create mode 100644 pkg/epp/flowcontroller/plugins/queue/factory.go create mode 100644 pkg/epp/flowcontroller/plugins/queue/listqueue.go create mode 100644 pkg/epp/flowcontroller/plugins/testing/mocks/mocks.go create mode 100644 pkg/epp/flowcontroller/types/errors.go create mode 100644 pkg/epp/flowcontroller/types/flow.go create mode 100644 pkg/epp/flowcontroller/types/outcomes.go create mode 100644 pkg/epp/flowcontroller/types/policy.go create mode 100644 pkg/epp/flowcontroller/types/queue.go create mode 100644 pkg/epp/flowcontroller/types/request.go diff --git a/cmd/epp/main.go b/cmd/epp/main.go index e023d6727..b4e0fbc8e 100644 --- a/cmd/epp/main.go +++ b/cmd/epp/main.go @@ -40,6 +40,7 @@ import ( "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metrics/collectors" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/saturationdetector" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework/plugins/filter" @@ -151,14 +152,17 @@ func run() error { }) setupLog.Info("Flags processed", "flags", flags) - // Init runtime. + // --- Load Configurations from Environment Variables --- + sdConfig := saturationdetector.LoadConfigFromEnv() + + // --- Get Kubernetes Config --- cfg, err := ctrl.GetConfig() if err != nil { - setupLog.Error(err, "Failed to get rest config") + setupLog.Error(err, "Failed to get Kubernetes rest config") return err } - // Set up mapper for metric scraping. + // --- Setup Datastore --- mapping, err := backendmetrics.NewMetricMapping( *totalQueuedRequestsMetric, *kvCacheUsagePercentageMetric, @@ -169,13 +173,11 @@ func run() error { return err } verifyMetricMapping(*mapping, setupLog) - pmf := backendmetrics.NewPodMetricsFactory(&backendmetrics.PodMetricsClientImpl{MetricMapping: mapping}, *refreshMetricsInterval) - // Setup runner. ctx := ctrl.SetupSignalHandler() - datastore := datastore.NewDatastore(ctx, pmf) + // --- Setup Metrics Server --- customCollectors := []prometheus.Collector{collectors.NewInferencePoolMetricsCollector(datastore)} metrics.Register(customCollectors...) metrics.RecordInferenceExtensionInfo() @@ -199,6 +201,7 @@ func run() error { return err } + // --- Initialize Core EPP Components --- scheduler := scheduling.NewScheduler(datastore) if schedulerV2 == "true" { queueScorerWeight := envutil.GetEnvInt("QUEUE_SCORE_WEIGHT", scorer.DefaultQueueScorerWeight, setupLog) @@ -221,6 +224,18 @@ func run() error { schedulerConfig := scheduling.NewSchedulerConfig(profilepicker.NewAllProfilesPicker(), map[string]*framework.SchedulerProfile{"schedulerv2": schedulerProfile}) scheduler = scheduling.NewSchedulerWithConfig(datastore, schedulerConfig) } + + saturationDetector, err := saturationdetector.NewDetector( + sdConfig, + datastore, + ctrl.Log.WithName("saturation-detector"), + ) + if err != nil { + setupLog.Error(err, "Failed to create SaturationDetector") + return err + } + + // --- Setup ExtProc Server Runner --- serverRunner := &runserver.ExtProcServerRunner{ GrpcPort: *grpcPort, DestinationEndpointHintMetadataNamespace: *destinationEndpointHintMetadataNamespace, @@ -231,24 +246,26 @@ func run() error { CertPath: *certPath, RefreshPrometheusMetricsInterval: *refreshPrometheusMetricsInterval, Scheduler: scheduler, + SaturationDetector: saturationDetector, } if err := serverRunner.SetupWithManager(ctx, mgr); err != nil { - setupLog.Error(err, "Failed to setup ext-proc controllers") + setupLog.Error(err, "Failed to setup EPP controllers") return err } + // --- Add Runnables to Manager --- // Register health server. if err := registerHealthServer(mgr, ctrl.Log.WithName("health"), datastore, *grpcHealthPort); err != nil { return err } // Register ext-proc server. - if err := mgr.Add(serverRunner.AsRunnable(ctrl.Log.WithName("ext-proc"))); err != nil { - setupLog.Error(err, "Failed to register ext-proc gRPC server") + if err := registerExtProcServer(mgr, serverRunner, ctrl.Log.WithName("ext-proc")); err != nil { return err } - // Start the manager. This blocks until a signal is received. + // --- Start Manager --- + // This blocks until a signal is received. setupLog.Info("Controller manager starting") if err := mgr.Start(ctx); err != nil { setupLog.Error(err, "Error starting controller manager") @@ -276,6 +293,16 @@ func initLogging(opts *zap.Options) { ctrl.SetLogger(logger) } +// registerExtProcServer adds the ExtProcServerRunner as a Runnable to the manager. +func registerExtProcServer(mgr manager.Manager, runner *runserver.ExtProcServerRunner, logger logr.Logger) error { + if err := mgr.Add(runner.AsRunnable(logger)); err != nil { + setupLog.Error(err, "Failed to register ext-proc gRPC server runnable") + return err + } + setupLog.Info("ExtProc server runner added to manager.") + return nil +} + // registerHealthServer adds the Health gRPC server as a Runnable to the given manager. func registerHealthServer(mgr manager.Manager, logger logr.Logger, ds datastore.Datastore, port int) error { srv := grpc.NewServer() @@ -309,5 +336,4 @@ func verifyMetricMapping(mapping backendmetrics.MetricMapping, logger logr.Logge if mapping.LoraRequestInfo == nil { logger.Info("Not scraping metric: LoraRequestInfo") } - } diff --git a/pkg/epp/flowcontroller/config/config.go b/pkg/epp/flowcontroller/config/config.go new file mode 100644 index 000000000..b21c794e0 --- /dev/null +++ b/pkg/epp/flowcontroller/config/config.go @@ -0,0 +1,185 @@ +/* +Copyright 2025 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 config defines configuration structures with defaulting and validation logic for the Flow Controller and the +// Flow Registry. +package config + +import ( + "fmt" + "time" + + "github.com/go-logr/logr" + interd "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/dispatch/interflow" + intrad "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/dispatch/intraflow" + interp "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/preemption/interflow" + intrap "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/preemption/intraflow" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/queue" +) + +// Default values for FlowControllerConfig +const ( + // DefaultFCQueueTTL is the default Time-To-Live for requests in the FlowController. + // Used if a request's InitialEffectiveTTL() is zero or if overridden by controller policy. + DefaultFCQueueTTL = 30 * time.Second + // DefaultFCExpiryCleanupInterval is the default frequency for the FlowController's background routine to check for + // and remove expired items. + DefaultFCExpiryCleanupInterval = 1 * time.Second +) + +// Default values for PriorityBandConfig +const ( + // DefaultPriorityBandMaxBytes is the default maximum byte capacity for a priority band if not explicitly configured. + // Set to a generous value suitable for LLM serving. + DefaultPriorityBandMaxBytes uint64 = 1 * 1024 * 1024 * 1024 // 1 GB +) + +// FlowControllerConfig groups configuration parameters for the FlowController engine. +// Note: Default values are applied by the FlowController during its initialization if specific fields are not set or +// are invalid. +type FlowControllerConfig struct { + // DefaultQueueTTL is the default Time-To-Live applied to requests within queues if not otherwise specified by the + // incoming request's InitialEffectiveTTL() or overridden by more specific configurations. + // Optional: If not set or set to a non-positive value, a system default (e.g., 30 seconds) will be used. + // Example: "30s". + DefaultQueueTTL time.Duration + // ExpiryCleanupInterval is the frequency at which the FlowController's background routine checks for and removes + // expired items from all managed queues. + // Optional: If not set or set to a non-positive value, a system default (e.g., 1 second) will be used. + // Example: "1s". + ExpiryCleanupInterval time.Duration + // MaxGlobalBytes defines an optional overall limit on the total byte size of requests across all queues in all + // priority bands. If set to a positive value, this is enforced by the FlowController's capacity checking logic in + // addition to per-priority limits (which are sourced from the FlowRegistry). + // Optional: A value of 0 means no global byte limit is enforced by the FlowController. + // Defaults to 0. + MaxGlobalBytes uint64 + // TODO: Consider adding MaxFlowBytes (per-flow capacity limit within a priority band) as a future enhancement for + // finer-grained fairness and resource isolation. This would likely involve changes to FlowSpecification or a new + // per-flow policy, and the FlowController's capacity checks. +} + +// ValidateAndApplyDefaults validates the FlowControllerConfig and applies default values if necessary. +func (fcc *FlowControllerConfig) ValidateAndApplyDefaults(logger logr.Logger) error { + if fcc.DefaultQueueTTL <= 0 { + logger.V(1).Info("FlowControllerConfig.DefaultQueueTTL is not set or invalid, using default.", + "default", DefaultFCQueueTTL) + fcc.DefaultQueueTTL = DefaultFCQueueTTL + } + if fcc.ExpiryCleanupInterval <= 0 { + logger.V(1).Info("FlowControllerConfig.ExpiryCleanupInterval is not set or invalid, using default.", + "default", DefaultFCExpiryCleanupInterval) + fcc.ExpiryCleanupInterval = DefaultFCExpiryCleanupInterval + } + // MaxGlobalBytes can be 0 (meaning no global limit), so no default needed if 0. + return nil +} + +// FlowRegistryConfig holds the configuration for the FlowRegistry, primarily defining the priority bands. +// Note: Default values for sub-configurations (like PriorityBandConfig) are applied by the FlowRegistry during its +// initialization if specific fields are not set or are invalid. +type FlowRegistryConfig struct { + // PriorityBands defines the set of priority bands managed by the FlowRegistry. + // Required: At least one PriorityBandConfig should typically be provided for a functional registry. + PriorityBands []PriorityBandConfig +} + +// validateAndApplyDefaults validates the FlowRegistryConfig by validating each of its PriorityBandConfigs. +func (frc *FlowRegistryConfig) ValidateAndApplyDefaults(logger logr.Logger) error { + for i := range frc.PriorityBands { + if err := frc.PriorityBands[i].validateAndApplyDefaults(logger); err != nil { + return fmt.Errorf("invalid config for priority band (priority %d, name %s): %w", + frc.PriorityBands[i].Priority, frc.PriorityBands[i].PriorityName, err) + } + } + return nil +} + +// PriorityBandConfig defines the configuration for a single priority band within a FlowRegistry. +// Note: Default values are applied by the FlowRegistry during its initialization if specific fields are not set or are +// invalid. +type PriorityBandConfig struct { + // Priority is the numerical priority level for this band. + // Convention: Lower numerical values indicate higher priority (e.g., 0 is highest). + // Required. + Priority uint + // PriorityName is a human-readable name for this priority band (e.g., "Critical", "Standard". "Sheddable"). + // Required. + PriorityName string + // InterFlowDispatchPolicy specifies the name of the registered policy used to select which flow's queue to service + // next from this band. + // Optional: If empty, a system default (e.g., "BestHeadPriorityScore") will be used. + InterFlowDispatchPolicy interd.RegisteredInterFlowDispatchPolicyName + // InterFlowPreemptionPolicy specifies the name of the registered policy used to select a victim flow's queue from + // this band if preemption is triggered from a higher priority band targeting this one. + // Optional: If empty, a system default (e.g., "RoundRobin") will be used. + InterFlowPreemptionPolicy interp.RegisteredInterFlowPreemptionPolicyName + // IntraFlowDispatchPolicy specifies the name of the registered policy used to select a specific request to dispatch + // next from within a single flow's queue in this band. + // Optional: If empty, a system default (e.g., "FCFS") will be used. + IntraFlowDispatchPolicy intrad.RegisteredIntraFlowDispatchPolicyName + // IntraFlowPreemptionPolicy specifies the name of the registered policy used to select a victim item from within a + // specific flow's queue in this band if preemption is triggered. + // Optional: If empty, a system default (e.g., "Tail") will be used. + IntraFlowPreemptionPolicy intrap.RegisteredIntraFlowPreemptionPolicyName + // QueueType specifies the name of the registered SafeQueue implementation to be used for flow queues within this + // band. + // Optional: If empty, a system default (e.g., "ListQueue") will be used. + QueueType queue.RegisteredQueueName + // MaxBytes defines the maximum total byte size for this specific priority band. The FlowController uses this limit + // in its capacity checking logic. + // Optional: If not set or set to a non-positive value, a system default (e.g., 1 GB) will be used. + MaxBytes uint64 +} + +// validateAndApplyDefaults validates and applies defaults for a single PriorityBandConfig. +func (pbc *PriorityBandConfig) validateAndApplyDefaults(logger logr.Logger) error { + if pbc.PriorityName == "" { + return fmt.Errorf("PriorityName cannot be empty for priority level %d", pbc.Priority) + } + bandLogger := logger.WithValues("priority", pbc.Priority, "priorityName", pbc.PriorityName) + + if pbc.InterFlowDispatchPolicy == "" { + bandLogger.V(1).Info("InterFlowDispatchPolicy is empty, using default", "defaultPolicy", + interd.BestHeadPriorityScoreDispatchPolicyName) + pbc.InterFlowDispatchPolicy = interd.BestHeadPriorityScoreDispatchPolicyName + } + if pbc.InterFlowPreemptionPolicy == "" { + bandLogger.V(1).Info("InterFlowPreemptionPolicy is empty, using default", "defaultPolicy", + interp.RoundRobinPreemptionPolicyName) + pbc.InterFlowPreemptionPolicy = interp.RoundRobinPreemptionPolicyName + } + if pbc.IntraFlowDispatchPolicy == "" { + bandLogger.V(1).Info("IntraFlowDispatchPolicy is empty, using default", "defaultPolicy", + intrad.FCFSDispatchPolicyName) + pbc.IntraFlowDispatchPolicy = intrad.FCFSDispatchPolicyName + } + if pbc.IntraFlowPreemptionPolicy == "" { + bandLogger.V(1).Info("IntraFlowPreemptionPolicy is empty, using default", "defaultPolicy", + intrap.TailPreemptionPolicyName) + pbc.IntraFlowPreemptionPolicy = intrap.TailPreemptionPolicyName + } + if pbc.QueueType == "" { + bandLogger.V(1).Info("QueueType is empty, using default", "defaultQueue", queue.ListQueueName) + pbc.QueueType = queue.ListQueueName + } + if pbc.MaxBytes <= 0 { + bandLogger.V(1).Info("PriorityBandConfig.MaxBytes is not set or invalid, using default", + "default", DefaultPriorityBandMaxBytes) + pbc.MaxBytes = DefaultPriorityBandMaxBytes + } + return nil +} diff --git a/pkg/epp/flowcontroller/config/config_test.go b/pkg/epp/flowcontroller/config/config_test.go new file mode 100644 index 000000000..f77c9c5fc --- /dev/null +++ b/pkg/epp/flowcontroller/config/config_test.go @@ -0,0 +1,316 @@ +/* +Copyright 2025 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 config + +import ( + "testing" + "time" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + interd "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/dispatch/interflow" + intrad "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/dispatch/intraflow" + interp "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/preemption/interflow" + intrap "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/preemption/intraflow" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/queue" +) + +func TestFlowControllerConfig_ValidateAndApplyDefaults(t *testing.T) { + logger := logr.Discard() + tests := []struct { + name string + input FlowControllerConfig + expected FlowControllerConfig + wantErr bool + }{ + { + name: "all values provided and valid", + input: FlowControllerConfig{ + DefaultQueueTTL: 10 * time.Second, + ExpiryCleanupInterval: 500 * time.Millisecond, + MaxGlobalBytes: 1024, + }, + expected: FlowControllerConfig{ + DefaultQueueTTL: 10 * time.Second, + ExpiryCleanupInterval: 500 * time.Millisecond, + MaxGlobalBytes: 1024, + }, + wantErr: false, + }, + { + name: "DefaultQueueTTL zero, should default", + input: FlowControllerConfig{ + DefaultQueueTTL: 0, + ExpiryCleanupInterval: 500 * time.Millisecond, + }, + expected: FlowControllerConfig{ + DefaultQueueTTL: DefaultFCQueueTTL, + ExpiryCleanupInterval: 500 * time.Millisecond, + }, + wantErr: false, + }, + { + name: "ExpiryCleanupInterval negative, should default", + input: FlowControllerConfig{ + DefaultQueueTTL: 10 * time.Second, + ExpiryCleanupInterval: -1 * time.Second, + }, + expected: FlowControllerConfig{ + DefaultQueueTTL: 10 * time.Second, + ExpiryCleanupInterval: DefaultFCExpiryCleanupInterval, + }, + wantErr: false, + }, + { + name: "empty config, all should default", + input: FlowControllerConfig{}, + expected: FlowControllerConfig{ + DefaultQueueTTL: DefaultFCQueueTTL, + ExpiryCleanupInterval: DefaultFCExpiryCleanupInterval, + MaxGlobalBytes: 0, // Default is 0 + }, + wantErr: false, + }, + { + name: "MaxGlobalBytes zero, remains zero", + input: FlowControllerConfig{ + MaxGlobalBytes: 0, + }, + expected: FlowControllerConfig{ + DefaultQueueTTL: DefaultFCQueueTTL, + ExpiryCleanupInterval: DefaultFCExpiryCleanupInterval, + MaxGlobalBytes: 0, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := tt.input // Make a copy + err := cfg.ValidateAndApplyDefaults(logger) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, cfg) + } + }) + } +} + +func TestFlowRegistryConfig_ValidateAndApplyDefaults(t *testing.T) { + logger := logr.Discard() + tests := []struct { + name string + input FlowRegistryConfig + expected FlowRegistryConfig // For checking defaults on sub-configs + wantErr bool + errText string // Substring to check in error message + }{ + { + name: "empty PriorityBands, valid", + input: FlowRegistryConfig{PriorityBands: []PriorityBandConfig{}}, + expected: FlowRegistryConfig{PriorityBands: []PriorityBandConfig{}}, + wantErr: false, + }, + { + name: "one valid PriorityBandConfig", + input: FlowRegistryConfig{ + PriorityBands: []PriorityBandConfig{ + {Priority: 0, PriorityName: "Critical", MaxBytes: 500}, + }, + }, + expected: FlowRegistryConfig{ // Expected after sub-config defaults + PriorityBands: []PriorityBandConfig{ + { + Priority: 0, + PriorityName: "Critical", + InterFlowDispatchPolicy: interd.BestHeadPriorityScoreDispatchPolicyName, + InterFlowPreemptionPolicy: interp.RoundRobinPreemptionPolicyName, + IntraFlowDispatchPolicy: intrad.FCFSDispatchPolicyName, + IntraFlowPreemptionPolicy: intrap.TailPreemptionPolicyName, + QueueType: queue.ListQueueName, + MaxBytes: 500, + }, + }, + }, + wantErr: false, + }, + { + name: "multiple valid PriorityBandConfigs", + input: FlowRegistryConfig{ + PriorityBands: []PriorityBandConfig{ + {Priority: 0, PriorityName: "Critical", MaxBytes: 500}, + {Priority: 1, PriorityName: "Standard", MaxBytes: 0}, // MaxBytes will default + }, + }, + expected: FlowRegistryConfig{ + PriorityBands: []PriorityBandConfig{ + { + Priority: 0, + PriorityName: "Critical", + InterFlowDispatchPolicy: interd.BestHeadPriorityScoreDispatchPolicyName, + InterFlowPreemptionPolicy: interp.RoundRobinPreemptionPolicyName, + IntraFlowDispatchPolicy: intrad.FCFSDispatchPolicyName, + IntraFlowPreemptionPolicy: intrap.TailPreemptionPolicyName, + QueueType: queue.ListQueueName, + MaxBytes: 500, + }, + { + Priority: 1, + PriorityName: "Standard", + InterFlowDispatchPolicy: interd.BestHeadPriorityScoreDispatchPolicyName, + InterFlowPreemptionPolicy: interp.RoundRobinPreemptionPolicyName, + IntraFlowDispatchPolicy: intrad.FCFSDispatchPolicyName, + IntraFlowPreemptionPolicy: intrap.TailPreemptionPolicyName, + QueueType: queue.ListQueueName, + MaxBytes: DefaultPriorityBandMaxBytes, // Defaulted + }, + }, + }, + wantErr: false, + }, + { + name: "one invalid PriorityBandConfig (missing PriorityName)", + input: FlowRegistryConfig{ + PriorityBands: []PriorityBandConfig{ + {Priority: 0, PriorityName: ""}, // Invalid + }, + }, + wantErr: true, + errText: "PriorityName cannot be empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := tt.input // Make a copy + err := cfg.ValidateAndApplyDefaults(logger) + if tt.wantErr { + require.Error(t, err) + if tt.errText != "" { + assert.Contains(t, err.Error(), tt.errText) + } + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, cfg) + } + }) + } +} + +func TestPriorityBandConfig_ValidateAndApplyDefaults(t *testing.T) { + logger := logr.Discard() + + tests := []struct { + name string + input PriorityBandConfig + expected PriorityBandConfig + wantErr bool + errText string + }{ + { + name: "all values provided and valid", + input: PriorityBandConfig{ + Priority: 0, + PriorityName: "Critical", + InterFlowDispatchPolicy: "CustomInterDispatch", + InterFlowPreemptionPolicy: "CustomInterPreempt", + IntraFlowDispatchPolicy: "CustomIntraDispatch", + IntraFlowPreemptionPolicy: "CustomIntraPreempt", + QueueType: "CustomQueue", + MaxBytes: 1024, + }, + expected: PriorityBandConfig{ + Priority: 0, + PriorityName: "Critical", + InterFlowDispatchPolicy: "CustomInterDispatch", + InterFlowPreemptionPolicy: "CustomInterPreempt", + IntraFlowDispatchPolicy: "CustomIntraDispatch", + IntraFlowPreemptionPolicy: "CustomIntraPreempt", + QueueType: "CustomQueue", + MaxBytes: 1024, + }, + wantErr: false, + }, + { + name: "empty policy/queue names, should default", + input: PriorityBandConfig{ + Priority: 1, + PriorityName: "Standard", + MaxBytes: 512, // Valid MaxBytes + }, + expected: PriorityBandConfig{ + Priority: 1, + PriorityName: "Standard", + InterFlowDispatchPolicy: interd.BestHeadPriorityScoreDispatchPolicyName, + InterFlowPreemptionPolicy: interp.RoundRobinPreemptionPolicyName, + IntraFlowDispatchPolicy: intrad.FCFSDispatchPolicyName, + IntraFlowPreemptionPolicy: intrap.TailPreemptionPolicyName, + QueueType: queue.ListQueueName, + MaxBytes: 512, + }, + wantErr: false, + }, + { + name: "PriorityName empty, should return error", + input: PriorityBandConfig{ + Priority: 0, + PriorityName: "", + }, + wantErr: true, + errText: "PriorityName cannot be empty", + }, + { + name: "MaxBytes zero, should default", + input: PriorityBandConfig{ + Priority: 2, + PriorityName: "Sheddable", + MaxBytes: 0, + }, + expected: PriorityBandConfig{ + Priority: 2, + PriorityName: "Sheddable", + InterFlowDispatchPolicy: interd.BestHeadPriorityScoreDispatchPolicyName, + InterFlowPreemptionPolicy: interp.RoundRobinPreemptionPolicyName, + IntraFlowDispatchPolicy: intrad.FCFSDispatchPolicyName, + IntraFlowPreemptionPolicy: intrap.TailPreemptionPolicyName, + QueueType: queue.ListQueueName, + MaxBytes: DefaultPriorityBandMaxBytes, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := tt.input // Make a copy + err := cfg.validateAndApplyDefaults(logger) + if tt.wantErr { + require.Error(t, err) + if tt.errText != "" { + assert.Contains(t, err.Error(), tt.errText) + } + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, cfg) + } + }) + } +} diff --git a/pkg/epp/flowcontroller/flowcontroller.go b/pkg/epp/flowcontroller/flowcontroller.go new file mode 100644 index 000000000..9aa5642f3 --- /dev/null +++ b/pkg/epp/flowcontroller/flowcontroller.go @@ -0,0 +1,976 @@ +/* +Copyright 2025 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 flowcontroller implements the core logic for managing and controlling the flow of requests. +package flowcontroller + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/go-logr/logr" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/config" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/types" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" +) + +// clock defines an interface for getting the current time, allowing for time manipulation in tests. +type clock interface { + // Now returns the current time. + Now() time.Time +} + +// realClock implements the clock interface using the actual system time. +type realClock struct{} + +// Now returns the current system time. +func (c realClock) Now() time.Time { return time.Now() } + +// capacityFailureReason indicates the reason why a capacity check failed. +type capacityFailureReason int + +const ( + // capacityFailureReasonNone indicates no capacity failure (i.e., has capacity). + capacityFailureReasonNone capacityFailureReason = iota + // capacityFailureReasonGlobalLimitExceeded indicates the global byte limit was exceeded. + capacityFailureReasonGlobalLimitExceeded + // capacityFailureReasonBandLimitExceeded indicates a priority band's byte limit was exceeded. + capacityFailureReasonBandLimitExceeded + // capacityFailureReasonBandConfigError indicates an error retrieving band configuration (e.g., its accessor or + // capacity limit from the FlowRegistry). + capacityFailureReasonBandConfigError +) + +// String returns a human-readable string representation of the capacityFailureReason. +func (cfr capacityFailureReason) String() string { + switch cfr { + case capacityFailureReasonNone: + return "None" + case capacityFailureReasonGlobalLimitExceeded: + return "GlobalLimitExceeded" + case capacityFailureReasonBandLimitExceeded: + return "BandLimitExceeded" + case capacityFailureReasonBandConfigError: + return "BandConfigError" + default: + return "Unknown" + } +} + +// SaturationDetector provides a signal indicating whether the backends are considered saturated. +// Conformance: +// - Implementations MUST be goroutine-safe. +type SaturationDetector interface { + // IsSaturated returns true if the system (e.g., backend model servers) is considered saturated according to its + // configured thresholds and observed metrics. The FlowController uses this to gate dispatch decisions. + IsSaturated() bool +} + +// FlowController manages the queuing, prioritization, fairness, and dispatch of requests based on configured policies +// and system saturation state. +// It is designed to be a portable library for flow control logic. +// +// The primary interaction point for submitting requests is EnqueueAndWait. +// The controller's processing loops are started via the Run method. +// +// == Error Handling Strategy for Priority Band and Flow Queue Iteration == +// +// The FlowController employs a two-tiered error handling strategy during operations like request dispatch and +// preemption, which involve iterating through priority bands and the flow queues within them: +// +// 1. Priority Band Domain (Inter-Flow Operations - "Fail Open for System"): +// - Behavior: If an issue prevents processing at the priority band level itself—such as failing to retrieve a band's +// InterFlowDispatchPolicy from the FlowRegistry, or if an InterFlow...Policy method returns an unrecoverable error +// for that band—the FlowController will typically log the error, skip processing for that specific priority band in +// the current operational cycle, and continue to the next available priority band. +// - Rationale: This "fail open" approach for band-level setup or inter-flow policy execution errors allows the system +// to attempt to provide service via other healthy bands. In this sense, the system is work-conserving. +// +// 2. Queue Domain (Intra-Flow Operations within a Band - "Fail Close for Band"): +// - Behavior: Once an InterFlow...Policy successfully selects a specific flow's queue for processing within a band, +// if an unrecoverable error occurs during this queue-specific (intra-flow) stage—such as failing to retrieve the +// IntraFlow...Policy for that queue from the FlowRegistry, or if an IntraFlow...Policy method itself returns an +// unrecoverable error—the FlowController will "fail close" for the *current priority band*. This means it will log +// the error and cease further attempts to select or process additional queues *from that same band* during the +// current operational cycle. The overall operation then moves to the next priority band, if any. +// - Rationale: Inter-flow policies may be stateless and lack a feedback mechanism to know if a previously selected +// queue encountered an intra-flow processing issue. +// This strategy prevents potential loops where an inter-flow policy might repeatedly select problematic queues +// within a band where intra-flow processing consistently fails. +// +// 3. Invariant Violations: Critical internal inconsistencies or invariant violations (e.g., an item retrieved from a queue +// not being the expected internal `*flowItem` type) will result in a `panic` to immediately signal a severe bug. +// +// This tiered strategy aims to maximize system robustness and work conservation by isolating failures, while preventing +// cascading issues or processing loops due to problematic configurations or plugin behaviors at different levels. +type FlowController struct { + registry types.FlowRegistry + saturationDetector SaturationDetector + clock clock + logger logr.Logger + + // config holds the operational configuration for the FlowController. + config config.FlowControllerConfig + + // enqueueChan is an unbuffered channel for submitting new flowItems to the Run loop. + enqueueChan chan *flowItem + + // stopCh is closed to signal the Run loop and other goroutines to terminate. + stopCh chan struct{} + onceStop sync.Once // Ensures stopCh is closed only once. + + // wg is used to wait for background goroutines (like expiry cleanup) to finish. + wg sync.WaitGroup +} + +// NewFlowController creates and initializes a new FlowController instance. +// The FlowRegistry provided should already be configured with its priority bands, policies, and queue types. +// Per-priority band capacity limits are sourced from the FlowRegistry via types.PriorityBandAccessor. +func NewFlowController( + detector SaturationDetector, + registry types.FlowRegistry, + cfg config.FlowControllerConfig, + logger logr.Logger, +) (*FlowController, error) { + if detector == nil { + return nil, fmt.Errorf("SaturationDetector cannot be nil") + } + if registry == nil { + return nil, fmt.Errorf("FlowRegistry cannot be nil") + } + + configCopy := cfg // Operate on a copy + // Validate and apply defaults to the FlowControllerConfig + if err := (&configCopy).ValidateAndApplyDefaults(logger.WithName("fc-config")); err != nil { + return nil, fmt.Errorf("invalid FlowControllerConfig: %w", err) + } + + fc := &FlowController{ + registry: registry, + saturationDetector: detector, + clock: realClock{}, + logger: logger.WithName("flow-controller"), + config: configCopy, + enqueueChan: make(chan *flowItem), + stopCh: make(chan struct{}), + } + + fc.logger.V(logutil.DEFAULT).Info("FlowController initialized", + "defaultQueueTTL", fc.config.DefaultQueueTTL, + "expiryCleanupInterval", fc.config.ExpiryCleanupInterval, + "maxGlobalBytes", fc.config.MaxGlobalBytes, + ) + return fc, nil +} + +// EnqueueAndWait submits a request for flow control management and blocks until the request's processing is finalized +// within the FlowController. +// Finalization means the request has reached a terminal state, which can be: +// - Unblocking of the calling goroutine, signifying the request has passed flow control checks and can proceed to the +// next processing stage (managed by the caller). +// - Rejection before or during enqueueing (e.g., due to capacity limits, invalid flow, or FlowController shutdown). +// - Eviction from a queue after being enqueued (e.g., due to TTL expiry, preemption, or cancellation of the request's +// context). +// - FlowController shutdown while the request is being managed. +// +// This method is goroutine-safe and is the primary entry point for requests +// into the FlowController. +// +// Parameters: +// - req: The FlowControlRequest to be processed. Must not be nil. +// The request's Context() is monitored for cancellation throughout its lifecycle within the FlowController. +// +// Returns: +// - types.QueueOutcome: Indicates the final status of the request's lifecycle. +// - error: Non-nil if the request was not successfully unblocked for further processing by the caller. +// The error will always wrap either types.ErrRejected or types.ErrEvicted. Callers can use errors.Is() to check for +// these general categories and then unwrap further for specific sentinel errors. +// If the outcome allows the caller to proceed (e.g. types.QueueOutcomeDispatched), the error will be nil. +// +// Conformance: +// - If req is nil, returns (types.QueueOutcomeRejectedOther, an error wrapping types.ErrRejected and +// types.ErrNilRequest). +// - If req.FlowID() is empty, returns (types.QueueOutcomeRejectedOther, an error wrapping types.ErrRejected and +// types.ErrFlowIDEmpty). +func (fc *FlowController) EnqueueAndWait(req types.FlowControlRequest) (types.QueueOutcome, error) { + if req == nil { + return types.QueueOutcomeRejectedOther, fmt.Errorf("%w: %w", types.ErrRejected, types.ErrNilRequest) + } + if req.FlowID() == "" { + return types.QueueOutcomeRejectedOther, fmt.Errorf("%w: %w", types.ErrRejected, types.ErrFlowIDEmpty) + } + + effectiveTTL := req.InitialEffectiveTTL() + if effectiveTTL <= 0 { + effectiveTTL = fc.config.DefaultQueueTTL + } + + item := newFlowItem(req, effectiveTTL, fc.clock.Now()) + + logger := log.FromContext(req.Context()).WithName("EnqueueAndWait").WithValues( + "flowID", item.FlowID(), + "reqID", item.RequestID(), + "reqByteSize", item.ByteSize(), + "effectiveTTL", item.EffectiveTTL(), + ) + + // Submit the item to the Run loop's enqueueChan. + logger.V(logutil.DEBUG).Info("Attempting to submit item to FlowController's internal channel.") + select { + case <-req.Context().Done(): + // Request context cancelled before it could be submitted to the internal channel. + err := fmt.Errorf("%w: %w: %w", types.ErrRejected, types.ErrContextCancelled, req.Context().Err()) + logger.V(logutil.VERBOSE).Info("Request context cancelled before submission.", "error", err) + item.finalize(types.QueueOutcomeRejectedOther, err) + return item.getFinalState() + case <-fc.stopCh: + // FlowController is shutting down before the item could be submitted. + err := fmt.Errorf("%w: %w", types.ErrRejected, types.ErrFlowControllerShutdown) + logger.V(logutil.VERBOSE).Info("FlowController shutting down before submission.", "error", err) + item.finalize(types.QueueOutcomeRejectedOther, err) + return item.getFinalState() + case fc.enqueueChan <- item: + logger.V(logutil.DEBUG).Info("Item submitted to internal enqueue channel.") + } + + // Wait for the item to be finalized by the Run loop (dispatched or evicted). + logger.V(logutil.DEBUG).Info("Item submitted, waiting for finalization by Run loop.") + select { + case <-req.Context().Done(): + // Request context cancelled while the item was (or was about to be) managed by the FlowController. + // The Run loop's expiry cleanup or dispatch logic should eventually detect this context cancellation and finalize + // the item. We must wait for item.done. + err := fmt.Errorf("%w: %w: %w", types.ErrEvicted, types.ErrContextCancelled, req.Context().Err()) + logger.V(logutil.VERBOSE).Info("Request context cancelled while item was managed.", "error", err) + <-item.done // Wait for FC to finalize + // It's possible item.finalize was called with a different reason if multiple conditions met. + // getFinalState will return whatever was set first. + return item.getFinalState() + case <-fc.stopCh: + // FlowController shut down while the item was being managed. + // The Run loop's shutdown logic (evictAllOnShutdown) should finalize the item. + logger.V(logutil.VERBOSE).Info("FlowController shutting down while item was managed.") + <-item.done // Wait for FC to finalize + return item.getFinalState() + case <-item.done: + // Item processing finished (dispatched or evicted by Run loop). + outcome, err := item.getFinalState() + if err == nil && outcome == types.QueueOutcomeDispatched { + logger.V(logutil.VERBOSE).Info("Request processing completed: Dispatched.") + } else { + logger.V(logutil.VERBOSE).Info("Request processing completed.", "outcome", outcome.String(), "error", err) + } + return outcome, err + } +} + +// Run starts the FlowController's main processing loops. This method blocks until the provided context is cancelled. +// +// The Run loop is responsible for orchestrating request processing. It interleaves the acceptance of new requests (from +// an unbuffered internal channel fed by EnqueueAndWait) with attempts to dispatch eligible requests from the managed +// queues. This interleaving is designed for contention management and responsiveness under load. +// The loop also manages periodic tasks like queue item expiry cleanup. +// +// It is intended to be called once, typically in its own goroutine. +// +// Parameters: +// - ctx: A context used to signal shutdown. Upon context cancellation, Run will initiate a graceful shutdown, +// finalizing pending requests. +func (fc *FlowController) Run(ctx context.Context) { + fc.logger.V(logutil.DEFAULT).Info("FlowController Run loop starting.") + defer func() { + fc.logger.Info("FlowController Run loop stopped.") + fc.signalStop() // Ensure stopCh is closed to signal any pending EnqueueAndWait calls. + fc.wg.Wait() // Wait for background goroutines to complete. + }() + + fc.wg.Add(1) // For the expiry cleanup goroutine + go fc.runExpiryCleanup(ctx) + + // Main processing loop: + for { + select { + case <-ctx.Done(): + fc.logger.V(logutil.DEFAULT).Info("Context cancelled, initiating FlowController shutdown.") + fc.evictAllOnShutdown(fmt.Errorf("%w: context cancelled", types.ErrEvicted), types.QueueOutcomeEvictedOther) + return + case <-fc.stopCh: // Typically handled by ctx.Done() in defer, but good practice + fc.logger.V(logutil.DEFAULT).Info("Internal stop signal received, initiating FlowController shutdown from stopCh.") + fc.evictAllOnShutdown( + fmt.Errorf("%w: %w", types.ErrEvicted, types.ErrFlowControllerShutdown), + types.QueueOutcomeEvictedOther) + return + case item, ok := <-fc.enqueueChan: + if !ok { // Should not happen if fc.stopCh is handled, but good practice + fc.logger.V(logutil.DEFAULT).Info("Enqueue channel closed, initiating shutdown.") + fc.evictAllOnShutdown( + fmt.Errorf("%w: enqueue channel closed unexpectedly", types.ErrEvicted), + types.QueueOutcomeEvictedOther) + return + } + if item == nil { // Should not happen, fail open + fc.logger.Error(errors.New("nil item received from enqueueChan"), "Nil item received, ignoring.") + continue + } + fc.handleEnqueue(item) // Process the newly submitted item + fc.attemptDispatchCycle() // After handling an enqueue, immediately try to dispatch + default: + dispatched := fc.attemptDispatchCycle() + if !dispatched { // Short pause to prevent busy looping; TODO(lukevandrie): should this be configurable? + time.Sleep(5 * time.Millisecond) + } + } + } +} + +// signalStop idempotently closes the stopCh. +func (fc *FlowController) signalStop() { + fc.onceStop.Do(func() { + close(fc.stopCh) + }) +} + +// handleEnqueue processes a single flowItem received from the enqueueChan. +// This method orchestrates the admission control logic for a new request: +// 1. Retrieves the active ManagedQueue for the item's flow. +// 2. Checks if the system and the item's target priority band have capacity. +// 3. If capacity limits are hit (specifically global, not the item's own band), it attempts preemption of items from +// lower priority bands. +// 4. If the item can be accommodated (either initially or after preemption), it's added to its ManagedQueue. +// 5. If any step fails (e.g., no active queue, capacity check error, capacity full and preemption fails/inapplicable), +// the item is finalized with an appropriate rejection outcome and error. +func (fc *FlowController) handleEnqueue(item *flowItem) { + reqCtx := item.OriginalRequest().Context() + logger := log.FromContext(reqCtx).WithName("handleEnqueue").WithValues( + "flowID", item.FlowID(), + "reqID", item.RequestID(), + "reqByteSize", item.ByteSize(), + ) + + managedQ, err := fc.registry.ActiveManagedQueue(item.FlowID()) + if err != nil { + logger.Error(err, "Failed to get active ManagedQueue for flow; rejecting item.") + item.finalize( + types.QueueOutcomeRejectedOther, + fmt.Errorf("%w: failed to get active ManagedQueue for flow: %w", types.ErrRejected, err)) + return + } + logger = logger.WithValues("priority", managedQ.FlowSpec().Priority(), "queueType", managedQ.Name()) + + bandAccessor, err := fc.registry.PriorityBandAccessor(managedQ.FlowSpec().Priority()) + if err != nil { + logger.Error(err, "Failed to get PriorityBandAccessor for item's priority band; rejecting item.") + item.finalize( + types.QueueOutcomeRejectedOther, + fmt.Errorf("%w: failed to get PriorityBandAccessor for item's priority band: %w", types.ErrRejected, err)) + return + } + logger = logger.WithValues("priorityName", bandAccessor.PriorityName()) + + canFit, reason, err := fc.hasCapacity(bandAccessor, item.ByteSize(), logger) + if err != nil { + logger.Error(err, "Failed to check capacity; rejecting item.") + item.finalize( + types.QueueOutcomeRejectedOther, + fmt.Errorf("%w: failed to check capacity: %w", types.ErrRejected, err)) + return + } + + if !canFit { + logger.V(logutil.VERBOSE).Info("Capacity limit reached.", "reason", reason.String()) + if reason == capacityFailureReasonBandLimitExceeded { + logger.V(logutil.VERBOSE).Info("Item's own priority band is at capacity; preemption not possible for this band.") + item.finalize(types.QueueOutcomeRejectedCapacity, fmt.Errorf("%w: priority band %d ('%s') at capacity: %w", + types.ErrRejected, managedQ.FlowSpec().Priority(), bandAccessor.PriorityName(), types.ErrQueueAtCapacity)) + return + } + + madeSpace, preemptionErr := fc.tryPreemptForRequest(item, bandAccessor, logger) + if !madeSpace { + finalErr := types.ErrQueueAtCapacity + if preemptionErr != nil { + finalErr = fmt.Errorf("%w: preemption failed: %w", types.ErrQueueAtCapacity, preemptionErr) + } + logger.V(logutil.VERBOSE).Info("Failed to make space via preemption.", "error", finalErr) + item.finalize(types.QueueOutcomeRejectedCapacity, fmt.Errorf("%w: %w", types.ErrRejected, finalErr)) + return + } + logger.V(logutil.VERBOSE).Info("Space successfully made via preemption.") + } + + if item.isFinalized() { // Check before adding to queue to avoid enqueuing an already terminal item + logger.V(logutil.DEBUG).Info("Item finalized concurrently before enqueuing into ManagedQueue.") + return + } + + _, _, err = managedQ.Add(item) + if err != nil { + logger.Error(err, "Failed to add item to ManagedQueue.") + item.finalize( + types.QueueOutcomeRejectedOther, + fmt.Errorf("%w: failed to add item to ManagedQueue: %w", types.ErrRejected, err)) + return + } + logger.V(logutil.DEBUG).Info("Item successfully enqueued into ManagedQueue.") + // The item is now in the queue; its 'done' channel remains open until it is dispatched or evicted by other mechanisms + // (expiry, subsequent preemption). +} + +// hasCapacity checks if there's sufficient capacity to accommodate an item of a given byte size. +// This check considers both the FlowController's global byte limit (fc.config.MaxGlobalBytes) and the specific +// capacity limit of the target priority band (obtained from bandAccessor.CapacityBytes()). +// +// Parameters: +// - bandAccessor: The PriorityBandAccessor for the priority band into which the item would be enqueued. +// - itemByteSize: The byte size of the item for which capacity is being checked. +// - logger: A contextual logger for detailed logging of capacity decisions. +// +// Returns: +// - canFit (bool): True if the item can fit according to configured limits. +// - reason (capacityFailureReason): The reason for failure if canFit is false. +// - err (error): Any unexpected error encountered during the check (e.g., registry issues). +func (fc *FlowController) hasCapacity( + bandAccessor types.PriorityBandAccessor, + itemByteSize uint64, + logger logr.Logger, +) (canFit bool, reason capacityFailureReason, err error) { + logger = logger.WithName("hasCapacity") + if itemByteSize == 0 { + return true, capacityFailureReasonNone, nil // Zero-size items always "fit" concerning byte limits. + } + + registryStats := fc.registry.GetStats() + + // 1. Check global capacity limit. + if fc.config.MaxGlobalBytes > 0 && (registryStats.GlobalByteSize+itemByteSize) > fc.config.MaxGlobalBytes { + logger.V(logutil.DEBUG).Info("Global capacity limit would be exceeded.", + "currentGlobalByteSize", registryStats.GlobalByteSize, + "globalByteLimit", fc.config.MaxGlobalBytes) + return false, capacityFailureReasonGlobalLimitExceeded, nil + } + + // 2. Check per-priority band capacity limit. + bandCapacityLimit := bandAccessor.CapacityBytes() + currentBandStats, ok := registryStats.PerPriorityBandStats[bandAccessor.Priority()] + if !ok { + err := fmt.Errorf("stats not found for priority band %s (%d) in FlowRegistryStats", + bandAccessor.PriorityName(), bandAccessor.Priority()) + logger.Error(err, "Failed to retrieve stats for priority band.") + return false, capacityFailureReasonBandConfigError, err + } + + if bandCapacityLimit > 0 && (currentBandStats.ByteSize+itemByteSize) > bandCapacityLimit { + logger.V(logutil.DEBUG).Info("Priority band capacity limit would be exceeded.", + "currentBandByteSize", currentBandStats.ByteSize, + "bandByteLimit", bandCapacityLimit) + return false, capacityFailureReasonBandLimitExceeded, nil + } + + return true, capacityFailureReasonNone, nil +} + +// tryPreemptForRequest attempts to make space for 'itemToFit' by preempting items from strictly lower priority bands +// than itemToFit's own band. +// The method iterates through lower priority bands, from the lowest upwards. Within each victim band, it repeatedly +// applies inter-flow and intra-flow preemption policies to select and evict victim items. After each successful +// preemption, it re-evaluates if 'itemToFit' can now be accommodated. +// +// Parameters: +// - itemToFit: The flowItem for which space needs to be made. +// - itemToFitBandAccessor: The PriorityBandAccessor for itemToFit's own priority band. +// This is used to re-check capacity for itemToFit after potential preemptions. +// - logger: A contextual logger for tracing preemption attempts. +// +// Error Handling Strategy (see FlowController GoDoc for details): +// - Band-Level/Inter-Policy Issues for a victim band (e.g., error getting PriorityBandAccessor, error +// getting/executing InterFlowPreemptionPolicy): Skips that victim band and tries the next lower priority band. +// - Queue-Level/Intra-Policy Issues for a selected victim queue (e.g., error getting/executing +// IntraFlowPreemptionPolicy): Stops attempting preemption from the current victim band and moves to the next lower +// priority band. +// +// Returns: +// - madeEnoughSpace (bool): True if sufficient space was made for itemToFit. +// - err (error): Any significant, unrecoverable error encountered during the preemption process that halted it. +// This excludes the Band-Level/Inter-Policy and Queue-Level/Intra-Policy issues which are logged and skipped as +// specified in the Error Handling Strategy. +func (fc *FlowController) tryPreemptForRequest( + itemToFit *flowItem, + itemToFitBandAccessor types.PriorityBandAccessor, + logger logr.Logger, +) (madeEnoughSpace bool, err error) { + logger = logger.WithName("tryPreemptForRequest") + if itemToFit.ByteSize() == 0 { + return true, nil + } + + logger.V(logutil.DEBUG).Info("Attempting preemption to free up space.") + + // Iterate victim priority bands from lowest actual priority (highest numeric value) up to, but not including, + // itemToFit's priority. + priorityLevels := fc.registry.AllOrderedPriorityLevels() + for i := len(priorityLevels) - 1; i >= 0; i-- { + priority := priorityLevels[i] + + // Preempt only from strictly lower (higher numeric value) priority bands. + if priority <= itemToFitBandAccessor.Priority() { + continue + } + + bandAccessor, err := fc.registry.PriorityBandAccessor(priority) + if err != nil { + logger.Error(err, "Failed to get PriorityBandAccessor for victim band, skipping band.", + "victimPriority", priority) + continue + } + bandLogger := logger.WithValues("victimPriority", priority, "victimPriorityName", bandAccessor.PriorityName()) + + // Loop to potentially preempt multiple items from queues within this band until enough space is made or the band + // offers no more victims. + for { + if itemToFit.isFinalized() { // Stop preemption efforts if the item needing space is already finalized + bandLogger.V(logutil.VERBOSE).Info("Preemptor item (itemToFit) finalized concurrently; stopping preemption for it.") + return false, nil + } + + if err := fc.preemptItem(bandAccessor, bandLogger); err != nil { + bandLogger.Error(err, "Failed to preempt item. Stopping preemption for this band.") + break + } + + // Re-check capacity for itemToFit *after* preempting a new victim. + // This accounts for space freed by previous preemptions in this or other bands, or concurrent + // dispatches/expiries. + canFitNow, reason, err := fc.hasCapacity(itemToFitBandAccessor, itemToFit.ByteSize(), logger) + if err != nil { + return false, fmt.Errorf("error checking capacity for itemToFit during preemption loop; aborted preemption attempt: %w", err) + } + if canFitNow { + bandLogger.V(logutil.VERBOSE).Info("Sufficient space now available for itemToFit.") + return true, nil + } + if reason == capacityFailureReasonBandLimitExceeded { + return false, fmt.Errorf("cannot fit item: its own priority band %d ('%s') is at capacity. Preemption from lower bands cannot resolve this band-specific limit. Aborted preemption attempt", + itemToFitBandAccessor.Priority(), itemToFitBandAccessor.PriorityName()) + } + } + } + return false, nil +} + +// preemptItem attempts to select and preempt a single item from the given victim priority band. +// It uses `applyPreemptionPolicies` to select a victim. If a victim is found, it's removed from its ManagedQueue and +// finalized with a types.QueueOutcomeEvictedPreempted outcome. +// +// Parameters: +// - bandAccessor: The PriorityBandAccessor for the victim priority band from which an item should be preempted. +// - logger: A contextual logger, typically scoped to the victim band being processed. +// +// Returns an error if victim selection or removal fails critically, or if no victim is selected by policies. +func (fc *FlowController) preemptItem(bandAccessor types.PriorityBandAccessor, logger logr.Logger) error { + logger = logger.WithName("preemptItem") + itemAccessor, err := fc.applyPreemptionPolicies(bandAccessor, logger) + if err != nil { + return fmt.Errorf("failed to select a victim item due to policy or registry error: %w", err) + } + if itemAccessor == nil { + return errors.New("no further victim item selected by by policies in this band") + } + logger = logger.WithValues( + "victimReqID", itemAccessor.RequestID(), + "victimFlowID", itemAccessor.FlowID(), + "victimByteSize", itemAccessor.ByteSize(), + ) + + managedQ, err := fc.registry.ManagedQueue(itemAccessor.FlowID(), bandAccessor.Priority()) + if err != nil { + return fmt.Errorf("failed to get flow '%s' ManagedQueue for victim item '%s' removal: %w", + itemAccessor.FlowID(), itemAccessor.RequestID(), err) + } + + // removedItemAccessor should always be equal to itemAccessor for properly behaving queues; however, we finalize the + // removedItemAccessor.(*flowItem) to be extra cautious. + logger.V(logutil.DEFAULT).Info("Attempting to preempt victim request.") + removedItemAccessor, _, _, err := managedQ.Remove(itemAccessor.Handle()) + if err != nil { + return fmt.Errorf("failed to remove victim item '%s' from flow '%s' queue '%s': %w", + itemAccessor.RequestID(), itemAccessor.FlowID(), managedQ.Name(), err) + } + removedItem, ok := removedItemAccessor.(*flowItem) + if !ok { + panic(fmt.Errorf("CRITICAL: Removed victim item '%s' from flow '%s' queue '%s' is not of type *flowItem, but %T", + itemAccessor.RequestID(), itemAccessor.FlowID(), managedQ.Name(), itemAccessor)) + } + + // This is idempotent. It is possible the victim item was already finalized by other means, such as concurrent expiry + // or dispatch. + // Whatever outcome was reported first "wins". + removedItem.finalize(types.QueueOutcomeEvictedPreempted, + fmt.Errorf("%w: %w: preempted to make space for request in higher priority band", + types.ErrEvicted, types.ErrPreempted)) + return nil +} + +// applyPreemptionPolicies orchestrates the selection of a single victim item for preemption from a given priority band. +// It performs the following steps: +// 1. Retrieves the InterFlowPreemptionPolicy for the band. +// 2. Calls the inter-flow policy to select a victim FlowQueueAccessor from the band. +// 3. If a victim queue is selected, retrieves the IntraFlowPreemptionPolicy for that specific flow. +// 4. Calls the intra-flow policy to select a victim QueueItemAccessor from the chosen queue. +// +// Parameters: +// - bandAccessor: The PriorityBandAccessor for the victim priority band. +// - logger: A contextual logger, typically scoped to the victim band being processed. +// +// If policies simply do not select a victim (returning nil item/queue without error), this function returns (nil, nil). +func (fc *FlowController) applyPreemptionPolicies( + bandAccessor types.PriorityBandAccessor, + logger logr.Logger, +) (victimItem types.QueueItemAccessor, err error) { + logger = logger.WithName("applyPreemptionPolicies") + interPolicy, err := fc.registry.InterFlowPreemptionPolicy(bandAccessor.Priority()) + if err != nil { + return nil, fmt.Errorf("failed to get InterFlowPreemptionPolicy for band %d ('%s'): %w", + bandAccessor.Priority(), bandAccessor.PriorityName(), err) + } + queueAccessor, err := interPolicy.SelectVictimQueue(bandAccessor) + if err != nil { + return nil, fmt.Errorf("InterFlowPreemptionPolicy SelectVictimQueue failed for band %d ('%s'): %w", + bandAccessor.Priority(), bandAccessor.PriorityName(), err) + } + if queueAccessor == nil { + logger.V(logutil.DEBUG).Info("No victim queue selected by inter-flow preemption policy in this band.") + return nil, nil + } + + flowSpec := queueAccessor.FlowSpec() + logger = logger.WithValues("victimFlowID", flowSpec.ID(), "victimQueueType", queueAccessor.Name()) + + intraPolicy, err := fc.registry.IntraFlowPreemptionPolicy(flowSpec.ID(), flowSpec.Priority()) + if err != nil { + return nil, fmt.Errorf("failed to get IntraFlowPreemptionPolicy for flow '%s' in band %d ('%s'): %w", + flowSpec.ID(), bandAccessor.Priority(), bandAccessor.PriorityName(), err) + } + itemAccessor, err := intraPolicy.SelectVictim(queueAccessor) + if err != nil { + return nil, fmt.Errorf("IntraFlowPreemptionPolicy SelectVictim failed for flow '%s' in band %d ('%s'): %w", + flowSpec.ID(), bandAccessor.Priority(), bandAccessor.PriorityName(), err) + } + if itemAccessor == nil { + logger.V(logutil.DEBUG).Info("No victim item selected by intra-flow preemption policy from this queue.") + return nil, nil + } + logger.V(logutil.DEBUG).Info("Victim item selected for preemption.", "victimReqID", itemAccessor.RequestID()) + return itemAccessor, nil +} + +// attemptDispatchCycle tries to dispatch one eligible request. +// It iterates through priority bands from highest to lowest priority. For each band, it attempts to select and dispatch +// a single candidate item via the `dispatchItem` method. +// Returns true if an item was successfully dispatched from any band. +// Returns false if no item was dispatched. This can occur if all queues are empty, the system is saturated, policies do +// not select any item for dispatch, a selected candidate is found to be invalid (e.g., expired) during processing, or +// if errors occur during policy application or registry access for all considered bands. +func (fc *FlowController) attemptDispatchCycle() bool { + logger := fc.logger.WithName("attemptDispatchCycle") + for _, priority := range fc.registry.AllOrderedPriorityLevels() { + bandAccessor, err := fc.registry.PriorityBandAccessor(priority) + if err != nil { + logger.Error(err, "Failed to get PriorityBandAccessor for dispatch, skipping band.", "priority", priority) + continue + } + bandLogger := logger.WithValues("priority", priority, "priorityName", bandAccessor.PriorityName()) + + if fc.saturationDetector.IsSaturated() { // Short circuit if system becomes saturated mid dispatch cycle + logger.V(logutil.DEBUG).Info("System saturated, pausing dispatch attempts for this cycle.") + return false + } + + err = fc.dispatchItem(bandAccessor, bandLogger) + if err == nil { + return true + } + // If dispatchItem returns an error, it means either no item was selected from this band, or an error occurred + // during policy application or item processing for this band. + // Log the error and continue to the next (lower) priority band. + bandLogger.Error(err, "Failed to dispatch item from band, attempting next priority band.") + } + return false +} + +// dispatchItem attempts to select and dispatch a single item from the given priority band. +// It uses `applyDispatchPolicies` to select a candidate. The selected candidate is then removed from its ManagedQueue. +// After removal, the item's validity (e.g., not expired or context cancelled) is checked. +// If still valid, it's finalized with types.QueueOutcomeDispatched. If it became invalid (e.g., expired just before or +// during this process), it's finalized with the appropriate eviction outcome. +func (fc *FlowController) dispatchItem(bandAccessor types.PriorityBandAccessor, logger logr.Logger) error { + logger = logger.WithName("dispatchItem") + itemAccessor, err := fc.applyDispatchPolicies(bandAccessor, logger) + if err != nil { + return fmt.Errorf("failed to select a dispatch candidate due to policy or registry error: %w", err) + } + if itemAccessor == nil { + return errors.New("no dispatch candidate selected by by policies in this band") + } + logger = logger.WithValues( + "candidateID", itemAccessor.RequestID(), + "flowID", itemAccessor.FlowID(), + "candidateByteSize", itemAccessor.ByteSize(), + ) + + managedQ, err := fc.registry.ManagedQueue(itemAccessor.FlowID(), bandAccessor.Priority()) + if err != nil { + return fmt.Errorf("failed to get flow '%s' ManagedQueue for dispatch candidate '%s' removal: %w", + itemAccessor.FlowID(), itemAccessor.RequestID(), err) + } + + // removedItemAccessor should always be equal to itemAccessor for properly behaving queues; however, we finalize the + // removedItemAccessor.(*flowItem) to be extra cautious. + logger.V(logutil.DEFAULT).Info("Attempting to dispatch candidate request.") + removedItemAccessor, _, _, err := managedQ.Remove(itemAccessor.Handle()) + if err != nil { + return fmt.Errorf("failed to remove dipatch candidate '%s' from flow '%s' queue '%s': %w", + itemAccessor.RequestID(), itemAccessor.FlowID(), managedQ.Name(), err) + } + removedItem, ok := removedItemAccessor.(*flowItem) + if !ok { + panic(fmt.Errorf("CRITICAL: Removed dispatch candidate '%s' from flow '%s' queue '%s' is not of type *flowItem, but %T", + itemAccessor.RequestID(), itemAccessor.FlowID(), managedQ.Name(), itemAccessor)) + } + + // Check if the item became invalid (expired, context cancelled). + // This prevents dispatching an item that should have been evicted. + isExpired, outcome, err := isItemExpiredFunc(logger)(itemAccessor, fc.clock.Now()) + if isExpired { + logger.V(logutil.DEBUG).Info("Dispatch candidate found to be expired/cancelled at time of dispatch processing (after removal), finalizing accordingly.", + "outcome", outcome.String(), "error", err) + removedItem.finalize(outcome, err) + return fmt.Errorf("dispatch candidate %s for flow %s became invalid before dispatch: %w", itemAccessor.RequestID(), itemAccessor.FlowID(), err) + } + + // This is idempotent. It is possible the dispatch candidate was already finalized by other means, such as concurrent + // expiry or preemption. + // Whatever outcome was reported first "wins". + removedItem.finalize(types.QueueOutcomeDispatched, nil) + logger.V(logutil.DEFAULT).Info("Request dispatched.") + return nil +} + +// applyDispatchPolicies orchestrates the selection of a single item for dispatch from a given priority band. +// It performs the following steps: +// 1. Retrieves the InterFlowDispatchPolicy for the band. +// 2. Calls the inter-flow policy to select a dispatch candidate FlowQueueAccessor from the band. +// 3. If a candidate queue is selected, retrieves the IntraFlowDispatchPolicy for that specific flow. +// 4. Calls the intra-flow policy to select a dispatch candidate QueueItemAccessor from the chosen queue. +func (fc *FlowController) applyDispatchPolicies( + bandAccessor types.PriorityBandAccessor, + logger logr.Logger, +) (selectedItem types.QueueItemAccessor, err error) { + logger = logger.WithName("applyDispatchPolicies") + interPolicy, err := fc.registry.InterFlowDispatchPolicy(bandAccessor.Priority()) + if err != nil { + return nil, fmt.Errorf("failed to get InterFlowDispatchPolicy for band %d ('%s'): %w", + bandAccessor.Priority(), bandAccessor.PriorityName(), err) + } + queueAccessor, err := interPolicy.SelectQueue(bandAccessor) + if err != nil { + return nil, fmt.Errorf("InterFlowDispatchPolicy SelectQueue failed for band %d ('%s'): %w", + bandAccessor.Priority(), bandAccessor.PriorityName(), err) + } + if queueAccessor == nil { + logger.V(logutil.DEBUG).Info("No queue selected by inter-flow dispatch policy in this band.") + return nil, nil + } + + flowSpec := queueAccessor.FlowSpec() + logger = logger.WithValues("flowID", flowSpec.ID(), "queueType", queueAccessor.Name()) + + intraPolicy, err := fc.registry.IntraFlowDispatchPolicy(flowSpec.ID(), flowSpec.Priority()) + if err != nil { + return nil, fmt.Errorf("failed to get IntraFlowDispatchPolicy for flow '%s' in band %d ('%s'): %w", + flowSpec.ID(), bandAccessor.Priority(), bandAccessor.PriorityName(), err) + } + itemAccessor := intraPolicy.SelectItem(queueAccessor) + if itemAccessor == nil { + logger.V(logutil.DEBUG).Info("No item selected by intra-flow dispatch policy from this queue.") + return nil, nil + } + logger.V(logutil.DEBUG).Info("Item selected for dispatch.", "reqID", itemAccessor.RequestID()) + return itemAccessor, nil +} + +// runExpiryCleanup periodically checks for and removes expired items from all queues. +func (fc *FlowController) runExpiryCleanup(ctx context.Context) { + defer fc.wg.Done() + logger := fc.logger.WithName("runExpiryCleanup") + logger.V(logutil.VERBOSE).Info("Expiry cleanup goroutine starting.") + defer logger.V(logutil.VERBOSE).Info("Expiry cleanup goroutine stopped.") + + ticker := time.NewTicker(fc.config.ExpiryCleanupInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-fc.stopCh: + return + case now := <-ticker.C: + logger.V(logutil.DEBUG).Info("Running periodic expiry cleanup cycle.") + fc.cleanupAllExpiredItems(now, logger) + } + } +} + +// isItemExpiredFunc is a factory that returns a types.IsItemExpiredFunc. +// The returned function checks if a given QueueItemAccessor is considered expired due to TTL violation or context +// cancellation. It also handles the edge case where an item might already be finalized. +func isItemExpiredFunc(logger logr.Logger) types.IsItemExpiredFunc { + return func(itemAccessor types.QueueItemAccessor, currentTime time.Time) (bool, types.QueueOutcome, error) { + fi, ok := itemAccessor.(*flowItem) + if !ok { + panic(fmt.Errorf("CRITICAL: item '%s' from flow '%s' queue is not of type *flowItem, but %T", + itemAccessor.RequestID(), itemAccessor.FlowID(), itemAccessor)) + } + + itemLogger := logger.WithValues( + "reqID", itemAccessor.RequestID(), + "flowID", itemAccessor.FlowID(), + "reqByteSize", itemAccessor.ByteSize(), + ) + + if fi.isFinalized() { + // This should ideally not happen if items are correctly removed from queues upon finalization. + // However, if it does, treat it as "expired" to ensure it is cleaned up from the queue. + itemLogger.V(logutil.DEBUG).Info("Item already finalized, treating as expired to trigger removal since it should no longer be in queue.") + outcome, err := fi.getFinalState() + return true, outcome, err + } + + if ctxErr := fi.OriginalRequest().Context().Err(); ctxErr != nil { + itemLogger.V(logutil.DEBUG).Info("Request context cancelled.", "contextErr", ctxErr) + return true, types.QueueOutcomeEvictedContextCancelled, fmt.Errorf("%w: %w: %w", types.ErrEvicted, types.ErrContextCancelled, ctxErr) + } + + if fi.EffectiveTTL() > 0 && currentTime.Sub(fi.EnqueueTime()) > fi.EffectiveTTL() { + itemLogger.V(logutil.DEBUG).Info("TTL expired, treating as expired.", + "overTTL", currentTime.Sub(fi.EnqueueTime())-fi.EffectiveTTL()) + return true, types.QueueOutcomeEvictedTTL, fmt.Errorf("%w: %w", types.ErrEvicted, types.ErrTTLExpired) + } + return false, types.QueueOutcomeDispatched /* any value, not used */, nil + } +} + +// cleanupAllExpiredItems iterates through all managed queues and removes expired items using the standard expiry logic. +func (fc *FlowController) cleanupAllExpiredItems(now time.Time, logger logr.Logger) { + logger = logger.WithName("cleanupAllExpiredItems") + logger.V(logutil.DEBUG).Info("Cleaning up all expired items.") + fc.applyIsItemExpiredFunc(now, logger, isItemExpiredFunc) + logger.V(logutil.DEBUG).Info("Completed cleaning up all expired items.") +} + +// evictAllOnShutdown is called when the FlowController is stopping. +// It iterates all queues and finalizes any remaining items with a shutdown-related outcome. +func (fc *FlowController) evictAllOnShutdown(shutdownError error, shutdownOutcome types.QueueOutcome) { + logger := fc.logger.WithName("evictAllOnShutdown") + logger.Info("Evicting all remaining items due to shutdown.", + "outcome", shutdownOutcome.String(), "error", shutdownError) + fc.applyIsItemExpiredFunc(fc.clock.Now(), logger, func(logger logr.Logger) types.IsItemExpiredFunc { + return func(itemAccessor types.QueueItemAccessor, currentTime time.Time) (bool, types.QueueOutcome, error) { + return true, shutdownOutcome, shutdownError + } + }) + logger.Info("Finished evicting all items on shutdown.") +} + +// applyIsItemExpiredFunc is a generic helper that orchestrates the cleanup of items across all priority bands and their +// respective queues. It operates with the following concurrency model: +// 1. Iterates through all configured priority bands, launching a separate goroutine for each band to process it +// concurrently. +// 2. Within each band-specific goroutine, it iterates through all flow queues in that band, launching a separate +// goroutine for each queue to process it concurrently. +// 3. For each queue, it calls `ManagedQueue.CleanupExpired()` with a provided `IsItemExpiredFunc` (produced by the +// factory `f`). This call is synchronous within the queue's goroutine, meaning the goroutine waits for +// `CleanupExpired` (which mutates the queue and its statistics) to complete. +// 4. After `CleanupExpired` returns the list of removed items, this function launches a new "fire-and-forget" +// goroutine for each removed item to finalize it (i.e., call `flowItem.finalize()`). The queue-processing +// goroutine (and thus the band-processing goroutine) does NOT wait for these individual item finalizations to +// complete. +// 5. The `applyIsItemExpiredFunc` method itself waits for all band-level goroutines to complete (which in turn wait +// for their queue-level `CleanupExpired` calls) before returning. +func (fc *FlowController) applyIsItemExpiredFunc( + now time.Time, + logger logr.Logger, + f func(logger logr.Logger) types.IsItemExpiredFunc, +) { + var bandWg sync.WaitGroup + for _, priority := range fc.registry.AllOrderedPriorityLevels() { + bandWg.Add(1) + go func(prio uint) { + defer bandWg.Done() + bandAccessor, err := fc.registry.PriorityBandAccessor(prio) + if err != nil { + logger.Error(err, "Failed to get PriorityBandAccessor.", "priority", prio) + return + } + bandLogger := logger.WithValues("priority", prio, "priorityName", bandAccessor.PriorityName()) + + var queueWg sync.WaitGroup + bandAccessor.IterateQueues(func(qAccessor types.FlowQueueAccessor) bool { + queueWg.Add(1) + go func(qAcc types.FlowQueueAccessor) { + defer queueWg.Done() + queueLogger := bandLogger.WithValues("flowID", qAcc.FlowSpec().ID(), "queueType", qAcc.Name()) + managedQ, err := fc.registry.ManagedQueue(qAcc.FlowSpec().ID(), qAcc.FlowSpec().Priority()) + if err != nil { + queueLogger.Error(err, "Failed to get ManagedQueue") + return + } + + // The factory `f` produces the specific IsItemExpiredFunc (e.g., standard expiry or shutdown eviction). + removedInfos, cleanupErr := managedQ.CleanupExpired(now, f(queueLogger)) + if cleanupErr != nil { + queueLogger.Error(cleanupErr, "Error during ManagedQueue CleanupExpired") + } + + // Finalize each removed item concurrently in a fire-and-forget manner. + // The queueWg.Done() call (and thus bandWg.Done()) will not wait for these. + for _, info := range removedInfos { + go func(i types.ExpiredItemInfo, qType string) { + fi, ok := i.Item.(*flowItem) + if !ok { + panic(fmt.Errorf("CRITICAL: expired item '%s' from flow '%s' queue '%s' is not of type *flowItem, but %T", + i.Item.RequestID(), i.Item.FlowID(), qType, i.Item)) + } + // Ensure the flowItem itself is finalized. This is idempotent. + fi.finalize(i.Outcome, i.Error) + queueLogger.V(logutil.VERBOSE).Info("Item removed by ManagedQueue CleanupExpired.", + "reqID", fi.RequestID(), "reqByteSize", fi.ByteSize(), + "outcome", i.Outcome.String(), "error", i.Error) + }(info, managedQ.Name()) + } + // Note: No itemWg.Wait() here, item finalization is fire-and-forget. + }(qAccessor) + return true // We swallow errors, so always keep iterating + }) + queueWg.Wait() // Wait for all queue CleanupExpired operations in this band to complete. + }(priority) + } + bandWg.Wait() // Wait for all band processing to complete. +} diff --git a/pkg/epp/flowcontroller/flowcontroller_test.go b/pkg/epp/flowcontroller/flowcontroller_test.go new file mode 100644 index 000000000..53729ddec --- /dev/null +++ b/pkg/epp/flowcontroller/flowcontroller_test.go @@ -0,0 +1,780 @@ +/* +Copyright 2025 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 flowcontroller + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/config" + mocks "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/testing/mocks" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/types" +) + +// Test Constants +const ( + testAsyncProcessingWait = 100 * time.Millisecond +) + +// --- Mock Saturation Detectors --- + +type mockSaturationDetector struct { + isSaturated bool + mu sync.RWMutex +} + +func newMockSaturationDetector(initialSaturation bool) *mockSaturationDetector { + return &mockSaturationDetector{isSaturated: initialSaturation} +} + +func (m *mockSaturationDetector) IsSaturated() bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.isSaturated +} + +func (m *mockSaturationDetector) SetSaturated(s bool) { + m.mu.Lock() + defer m.mu.Unlock() + m.isSaturated = s +} + +var _ SaturationDetector = &mockSaturationDetector{} + +// --- Mock Clock --- +type mockClock struct { + mu sync.Mutex + currentTime time.Time +} + +func newMockClock(initialTime time.Time) *mockClock { + return &mockClock{currentTime: initialTime} +} + +func (mc *mockClock) Now() time.Time { + mc.mu.Lock() + defer mc.mu.Unlock() + return mc.currentTime +} + +var _ clock = &mockClock{} + +// --- Mock FlowRegistry --- +type mockFlowRegistry struct { + types.FlowRegistry // For methods not directly called by FlowController, will panic if called + GetActiveManagedQueueFunc func(flowID string) (types.ManagedQueue, error) + GetPriorityBandAccessorFunc func(priority uint) (types.PriorityBandAccessor, error) + GetManagedQueueFunc func(flowID string, priority uint) (types.ManagedQueue, error) + GetInterFlowDispatchPolicyFunc func(priority uint) (types.InterFlowDispatchPolicy, error) + GetInterFlowPreemptionPolicyFunc func(priority uint) (types.InterFlowPreemptionPolicy, error) + GetIntraFlowDispatchPolicyFunc func(flowID string, priority uint) (types.IntraFlowDispatchPolicy, error) + GetIntraFlowPreemptionPolicyFunc func(flowID string, priority uint) (types.IntraFlowPreemptionPolicy, error) + GetAllOrderedPriorityLevelsFunc func() []uint + GetGetStatsFunc func() types.FlowRegistryStats +} + +func (mfr *mockFlowRegistry) ActiveManagedQueue(flowID string) (types.ManagedQueue, error) { + if mfr.GetActiveManagedQueueFunc != nil { + return mfr.GetActiveManagedQueueFunc(flowID) + } + panic("mockFlowRegistry.ActiveManagedQueue not implemented") +} + +func (mfr *mockFlowRegistry) PriorityBandAccessor(priority uint) (types.PriorityBandAccessor, error) { + if mfr.GetPriorityBandAccessorFunc != nil { + return mfr.GetPriorityBandAccessorFunc(priority) + } + panic("mockFlowRegistry.PriorityBandAccessor not implemented") +} + +func (mfr *mockFlowRegistry) ManagedQueue(flowID string, priority uint) (types.ManagedQueue, error) { + if mfr.GetManagedQueueFunc != nil { + return mfr.GetManagedQueueFunc(flowID, priority) + } + panic("mockFlowRegistry.ManagedQueue not implemented") +} + +func (mfr *mockFlowRegistry) InterFlowDispatchPolicy(priority uint) (types.InterFlowDispatchPolicy, error) { + if mfr.GetInterFlowDispatchPolicyFunc != nil { + return mfr.GetInterFlowDispatchPolicyFunc(priority) + } + panic("mockFlowRegistry.InterFlowDispatchPolicy not implemented") +} + +func (mfr *mockFlowRegistry) InterFlowPreemptionPolicy(priority uint) (types.InterFlowPreemptionPolicy, error) { + if mfr.GetInterFlowPreemptionPolicyFunc != nil { + return mfr.GetInterFlowPreemptionPolicyFunc(priority) + } + panic("mockFlowRegistry.InterFlowPreemptionPolicy not implemented") +} + +func (mfr *mockFlowRegistry) IntraFlowDispatchPolicy( + flowID string, + priority uint, +) (types.IntraFlowDispatchPolicy, error) { + if mfr.GetIntraFlowDispatchPolicyFunc != nil { + return mfr.GetIntraFlowDispatchPolicyFunc(flowID, priority) + } + panic("mockFlowRegistry.IntraFlowDispatchPolicy not implemented") +} + +func (mfr *mockFlowRegistry) IntraFlowPreemptionPolicy( + flowID string, + priority uint, +) (types.IntraFlowPreemptionPolicy, error) { + if mfr.GetIntraFlowPreemptionPolicyFunc != nil { + return mfr.GetIntraFlowPreemptionPolicyFunc(flowID, priority) + } + panic("mockFlowRegistry.IntraFlowPreemptionPolicy not implemented") +} + +func (mfr *mockFlowRegistry) AllOrderedPriorityLevels() []uint { + if mfr.GetAllOrderedPriorityLevelsFunc != nil { + return mfr.GetAllOrderedPriorityLevelsFunc() + } + panic("mockFlowRegistry.AllOrderedPriorityLevels not implemented") +} + +func (mfr *mockFlowRegistry) GetStats() types.FlowRegistryStats { + if mfr.GetGetStatsFunc != nil { + return mfr.GetGetStatsFunc() + } + panic("mockFlowRegistry.GetStats not implemented") +} + +var _ types.FlowRegistry = &mockFlowRegistry{} + +// --- Mock ManagedQueue --- + +type mockManagedQueueAddImpl func(item types.QueueItemAccessor) (newLen uint64, newByteSize uint64, err error) +type mockManagedQueueRemoveImpl func(handle types.QueueItemHandle) ( + removedItem types.QueueItemAccessor, newLen uint64, newByteSize uint64, err error, +) +type mockManagedQueueCleanupExpiredImpl func(currentTime time.Time, isItemExpired types.IsItemExpiredFunc) ( + removedItemsInfo []types.ExpiredItemInfo, err error, +) + +type mockManagedQueue struct { + types.ManagedQueue // For methods not directly called by FlowController, will panic if called + MockFlowSpecVal types.FlowSpecification + MockNameVal string + AddImpl mockManagedQueueAddImpl + RemoveImpl mockManagedQueueRemoveImpl + CleanupExpiredImpl mockManagedQueueCleanupExpiredImpl +} + +func (mmq *mockManagedQueue) FlowSpec() types.FlowSpecification { return mmq.MockFlowSpecVal } +func (mmq *mockManagedQueue) Name() string { return mmq.MockNameVal } + +func (mmq *mockManagedQueue) Add(item types.QueueItemAccessor) (newLen uint64, newByteSize uint64, err error) { + if mmq.AddImpl != nil { + return mmq.AddImpl(item) + } + panic("mockManagedQueue.AddImpl not set") +} + +func (mmq *mockManagedQueue) Remove( + handle types.QueueItemHandle, +) ( + removedItem types.QueueItemAccessor, newLen uint64, newByteSize uint64, err error) { + if mmq.RemoveImpl != nil { + return mmq.RemoveImpl(handle) + } + panic("mockManagedQueue.RemoveImpl not set") +} + +func (mmq *mockManagedQueue) CleanupExpired( + currentTime time.Time, + isItemExpired types.IsItemExpiredFunc, +) (removedItemsInfo []types.ExpiredItemInfo, err error) { + if mmq.CleanupExpiredImpl != nil { + return mmq.CleanupExpiredImpl(currentTime, isItemExpired) + } + panic("mockManagedQueue.CleanupExpiredImpl not set") +} + +var _ types.ManagedQueue = &mockManagedQueue{} + +// --- Mock Policies --- +type mockInterFlowDispatchPolicy struct { + SelectQueueFunc func(band types.PriorityBandAccessor) (selectedQueue types.FlowQueueAccessor, err error) + NameFunc func() string +} + +func (m *mockInterFlowDispatchPolicy) SelectQueue(band types.PriorityBandAccessor) (types.FlowQueueAccessor, error) { + if m.SelectQueueFunc != nil { + return m.SelectQueueFunc(band) + } + panic("mockInterFlowDispatchPolicy.SelectQueueFunc not set") +} +func (m *mockInterFlowDispatchPolicy) Name() string { + if m.NameFunc != nil { + return m.NameFunc() + } + return "mockInterFlowDispatchPolicy" +} + +var _ types.InterFlowDispatchPolicy = &mockInterFlowDispatchPolicy{} + +type mockIntraFlowDispatchPolicy struct { + SelectItemFunc func(queue types.FlowQueueAccessor) (selectedItem types.QueueItemAccessor) + ComparatorFunc func() types.ItemComparator + RequiredQueuesCapsFunc func() []types.QueueCapability + NameFunc func() string +} + +func (m *mockIntraFlowDispatchPolicy) SelectItem(queue types.FlowQueueAccessor) types.QueueItemAccessor { + if m.SelectItemFunc != nil { + return m.SelectItemFunc(queue) + } + panic("mockIntraFlowDispatchPolicy.SelectItemFunc not set") +} + +func (m *mockIntraFlowDispatchPolicy) Comparator() types.ItemComparator { + if m.ComparatorFunc != nil { + return m.ComparatorFunc() + } + // Return a default mock comparator if not set, as FlowController might indirectly access this via registry. + return mocks.NewMockItemComparator(nil, "default-mock-comparator") +} + +func (m *mockIntraFlowDispatchPolicy) RequiredQueueCapabilities() []types.QueueCapability { + if m.RequiredQueuesCapsFunc != nil { + return m.RequiredQueuesCapsFunc() + } + return nil // Default: no specific capabilities required by mock +} + +func (m *mockIntraFlowDispatchPolicy) Name() string { + if m.NameFunc != nil { + return m.NameFunc() + } + return "mockIntraFlowDispatchPolicy" +} + +var _ types.IntraFlowDispatchPolicy = &mockIntraFlowDispatchPolicy{} + +type mockInterFlowPreemptionPolicy struct { + SelectVictimQueueFunc func(victimBand types.PriorityBandAccessor) (victimQueue types.FlowQueueAccessor, err error) + NameFunc func() string +} + +func (m *mockInterFlowPreemptionPolicy) SelectVictimQueue( + victimBand types.PriorityBandAccessor, +) (types.FlowQueueAccessor, error) { + if m.SelectVictimQueueFunc != nil { + return m.SelectVictimQueueFunc(victimBand) + } + panic("mockInterFlowPreemptionPolicy.SelectVictimQueueFunc not set") +} +func (m *mockInterFlowPreemptionPolicy) Name() string { + if m.NameFunc != nil { + return m.NameFunc() + } + return "mockInterFlowPreemptionPolicy" +} + +var _ types.InterFlowPreemptionPolicy = &mockInterFlowPreemptionPolicy{} + +type mockIntraFlowPreemptionPolicy struct { + SelectVictimFunc func(queue types.FlowQueueAccessor) (victimItem types.QueueItemAccessor, err error) + RequiredQueuesCapsFunc func() []types.QueueCapability + NameFunc func() string +} + +func (m *mockIntraFlowPreemptionPolicy) SelectVictim(queue types.FlowQueueAccessor) (types.QueueItemAccessor, error) { + if m.SelectVictimFunc != nil { + return m.SelectVictimFunc(queue) + } + panic("mockIntraFlowPreemptionPolicy.SelectVictimFunc not set") +} +func (m *mockIntraFlowPreemptionPolicy) RequiredQueueCapabilities() []types.QueueCapability { + if m.RequiredQueuesCapsFunc != nil { + return m.RequiredQueuesCapsFunc() + } + return nil +} +func (m *mockIntraFlowPreemptionPolicy) Name() string { + if m.NameFunc != nil { + return m.NameFunc() + } + return "mockIntraFlowPreemptionPolicy" +} + +var _ types.IntraFlowPreemptionPolicy = &mockIntraFlowPreemptionPolicy{} + +// --- Test Rig --- +type flowControllerTestRig struct { + fc *FlowController + cfg config.FlowControllerConfig + mockRegistry *mockFlowRegistry + mockSatDetector SaturationDetector + mockClock *mockClock + logger logr.Logger + t *testing.T + cancelRunContext context.CancelFunc +} + +func defaultTestFlowControllerConfig() config.FlowControllerConfig { + return config.FlowControllerConfig{ + DefaultQueueTTL: 30 * time.Second, + ExpiryCleanupInterval: 100 * time.Millisecond, + MaxGlobalBytes: 1024 * 1024, // 1MB + } +} + +func setupTestRig( + t *testing.T, + cfg config.FlowControllerConfig, + registry *mockFlowRegistry, + satDetector SaturationDetector, +) (*flowControllerTestRig, func()) { + t.Helper() + + if cfg.ExpiryCleanupInterval == 0 { + cfg.ExpiryCleanupInterval = 100 * time.Millisecond // Ensure a default for tests + } + + rig := &flowControllerTestRig{ + cfg: cfg, + mockRegistry: registry, + mockSatDetector: satDetector, + mockClock: newMockClock(time.Now()), + logger: logr.Discard(), + t: t, + } + + var err error + rig.fc, err = NewFlowController(rig.mockSatDetector, rig.mockRegistry, cfg, rig.logger) + require.NoError(t, err, "NewFlowController failed") + rig.fc.clock = rig.mockClock + + runCtx, cancelRunCtx := context.WithCancel(context.Background()) + rig.cancelRunContext = cancelRunCtx + + // Start FC's Run loop. + fcDone := make(chan struct{}) + go func() { + defer close(fcDone) + rig.fc.Run(runCtx) + }() + + cleanup := func() { + t.Log("TestRig: Initiating cleanup, cancelling Run context.") + cancelRunCtx() + select { + case <-fcDone: + t.Log("TestRig: FlowController Run loop completed.") + case <-time.After(2 * time.Second): // Timeout for graceful shutdown + t.Error("TestRig: Timeout waiting for FlowController Run loop to complete.") + } + // Additional check for stopCh, though fcDone should be sufficient + select { + case <-rig.fc.stopCh: + t.Log("TestRig: FlowController stopCh is closed.") + default: + t.Log("TestRig: FlowController stopCh was not closed (or already checked).") + } + } + + return rig, cleanup +} + +// --- Test Helper Functions --- + +func newTestRequest( + t *testing.T, + reqID, + flowID string, + size uint64, + ttl time.Duration, + ctx context.Context, +) types.FlowControlRequest { + t.Helper() + if ctx == nil { + ctx = context.Background() + } + return &mocks.MockFlowControlRequest{ + MockCtx: ctx, + MockIDVal: reqID, + MockFlowIDVal: flowID, + MockSizeVal: size, + MockInitialEffectiveTTL: ttl, + } +} + +// Helper to wait for an item to be finalized and check its outcome. +func expectOutcome( + t *testing.T, + itemDone <-chan struct{}, + timeout time.Duration, + getFinalStateFunc func() (types.QueueOutcome, error), + expectedOutcome types.QueueOutcome, + expectError bool, + errorWraps []error, // Ensure the errors.Is evaluates to true for *all* of these +) { + t.Helper() + select { + case <-itemDone: + outcome, err := getFinalStateFunc() + assert.Equal(t, expectedOutcome, outcome, "Unexpected QueueOutcome") + if expectError { + require.Error(t, err, "Expected an error but got nil") + for _, expectedErr := range errorWraps { + require.ErrorIs(t, err, expectedErr, "Expected error to wrap %v, but it did not", expectedErr) + } + } else { + assert.NoError(t, err, "Expected no error but got one") + } + case <-time.After(timeout): + t.Fatalf("Timeout waiting for item to be finalized (expected outcome: %s)", expectedOutcome.String()) + } +} + +// newBasicManagedQueueAddImpl creates a default AddImpl for mockManagedQueue that captures the enqueued item. +// This is useful for tests that need to wait for an item to be enqueued before proceeding. +func newBasicManagedQueueAddImpl( + t *testing.T, + captureItemChan chan<- *flowItem, // Optional channel to send the captured *flowItem +) func(item types.QueueItemAccessor) (uint64, uint64, error) { + return func(item types.QueueItemAccessor) (uint64, uint64, error) { + t.Logf("basicManagedQueueAddImpl: Add called for item: %s", item.RequestID()) + fi, ok := item.(*flowItem) // FlowController creates flowItem + require.True(t, ok, "Item added to ManagedQueue should be *flowItem, got %T", item) + + mockHandle := mocks.NewMockQueueItemHandle(fi.RequestID()) // Use item ID as raw handle + fi.SetHandle(mockHandle) + + if captureItemChan != nil { + captureItemChan <- fi + } + return 1, item.ByteSize(), nil + } +} + +// --- Test Cases --- + +func TestFlowController_NewFlowController(t *testing.T) { + t.Parallel() + + logger := logr.Discard() + cfg := defaultTestFlowControllerConfig() + mockSatDet := newMockSaturationDetector(false) + mockReg := &mockFlowRegistry{} + + t.Run("ValidInitialization", func(t *testing.T) { + t.Parallel() + fc, err := NewFlowController(mockSatDet, mockReg, cfg, logger) + require.NoError(t, err) + require.NotNil(t, fc) + assert.Equal(t, cfg.DefaultQueueTTL, fc.config.DefaultQueueTTL) + assert.NotNil(t, fc.clock) // Should default to realClock + assert.NotNil(t, fc.enqueueChan) + assert.NotNil(t, fc.stopCh) + }) + + t.Run("NilSaturationDetector", func(t *testing.T) { + t.Parallel() + _, err := NewFlowController(nil, mockReg, cfg, logger) + require.Error(t, err) + assert.Contains(t, err.Error(), "SaturationDetector cannot be nil") + }) + + t.Run("NilFlowRegistry", func(t *testing.T) { + t.Parallel() + _, err := NewFlowController(mockSatDet, nil, cfg, logger) + require.Error(t, err) + assert.Contains(t, err.Error(), "FlowRegistry cannot be nil") + }) + + t.Run("InvalidConfig_DefaultTTL", func(t *testing.T) { + t.Parallel() + invalidCfg := cfg + invalidCfg.DefaultQueueTTL = 0 // Will be defaulted by validateAndApplyDefaults + fc, err := NewFlowController(mockSatDet, mockReg, invalidCfg, logger) + require.NoError(t, err) + assert.Equal(t, config.DefaultFCQueueTTL, fc.config.DefaultQueueTTL, "DefaultQueueTTL should be defaulted") + }) +} + +// func TestFlowController_EnqueueAndWait_InputValidation(t *testing.T) { +// t.Parallel() + +// t.Run("EnqueueAndWait_Reject_NilRequest", func(t *testing.T) { +// t.Parallel() +// cfg := defaultTestFlowControllerConfig() +// mockReg := &mockFlowRegistry{} +// rig, cleanup := setupTestRig(t, cfg, mockReg, newMockSaturationDetector(false)) +// defer cleanup() + +// outcome, err := rig.fc.EnqueueAndWait(nil) +// assert.Equal(t, types.QueueOutcomeRejectedOther, outcome) +// require.Error(t, err) +// assert.ErrorIs(t, err, types.ErrRejected) +// assert.ErrorIs(t, err, types.ErrNilRequest) +// }) + +// t.Run("EnqueueAndWait_Reject_EmptyFlowID", func(t *testing.T) { +// t.Parallel() +// cfg := defaultTestFlowControllerConfig() +// mockReg := &mockFlowRegistry{} +// // rig, cleanup := setupTestRig(t, cfg, mockReg, newMockSaturationDetector(false)) + +// outcome, err := rig.fc.EnqueueAndWait(newTestRequest(t, "req-1", "", 100, cfg.DefaultQueueTTL, nil)) + +// assert.Equal(t, types.QueueOutcomeRejectedOther, outcome) +// require.Error(t, err) +// assert.ErrorIs(t, err, types.ErrRejected) +// assert.ErrorIs(t, err, types.ErrFlowIDEmpty) +// }) +// } + +func TestFlowController_EnqueueAndWait_Lifecycle(t *testing.T) { + t.Parallel() + + t.Run("EnqueueAndWait_Dispatch", func(t *testing.T) { + t.Parallel() + cfg := defaultTestFlowControllerConfig() + + // Setup Mocks + flowID := "test-flow" + priority := uint(0) + reqID := "req-1" + itemByteSize := uint64(100) + + // enqueuedItemPtr will hold the address of the flowItem once captured. + // This allows other mock funcs to safely access the item after it's captured. + var enqueuedItemPtr **flowItem + captureItemChan := make(chan *flowItem) + enqueuedItemPtrReady := make(chan struct{}) + + mockReg := &mockFlowRegistry{} + mockMQ := &mockManagedQueue{ + MockFlowSpecVal: mocks.NewMockFlowSpecification(flowID, priority), + MockNameVal: "mock-mq", + AddImpl: newBasicManagedQueueAddImpl(t, captureItemChan), + RemoveImpl: func(handle types.QueueItemHandle) (types.QueueItemAccessor, uint64, uint64, error) { + require.NotNil(t, enqueuedItemPtr, "enqueuedItemPtr should not be nil in RemoveImpl") + item := *enqueuedItemPtr + require.NotNil(t, item, "enqueuedItem should have been set by AddImpl") + require.NotNil(t, item.Handle(), "enqueuedItem handle should not be nil before comparison") + require.NotNil(t, handle, "passed handle to RemoveImpl should not be nil") + assert.Equal(t, item.Handle().Handle(), handle.Handle(), "Handle mismatch in Remove") + t.Logf("mockManagedQueue.Remove called for item: %s", item.RequestID()) + return item, 0, 0, nil + }, + } + + mockReg.GetActiveManagedQueueFunc = func(fID string) (types.ManagedQueue, error) { + require.Equal(t, flowID, fID) + return mockMQ, nil + } + mockReg.GetPriorityBandAccessorFunc = func(prio uint) (types.PriorityBandAccessor, error) { + require.Equal(t, priority, prio) + return &mocks.MockPriorityBandAccessor{MockCapacityBytes: 200}, nil + } + mockReg.GetAllOrderedPriorityLevelsFunc = func() []uint { return []uint{priority} } + mockReg.GetGetStatsFunc = func() types.FlowRegistryStats { + return types.FlowRegistryStats{ + GlobalByteSize: 0, + GlobalLen: 0, + PerPriorityBandStats: map[uint]types.PriorityBandStats{ + priority: {ByteSize: 0, Len: 0}, + }, + } + } + mockFQA := mocks.NewMockFlowQueueAccessor(mockMQ.MockFlowSpecVal, "mock-fqa", nil, nil) + mockReg.GetInterFlowDispatchPolicyFunc = func(prio uint) (types.InterFlowDispatchPolicy, error) { + return &mockInterFlowDispatchPolicy{ + SelectQueueFunc: func(band types.PriorityBandAccessor) (types.FlowQueueAccessor, error) { + return mockFQA, nil + }, + }, nil + } + mockReg.GetIntraFlowDispatchPolicyFunc = func(fID string, prio uint) (types.IntraFlowDispatchPolicy, error) { + return &mockIntraFlowDispatchPolicy{ + SelectItemFunc: func(queue types.FlowQueueAccessor) types.QueueItemAccessor { + // Wait until the main test goroutine signals that enqueuedItemPtr is ready. + select { + case <-enqueuedItemPtrReady: + // enqueuedItemPtr is now guaranteed to be set by the main test goroutine + require.NotNil(t, enqueuedItemPtr, "enqueuedItemPtr should be non-nil after ready signal") + item := *enqueuedItemPtr + require.NotNil(t, item, "enqueuedItem should be available for SelectItem") + t.Logf("mockIntraFlowDispatchPolicy.SelectItem returning item: %s", item.RequestID()) + return item + case <-time.After(testAsyncProcessingWait): + t.Errorf("Timeout waiting for enqueuedItemPtr to be ready in SelectItemFunc") + // To avoid panicking the FC's goroutine, return nil, which will cause dispatch to fail for this cycle. + return nil + } + }, + }, nil + } + mockReg.GetManagedQueueFunc = func(fID string, prio uint) (types.ManagedQueue, error) { + return mockMQ, nil // Needed by dispatchItem to remove + } + + rig, cleanup := setupTestRig(t, cfg, mockReg, newMockSaturationDetector(false)) + defer cleanup() + + // Create request + req := newTestRequest(t, reqID, flowID, itemByteSize, cfg.DefaultQueueTTL, nil) + + // Call EnqueueAndWait in a goroutine as it blocks + enqueueDone := make(chan struct{}) + go func() { + defer close(enqueueDone) + rig.fc.EnqueueAndWait(req) + }() + + // Block here until AddImpl sends the item. + // Once received, store its address in enqueuedItemPtr. + itemFromChan := <-captureItemChan + enqueuedItemPtr = &itemFromChan // Now enqueuedItemPtr points to the captured item. + close(enqueuedItemPtrReady) // Signal that enqueuedItemPtr is now set and ready for use. + + expectOutcome(t, (*enqueuedItemPtr).done, testAsyncProcessingWait, (*enqueuedItemPtr).getFinalState, + types.QueueOutcomeDispatched, false, nil) + require.NotNil(t, *enqueuedItemPtr, "enqueuedItem (dereferenced) should be set if dispatch occurred") + }) + + t.Run("EnqueueAndWait_Evicted_ContextCancelled", func(t *testing.T) { + t.Skip() + }) + + t.Run("EnqueueAndWait_Evicted_TTL", func(t *testing.T) { + t.Parallel() + cfg := defaultTestFlowControllerConfig() + cfg.ExpiryCleanupInterval = 20 * time.Millisecond + itemTTL := 50 * time.Millisecond + + flowID := "ttl-flow" + priority := uint(1) + reqID := "req-ttl" + itemByteSize := uint64(50) + + var enqueuedItemPtr **flowItem + captureItemChan := make(chan *flowItem) + enqueuedItemPtrReady := make(chan struct{}) + itemCleanedUpSignal := make(chan struct{}) + + mockReg := &mockFlowRegistry{} + mockMQ := &mockManagedQueue{ + MockFlowSpecVal: mocks.NewMockFlowSpecification(flowID, priority), + MockNameVal: "mock-ttl-mq", + AddImpl: newBasicManagedQueueAddImpl(t, captureItemChan), + CleanupExpiredImpl: func( + currentTime time.Time, + isItemExpired types.IsItemExpiredFunc, + ) ([]types.ExpiredItemInfo, error) { + t.Logf("mockManagedQueue.CleanupExpiredImpl called at mock time: %v", currentTime) + var removed []types.ExpiredItemInfo + // This mock assumes only one item is in it for simplicity. + // A real queue would iterate its items. + if enqueuedItemPtr != nil && *enqueuedItemPtr != nil { + item := *enqueuedItemPtr + // Use the FC's provided expiry check logic + expired, outcome, err := isItemExpired(item, currentTime) + if expired { + t.Logf("mockManagedQueue.CleanupExpiredImpl: Item %s determined to be expired. Outcome: %s, Err: %v", + item.RequestID(), outcome, err) + removed = append(removed, types.ExpiredItemInfo{ + Item: item, + Outcome: outcome, + Error: err, + }) + // Signal that cleanup has processed this item. + // Do this before item.Handle().Invalidate() if the handle is checked by the test. + close(itemCleanedUpSignal) + if item.Handle() != nil { + item.Handle().Invalidate() + } + } + } + return removed, nil + }, + } + + mockReg.GetActiveManagedQueueFunc = func(fID string) (types.ManagedQueue, error) { return mockMQ, nil } + mockReg.GetPriorityBandAccessorFunc = func(prio uint) (types.PriorityBandAccessor, error) { + return &mocks.MockPriorityBandAccessor{MockCapacityBytes: 200, MockPriorityVal: prio}, nil + } + mockReg.GetAllOrderedPriorityLevelsFunc = func() []uint { return []uint{priority} } + mockReg.GetGetStatsFunc = func() types.FlowRegistryStats { return types.FlowRegistryStats{} } + // No dispatch policies needed as we expect TTL expiry. + mockReg.GetInterFlowDispatchPolicyFunc = func(uint) (types.InterFlowDispatchPolicy, error) { return &mockInterFlowDispatchPolicy{}, nil } + mockReg.GetIntraFlowDispatchPolicyFunc = func(string, uint) (types.IntraFlowDispatchPolicy, error) { return &mockIntraFlowDispatchPolicy{}, nil } + mockReg.GetManagedQueueFunc = func(string, uint) (types.ManagedQueue, error) { return mockMQ, nil } + + rig, cleanup := setupTestRig(t, cfg, mockReg, newMockSaturationDetector(false)) + defer cleanup() + + req := newTestRequest(t, reqID, flowID, itemByteSize, itemTTL, nil) + + go func() { rig.fc.EnqueueAndWait(req) }() + + itemFromChan := <-captureItemChan + enqueuedItemPtr = &itemFromChan + close(enqueuedItemPtrReady) // Not strictly needed for SelectItem in this test, but good practice. + + // Advance time past TTL and cleanup interval + rig.mockClock.currentTime = rig.mockClock.currentTime.Add(itemTTL + cfg.ExpiryCleanupInterval + (5 * time.Millisecond)) + t.Logf("Advanced mock clock to: %v", rig.mockClock.currentTime) + + expectOutcome(t, (*enqueuedItemPtr).done, testAsyncProcessingWait*3, (*enqueuedItemPtr).getFinalState, + types.QueueOutcomeEvictedTTL, true, []error{types.ErrEvicted, types.ErrTTLExpired}) + }) + + t.Run("EnqueueAndWait_Evicted_Preemption", func(t *testing.T) { + t.Skip() + }) + + t.Run("EnqueueAndWait_Evicted_FlowControllerShutdown", func(t *testing.T) { + t.Skip() + }) +} + +// func TestFlowController_Run_Shutdown(t *testing.T) { +// t.Parallel() +// cfg := defaultTestFlowControllerConfig() +// mockReg := &mockFlowRegistry{} +// // Ensure registry is minimally mocked for shutdown processing, specifically AllOrderedPriorityLevels for +// // evictAllOnShutdown. +// mockReg.GetAllOrderedPriorityLevelsFunc = func() []uint { return []uint{} } // No bands, simplest case +// rig, cleanup := setupTestRig(t, cfg, mockReg, newMockSaturationDetector(false)) + +// // Call cleanup, which cancels the context for rig.fc.Run +// cleanup() // This will block until fcDone or timeout + +// // Additional assertion: check if stopCh is closed (idempotent check) +// select { +// case <-rig.fc.stopCh: +// // Success, stopCh is closed +// default: +// t.Error("FlowController stopCh was not closed after shutdown") +// } + +// // Needed assertions: +// // - evictAllOnShutdown was called +// // - fc.wg.Wait() is honored (ensuring runExpiryCleanup finishes). +// } diff --git a/pkg/epp/flowcontroller/flowitem.go b/pkg/epp/flowcontroller/flowitem.go new file mode 100644 index 000000000..d79bded44 --- /dev/null +++ b/pkg/epp/flowcontroller/flowitem.go @@ -0,0 +1,159 @@ +/* +Copyright 2025 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 flowcontroller + +import ( + "sync" + "sync/atomic" + "time" + + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/types" +) + +// flowItem is the internal representation of a request managed by the FlowController. +// +// It wraps the original FlowControlRequest and adds metadata for queuing, lifecycle management, and policy +// interaction. +// +// flowItem implements the types.QueueItemAccessor interface. +type flowItem struct { + // originalRequest is the underlying request submitted to the FlowController. + originalRequest types.FlowControlRequest + // flowID is the unique identifier of the flow this item belongs to. + // This is cached from originalRequest.FlowID(). + flowID string + // enqueueTime is the timestamp when the item was logically accepted by the FlowController for processing (i.e., when + // EnqueueAndWait was called). + enqueueTime time.Time + // effectiveTTL is the actual Time-To-Live assigned to this item by the FlowController, considering the request's + // preference and controller defaults. + effectiveTTL time.Duration + // queueHandle is the opaque handle returned by the FlowQueue when this item is successfully added to a queue. + // It's used by the FlowController to instruct the FlowQueue to remove this specific item. + // This is nil until the item is successfully enqueued into a FlowQueue. + queueHandle types.QueueItemHandle + // done is closed exactly once when the item is finalized (dispatched or evicted/rejected). Callers of + // EnqueueAndWait() block on this channel. + done chan struct{} + // err stores the final error state if the item was not successfully dispatched. + // Set atomically via finalize(). + err atomic.Value // Stores error + // outcome stores the final QueueOutcome of the item's lifecycle. + // Set atomically via finalize(). + outcome atomic.Value // Stores types.QueueOutcome + // finalizedOnce ensures the finalize() logic runs only once. + finalizedOnce sync.Once +} + +// newFlowItem creates a new flowItem. +// The flowSpec and effectiveTTL are determined and provided by the FlowController. +func newFlowItem(req types.FlowControlRequest, effectiveTTL time.Duration, enqueueTime time.Time) *flowItem { + fi := &flowItem{ + originalRequest: req, + flowID: req.FlowID(), + enqueueTime: enqueueTime, + effectiveTTL: effectiveTTL, + done: make(chan struct{}), + } + // Initialize outcome to a sensible default before any processing. + // If rejected pre-queue, this might be updated by the EnqueueAndWait logic. + fi.outcome.Store(types.QueueOutcomeRejectedOther) // A pessimistic default until explicitly set otherwise + return fi +} + +// --- Implementation of types.QueueItemAccessor --- + +var _ types.QueueItemAccessor = &flowItem{} // Compile-time validation + +func (fi *flowItem) EnqueueTime() time.Time { + return fi.enqueueTime +} + +func (fi *flowItem) ByteSize() uint64 { + return fi.originalRequest.ByteSize() +} + +func (fi *flowItem) FlowID() string { + return fi.flowID +} + +func (fi *flowItem) EffectiveTTL() time.Duration { + return fi.effectiveTTL +} + +func (fi *flowItem) RequestID() string { + return fi.originalRequest.ID() +} + +func (fi *flowItem) OriginalRequest() types.FlowControlRequest { + return fi.originalRequest +} + +func (fi *flowItem) Handle() types.QueueItemHandle { + return fi.queueHandle +} + +func (fi *flowItem) SetHandle(handle types.QueueItemHandle) { + fi.queueHandle = handle +} + +// --- Lifecycle Management Methods (called by FlowController) --- + +// finalize sets the item's terminal state (outcome, error) and closes its 'done' channel idempotently using sync.Once. +// This is the single point where an item's lifecycle within the FlowController concludes. +// The FlowController is responsible for determining the correct outcome and error. +func (fi *flowItem) finalize(outcome types.QueueOutcome, err error) { + fi.finalizedOnce.Do(func() { + if err != nil { + fi.err.Store(err) + } + fi.outcome.Store(outcome) + close(fi.done) + }) +} + +// getFinalState extracts the final outcome and error stored atomically. +// Should be called after item.done is closed or known to be closed. +func (fi *flowItem) getFinalState() (types.QueueOutcome, error) { + outcomeVal := fi.outcome.Load() + errVal := fi.err.Load() + + var finalOutcome types.QueueOutcome + if oc, ok := outcomeVal.(types.QueueOutcome); ok { + finalOutcome = oc + } else { + // This case should ideally not happen if finalize is always called correctly. + // Default to an error state if outcome is not what's expected. + finalOutcome = types.QueueOutcomeRejectedOther // Or some other error default + } + + var finalErr error + if e, ok := errVal.(error); ok { + finalErr = e + } + return finalOutcome, finalErr +} + +// isFinalized checks if the item has been finalized without blocking. +func (fi *flowItem) isFinalized() bool { + select { + case <-fi.done: + return true + default: + return false + } +} diff --git a/pkg/epp/flowcontroller/flowregistry.go b/pkg/epp/flowcontroller/flowregistry.go new file mode 100644 index 000000000..cd31427cb --- /dev/null +++ b/pkg/epp/flowcontroller/flowregistry.go @@ -0,0 +1,926 @@ +/* +Copyright 2025 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 flowcontroller + +import ( + "fmt" + "sort" + "sync" + "sync/atomic" + "time" + + "github.com/go-logr/logr" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/config" + interd "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/dispatch/interflow" + intrad "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/dispatch/intraflow" + interp "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/preemption/interflow" + intrap "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/preemption/intraflow" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/queue" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/types" + logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" +) + +// flowInstance represents a specific instance of a flow within a priority band. +type flowInstance struct { + spec types.FlowSpecification // The specification that led to this instance's creation/activation + id string // Cached from spec.ID() for convenience + + queue types.ManagedQueue + intraFlowDispatchPolicy types.IntraFlowDispatchPolicy + intraFlowPreemptionPolicy types.IntraFlowPreemptionPolicy + + instanceMu sync.Mutex + isActive bool // Is this the instance to which new requests for this FlowID are routed? + isRegistered bool // Is the flow considered registered? (False if UnregisterFlow called) + + priority uint +} + +// priorityBandState holds the state for a single priority band within the FlowRegistry. +type priorityBandState struct { + priorityLevel uint + config config.PriorityBandConfig + + interFlowDispatchPolicy types.InterFlowDispatchPolicy + interFlowPreemptionPolicy types.InterFlowPreemptionPolicy + accessor types.PriorityBandAccessor // Cached accessor for this band + + // Band aggregated stats + bandByteSize atomic.Uint64 // Total byte size of all items in this band's queues + bandLen atomic.Uint64 // Total item count in this band's queues + + flowInstances map[string]*flowInstance +} + +// FlowRegistry manages the lifecycle of flows, their queues, and associated policies. +type FlowRegistry struct { + mu sync.RWMutex + + config config.FlowRegistryConfig + logger logr.Logger + + priorityBands map[uint]*priorityBandState + activeFlowInstances map[string]*flowInstance + allFlowInstances map[string]map[uint]*flowInstance + + // Global aggregated stats + globalByteSize atomic.Uint64 // Total byte size of all items queued items + globalLen atomic.Uint64 // Total queued items count +} + +var _ types.FlowRegistry = &FlowRegistry{} // Compile-time validation + +// NewFlowRegistry creates and initializes a new FlowRegistry. +func NewFlowRegistry(cfg config.FlowRegistryConfig, logger logr.Logger) (*FlowRegistry, error) { + logger = logger.WithName("flow-registry") + cfgCopy := cfg // Operate on a copy + // Validate and apply defaults to the config.FlowRegistryConfig (which cascades to config.PriorityBandConfigs) + if err := (&cfgCopy).ValidateAndApplyDefaults(logger.WithName("registry-config")); err != nil { + return nil, fmt.Errorf("invalid config.FlowRegistryConfig: %w", err) + } + + fr := &FlowRegistry{ + config: cfgCopy, + logger: logger, + priorityBands: make(map[uint]*priorityBandState), + activeFlowInstances: make(map[string]*flowInstance), + allFlowInstances: make(map[string]map[uint]*flowInstance), + } + + if len(fr.config.PriorityBands) == 0 { + logger.V(logutil.VERBOSE).Info("No priority bands defined in config; FlowRegistry will be empty initially.") + } + + // First, create all priorityBandState objects. + // The bandCfgs here have already had defaults applied by fr.config.validateAndApplyDefaults above. + for _, bandCfg := range fr.config.PriorityBands { + currentBandCfg := bandCfg + priorityVal := currentBandCfg.Priority + if _, ok := fr.priorityBands[priorityVal]; ok { + return nil, fmt.Errorf("duplicate priority band level configured: %d", priorityVal) + } + fr.priorityBands[priorityVal] = &priorityBandState{ + priorityLevel: priorityVal, + config: currentBandCfg, + flowInstances: make(map[string]*flowInstance), + accessor: fr.newInternalBandStateAccessor(priorityVal), + } + } + + // Then, iterate again to create accessors and instantiate inter-flow policies. + for priorityVal, pbs := range fr.priorityBands { + var err error + pbs.interFlowDispatchPolicy, err = interd.NewPolicyFromName(pbs.config.InterFlowDispatchPolicy) + if err != nil { + return nil, fmt.Errorf("failed to create inter-flow dispatch policy '%s' for band %d (%s): %w", pbs.config.InterFlowDispatchPolicy, priorityVal, pbs.config.PriorityName, err) + } + pbs.interFlowPreemptionPolicy, err = interp.NewPolicyFromName(pbs.config.InterFlowPreemptionPolicy) + if err != nil { + return nil, fmt.Errorf("failed to create inter-flow preemption policy '%s' for band %d (%s): %w", pbs.config.InterFlowPreemptionPolicy, priorityVal, pbs.config.PriorityName, err) + } + logger.V(logutil.VERBOSE).Info("Initialized priority band", "priority", priorityVal, "priorityName", pbs.config.PriorityName) + } + return fr, nil +} + +// RegisterOrUpdateFlow handles registration or update of a flow. +func (fr *FlowRegistry) RegisterOrUpdateFlow(spec types.FlowSpecification) error { + fr.mu.Lock() + defer fr.mu.Unlock() + + flowID := spec.ID() + if flowID == "" { + return fmt.Errorf("%w: flow ID in spec is empty", types.ErrFlowIDEmpty) + } + + targetPriority := spec.Priority() + targetPriorityName := fr.getPriorityNameFromConfig(targetPriority) + flowLogger := fr.logger.WithValues("operation", "RegisterOrUpdateFlow", "flowID", flowID, + "priority", targetPriority, "priorityName", targetPriorityName) + + if _, ok := fr.allFlowInstances[flowID]; !ok { + fr.allFlowInstances[flowID] = make(map[uint]*flowInstance) + } + + if _, ok := fr.priorityBands[targetPriority]; !ok { + return fmt.Errorf("%w: priority %d specified in flow spec '%s' is not a configured priority band", types.ErrInvalidFlowPriority, targetPriority, flowID) + } + + currentActiveInstance, isCurrentlyActive := fr.activeFlowInstances[flowID] + if !isCurrentlyActive { + flowLogger.V(logutil.DEFAULT).Info("Flow not currently registered, proceeding with registration and activation") + return fr.activateOrCreateInstanceInBand(spec, targetPriority, flowLogger) + } + + currentPriority := currentActiveInstance.priority + currentActiveInstance.instanceMu.Lock() + currentPriorityName := fr.getPriorityNameFromConfig(currentPriority) + + updateFlowLogger := flowLogger.WithValues("oldPriority", currentPriority, "oldPriorityName", currentPriorityName) + + if currentPriority == targetPriority { + updateFlowLogger.V(logutil.DEFAULT).Info("Priority unchanged, updating spec on active instance") + currentActiveInstance.spec = spec + currentActiveInstance.isRegistered = true + currentActiveInstance.instanceMu.Unlock() + return nil + } + + updateFlowLogger.V(logutil.DEFAULT).Info("Priority changed, performing live migration") + currentActiveInstance.isActive = false + currentActiveInstance.isRegistered = true // Still registered, just draining + currentActiveInstance.instanceMu.Unlock() + delete(fr.activeFlowInstances, flowID) + + // Attempt to cleanup the instance that was just made inactive, if it's already empty. + // This is a targeted, immediate cleanup attempt for the specific instance. + fr.tryCleanupInstance(currentActiveInstance.id, currentActiveInstance.priority, updateFlowLogger) + + // Re-ensure the map for flowID exists in allFlowInstances, as tryCleanupInstance might have removed it if the + // cleaned instance was the last one for that flowID. + if _, ok := fr.allFlowInstances[flowID]; !ok { + fr.allFlowInstances[flowID] = make(map[uint]*flowInstance) + } + return fr.activateOrCreateInstanceInBand(spec, targetPriority, flowLogger) +} + +// activateOrCreateInstanceInBand internal helper. Assumes fr.mu is write-locked. +func (fr *FlowRegistry) activateOrCreateInstanceInBand(spec types.FlowSpecification, targetPriority uint, logger logr.Logger) error { + flowID := spec.ID() + bandState, ok := fr.priorityBands[targetPriority] + if !ok { + return fmt.Errorf("%w: target priority band %d for flow %s not configured", types.ErrPriorityBandNotFound, targetPriority, flowID) + } + + var newActiveInstance *flowInstance + if existingInstanceInTargetBand, ok := fr.allFlowInstances[flowID][targetPriority]; ok { + logger.V(logutil.DEFAULT).Info("Re-activating existing instance in target band") + existingInstanceInTargetBand.instanceMu.Lock() + existingInstanceInTargetBand.spec = spec + existingInstanceInTargetBand.isActive = true + existingInstanceInTargetBand.isRegistered = true + existingInstanceInTargetBand.instanceMu.Unlock() + newActiveInstance = existingInstanceInTargetBand + } else { + logger.V(logutil.DEFAULT).Info("Creating new instance in target band") + createdInstance, err := fr.createFlowInstance(spec, bandState) + if err != nil { + return fmt.Errorf("failed to create flow instance in band %d: %w", targetPriority, err) + } + createdInstance.isActive = true // isRegistered is set to true in createFlowInstance + bandState.flowInstances[flowID] = createdInstance + fr.allFlowInstances[flowID][targetPriority] = createdInstance + initialQueueSize := createdInstance.queue.ByteSize() + initialQueueLen := uint64(createdInstance.queue.Len()) + bandState.bandByteSize.Add(initialQueueSize) + bandState.bandLen.Add(initialQueueLen) + fr.globalByteSize.Add(initialQueueSize) + fr.globalLen.Add(initialQueueLen) + newActiveInstance = createdInstance + } + + fr.activeFlowInstances[flowID] = newActiveInstance + logger.V(logutil.DEFAULT).Info("Flow instance is now active in band") + return nil +} + +// UnregisterFlow marks a flow as inactive and eligible for cleanup once its queues are empty. +func (fr *FlowRegistry) UnregisterFlow(flowID string) error { + fr.mu.Lock() + defer fr.mu.Unlock() + + if flowID == "" { + return fmt.Errorf("%w: flow ID for unregistration is empty", types.ErrFlowIDEmpty) + } + + flowLogger := fr.logger.WithValues("operation", "UnregisterFlow", "flowID", flowID) + foundAndModified := false + var instancesToCheckForCleanup []*flowInstance + + if flowPriorityMap, ok := fr.allFlowInstances[flowID]; ok { + for _, instance := range flowPriorityMap { + instance.instanceMu.Lock() + if instance.isRegistered || instance.isActive { + instancePriorityName := fr.getPriorityNameFromConfig(instance.priority) + logger := flowLogger.WithValues("priority", instance.priority, "priorityName", instancePriorityName) + logger.V(logutil.DEFAULT).Info("Marking instance as unregistered and inactive") + instance.isRegistered = false + instance.isActive = false + foundAndModified = true + instancesToCheckForCleanup = append(instancesToCheckForCleanup, instance) + } + instance.instanceMu.Unlock() + } + } + + if _, isActive := fr.activeFlowInstances[flowID]; isActive { + delete(fr.activeFlowInstances, flowID) + foundAndModified = true + } + + if !foundAndModified { + return fmt.Errorf("%w: flow %s not found or already fully unregistered/inactive", types.ErrFlowNotRegistered, flowID) + } + + // Attempt immediate cleanup for instances that were just modified and might be empty. + for _, instance := range instancesToCheckForCleanup { + fr.tryCleanupInstance(instance.id, instance.priority, flowLogger) + } + + flowLogger.V(logutil.DEFAULT).Info("Flow marked as unregistered; all its instances are now inactive and will drain") + return nil +} + +// createFlowInstance internal helper. Assumes fr.mu is write-locked. +func (fr *FlowRegistry) createFlowInstance(spec types.FlowSpecification, bandState *priorityBandState) (*flowInstance, error) { + intraFlowDispatchPolicy, err := intrad.NewPolicyFromName(bandState.config.IntraFlowDispatchPolicy) + if err != nil { + return nil, fmt.Errorf("failed to create intra-flow dispatch policy '%s' for band %d (%s): %w", + bandState.config.IntraFlowDispatchPolicy, bandState.priorityLevel, bandState.config.PriorityName, err) + } + intraFlowPreemptionPolicy, err := intrap.NewPolicyFromName(bandState.config.IntraFlowPreemptionPolicy) + if err != nil { + return nil, fmt.Errorf("failed to create intra-flow preemption policy '%s' for band %d (%s): %w", + bandState.config.IntraFlowPreemptionPolicy, bandState.priorityLevel, bandState.config.PriorityName, err) + } + + itemComparator := intraFlowDispatchPolicy.Comparator() + safeQ, err := queue.NewQueueFromName(bandState.config.QueueType, itemComparator) + if err != nil { + return nil, fmt.Errorf("failed to create queue '%s' for band %d (%s): %w", + bandState.config.QueueType, bandState.priorityLevel, bandState.config.PriorityName, err) + } + + queueCapabilitiesMap := make(map[types.QueueCapability]bool) + for _, capability := range safeQ.Capabilities() { + queueCapabilitiesMap[capability] = true + } + + var missingDispatchCapabilities []types.QueueCapability + for _, requiredCapability := range intraFlowDispatchPolicy.RequiredQueueCapabilities() { + if !queueCapabilitiesMap[requiredCapability] { + missingDispatchCapabilities = append(missingDispatchCapabilities, requiredCapability) + } + } + var missingPreemptionCapabilities []types.QueueCapability + for _, requiredCapability := range intraFlowPreemptionPolicy.RequiredQueueCapabilities() { + if !queueCapabilitiesMap[requiredCapability] { + missingPreemptionCapabilities = append(missingPreemptionCapabilities, requiredCapability) + } + } + + if len(missingDispatchCapabilities) > 0 || len(missingPreemptionCapabilities) > 0 { + return nil, fmt.Errorf("queue '%s' is missing capabilities. For dispatch policy '%s': %v. For preemption policy '%s': %v", + bandState.config.QueueType, + bandState.config.IntraFlowDispatchPolicy, missingDispatchCapabilities, + bandState.config.IntraFlowPreemptionPolicy, missingPreemptionCapabilities) + } + + managedQ := newManagedQueueWrapper(safeQ, fr, spec, intraFlowDispatchPolicy.Comparator()) + + return &flowInstance{ + spec: spec, + id: spec.ID(), + queue: managedQ, + intraFlowDispatchPolicy: intraFlowDispatchPolicy, + intraFlowPreemptionPolicy: intraFlowPreemptionPolicy, + priority: bandState.priorityLevel, + isRegistered: true, // Created instances start as registered + }, nil +} + +// ActiveManagedQueue returns the queue of the currently active instance for the given flowID. +func (fr *FlowRegistry) ActiveManagedQueue(flowID string) (types.ManagedQueue, error) { + fr.mu.RLock() + defer fr.mu.RUnlock() + + instance, ok := fr.activeFlowInstances[flowID] + if !ok { + return nil, fmt.Errorf("%w: no active instance for flow %s", types.ErrFlowNotRegistered, flowID) + } + + instance.instanceMu.Lock() + defer instance.instanceMu.Unlock() + instancePriorityName := fr.getPriorityNameFromConfig(instance.priority) + if !instance.isActive || !instance.isRegistered { + errMsg := fmt.Sprintf("invariant violation: flow instance %s (priority %d, name %s) in activeFlowInstances map is not active/registered (isActive: %t, isRegistered: %t)", + flowID, instance.priority, instancePriorityName, instance.isActive, instance.isRegistered) + fr.logger.Error(fmt.Errorf("%s", errMsg), "Critical internal state error in ActiveManagedQueue") + panic(errMsg) + } + return instance.queue, nil +} + +// ManagedQueue retrieves a specific flow instance's queue, regardless of its active status. +func (fr *FlowRegistry) ManagedQueue(flowID string, priority uint) (types.ManagedQueue, error) { + fr.mu.RLock() + defer fr.mu.RUnlock() + if priorityMap, ok := fr.allFlowInstances[flowID]; ok { + if instance, ok2 := priorityMap[priority]; ok2 { + if instance.queue == nil { + errMsg := fmt.Sprintf("invariant violation: flow instance %s at priority %d found but its queue is nil", + flowID, priority) + fr.logger.Error(fmt.Errorf("%s", errMsg), "Critical internal state error in ManagedQueue") + panic(errMsg) + } + return instance.queue, nil + } + } + return nil, fmt.Errorf("%w: for flow %s at priority %d", types.ErrFlowInstanceNotFound, flowID, priority) +} + +// IntraFlowDispatchPolicy retrieves a specific flow instance's intra-flow dispatch policy. +func (fr *FlowRegistry) IntraFlowDispatchPolicy(flowID string, priority uint) (types.IntraFlowDispatchPolicy, error) { + fr.mu.RLock() + defer fr.mu.RUnlock() + if priorityMap, ok := fr.allFlowInstances[flowID]; ok { + if instance, ok2 := priorityMap[priority]; ok2 { + if instance.intraFlowDispatchPolicy == nil { + errMsg := fmt.Sprintf("invariant violation: flow instance %s at priority %d found but IntraFlowDispatchPolicy is nil", + flowID, priority) + fr.logger.Error(fmt.Errorf("%s", errMsg), "Critical internal state error") + panic(errMsg) + } + return instance.intraFlowDispatchPolicy, nil + } + } + return nil, fmt.Errorf("%w: for flow %s at priority %d (dispatch policy)", types.ErrFlowInstanceNotFound, flowID, priority) +} + +// IntraFlowPreemptionPolicy retrieves a specific flow instance's intra-flow preemption policy. +func (fr *FlowRegistry) IntraFlowPreemptionPolicy(flowID string, priority uint) (types.IntraFlowPreemptionPolicy, error) { + fr.mu.RLock() + defer fr.mu.RUnlock() + if priorityMap, ok := fr.allFlowInstances[flowID]; ok { + if instance, ok2 := priorityMap[priority]; ok2 { + if instance.intraFlowPreemptionPolicy == nil { + errMsg := fmt.Sprintf("invariant violation: flow instance %s at priority %d found but IntraFlowPreemptionPolicy is nil", + flowID, priority) + fr.logger.Error(fmt.Errorf("%s", errMsg), "Critical internal state error") + panic(errMsg) + } + return instance.intraFlowPreemptionPolicy, nil + } + } + return nil, fmt.Errorf("%w: for flow %s at priority %d (preemption policy)", types.ErrFlowInstanceNotFound, flowID, priority) +} + +// InterFlowDispatchPolicy retrieves a priority band's inter-flow dispatch policy. +func (fr *FlowRegistry) InterFlowDispatchPolicy(priority uint) (types.InterFlowDispatchPolicy, error) { + fr.mu.RLock() + defer fr.mu.RUnlock() + if bandState, ok := fr.priorityBands[priority]; ok { + if bandState.interFlowDispatchPolicy == nil { + errMsg := fmt.Sprintf("invariant violation: priority band %d found but InterFlowDispatchPolicy is nil", priority) + fr.logger.Error(fmt.Errorf("%s", errMsg), "Critical internal state error") + panic(errMsg) + } + return bandState.interFlowDispatchPolicy, nil + } + return nil, fmt.Errorf("%w: level %d (dispatch policy)", types.ErrPriorityBandNotFound, priority) +} + +// InterFlowPreemptionPolicy retrieves a priority band's inter-flow preemption policy. +func (fr *FlowRegistry) InterFlowPreemptionPolicy(priority uint) (types.InterFlowPreemptionPolicy, error) { + fr.mu.RLock() + defer fr.mu.RUnlock() + if bandState, ok := fr.priorityBands[priority]; ok { + if bandState.interFlowPreemptionPolicy == nil { + errMsg := fmt.Sprintf("invariant violation: priority band %d found but InterFlowPreemptionPolicy is nil", priority) + fr.logger.Error(fmt.Errorf("%s", errMsg), "Critical internal state error") + panic(errMsg) + } + return bandState.interFlowPreemptionPolicy, nil + } + return nil, fmt.Errorf("%w: level %d (preemption policy)", types.ErrPriorityBandNotFound, priority) +} + +// PriorityBandAccessor retrieves a types.PriorityBandAccessor for a given priority level. +func (fr *FlowRegistry) PriorityBandAccessor(priority uint) (types.PriorityBandAccessor, error) { + fr.mu.RLock() + defer fr.mu.RUnlock() + bandState, ok := fr.priorityBands[priority] + if !ok { + return nil, fmt.Errorf("%w: level %d (accessor)", types.ErrPriorityBandNotFound, priority) + } + if bandState.accessor == nil { + errMsg := fmt.Sprintf("invariant violation: priority band %d found but its accessor is nil", priority) + fr.logger.Error(fmt.Errorf("%s", errMsg), "Critical internal state error") + panic(errMsg) + } + return bandState.accessor, nil +} + +// AllOrderedPriorityLevels returns configured priority levels in sorted order (highest to lowest priority where lowest +// numeric value means highest priority). +func (fr *FlowRegistry) AllOrderedPriorityLevels() []uint { + fr.mu.RLock() + defer fr.mu.RUnlock() + levels := make([]uint, 0, len(fr.priorityBands)) + for level := range fr.priorityBands { + levels = append(levels, level) + } + // Sort ascending for uint (lower value = higher priority). + sort.Slice(levels, func(i, j int) bool { return levels[i] < levels[j] }) + return levels +} + +// doesFlowInstanceExist checks if a flow instance for the given flowID and priority still exists in the registry +// (i.e., it has not been fully cleaned up). +// This is used by ManagedQueue wrappers to ensure they are not operating on a stale instance. +func (fr *FlowRegistry) doesFlowInstanceExist(flowID string, priority uint) bool { + fr.mu.RLock() + defer fr.mu.RUnlock() + + if priorityMap, flowExists := fr.allFlowInstances[flowID]; flowExists { + if _, instanceExists := priorityMap[priority]; instanceExists { + return true + } + } + fr.logger.V(logutil.DEBUG).Info("Flow instance does not exist in registry", "flowID", flowID, "priority", priority) + return false +} + +// tryCleanupInstance attempts to clean up a specific instance if it's eligible (unregistered, inactive, empty). +// It assumes fr.mu is write-locked. +// Returns true if cleanup occurred. +func (fr *FlowRegistry) tryCleanupInstance(flowID string, priority uint, logger logr.Logger) bool { + // Cannot use fr.doesFlowInstanceExist here because we already have the write lock. + priorityMap, flowExists := fr.allFlowInstances[flowID] + if !flowExists { + return false + } + instance, instanceExists := priorityMap[priority] + if !instanceExists { + return false + } + + instance.instanceMu.Lock() + isReg := instance.isRegistered + isAct := instance.isActive + instance.instanceMu.Unlock() + + if !isReg || !isAct { // Not registered OR not the active instance for this FlowID + if instance.queue != nil && instance.queue.Len() == 0 { + logger.V(logutil.DEFAULT).Info("Cleaning up eligible flow instance (empty, and either unregistered or inactive)", + "instanceIsRegistered", isReg, "instanceIsActive", isAct, "queueLen", instance.queue.Len()) + if bandState, ok := fr.priorityBands[priority]; ok { + delete(bandState.flowInstances, flowID) + } + delete(priorityMap, priority) + if len(priorityMap) == 0 { + delete(fr.allFlowInstances, flowID) + } + return true + } + } + return false +} + +// signalQueueEmptied is called by a ManagedQueue when its underlying SafeQueue becomes empty. +// This method attempts to clean up the corresponding flow instance if it's inactive or unregistered. +func (fr *FlowRegistry) signalQueueEmptied(flowID string, priority uint) { + fr.mu.Lock() + defer fr.mu.Unlock() + + flowLogger := fr.logger.WithName("signalQueueEmptied").WithValues("flowID", flowID, "priority", priority) + cleanedUp := fr.tryCleanupInstance(flowID, priority, flowLogger) + if cleanedUp { + flowLogger.V(logutil.DEFAULT).Info("Successfully cleaned up flow instance after queue emptied signal.") + } else { + flowLogger.V(logutil.DEBUG).Info("Flow instance not cleaned up after queue emptied signal (might be active, registered, or already gone).") + } +} + +// GetStats returns aggregated statistics for the FlowRegistry. +func (fr *FlowRegistry) GetStats() types.FlowRegistryStats { + fr.mu.RLock() + defer fr.mu.RUnlock() + + stats := types.FlowRegistryStats{ + GlobalByteSize: fr.globalByteSize.Load(), + GlobalLen: fr.globalLen.Load(), + PerPriorityBandStats: make(map[uint]types.PriorityBandStats), + } + + for priority, bandState := range fr.priorityBands { + stats.PerPriorityBandStats[priority] = types.PriorityBandStats{ + PriorityLevel: bandState.priorityLevel, + PriorityName: bandState.config.PriorityName, + ByteSize: bandState.bandByteSize.Load(), + Len: bandState.bandLen.Load(), + } + } + return stats +} + +// reconcileStats atomically updates band and global statistics by the given deltas. +func (fr *FlowRegistry) reconcileStats(priority uint, deltaLen int64, deltaByteSize int64) { + pb, ok := fr.priorityBands[priority] + if !ok { + errMsg := fmt.Sprintf("invariant violation: flow instance at priority %d found but priority band not configured", + priority) + fr.logger.Error(fmt.Errorf("%s", errMsg), "Critical internal state error in reconcileStats") + panic(errMsg) + } + // atomic.Add handles positive and negative deltas correctly when cast to uint64. + // e.g., Add(uint64(-5)) is equivalent to Add(^(uint64(5)-1)). + pb.bandLen.Add(uint64(deltaLen)) + pb.bandByteSize.Add(uint64(deltaByteSize)) + fr.globalLen.Add(uint64(deltaLen)) + fr.globalByteSize.Add(uint64(deltaByteSize)) +} + +// getPriorityNameFromConfig is a helper to safely get the canonical priority name. +// Assumes fr.mu might be RLocked or Locked by caller if necessary for bandConfig consistency. +func (fr *FlowRegistry) getPriorityNameFromConfig(priority uint) string { + if bandState, ok := fr.priorityBands[priority]; ok { + return bandState.config.PriorityName + } + // This should ideally not happen if priorities are validated upstream. + fr.logger.Error(fmt.Errorf("priority level %d not found in configured bands", priority), "Failed to get priority name") + return "UnknownPriority" +} + +// --- internalBandStateAccessor --- + +// internalBandStateAccessor implements types.PriorityBandAccessor. +type internalBandStateAccessor struct { + registry *FlowRegistry + bandPriority uint +} + +var _ types.PriorityBandAccessor = &internalBandStateAccessor{} // Compile-time validation + +// newInternalBandStateAccessor creates an accessor for inter-flow policies for a specific priority band. +func (fr *FlowRegistry) newInternalBandStateAccessor(level uint) *internalBandStateAccessor { + return &internalBandStateAccessor{registry: fr, bandPriority: level} +} + +func (iba *internalBandStateAccessor) CapacityBytes() uint64 { + iba.registry.mu.RLock() + defer iba.registry.mu.RUnlock() + bandState, ok := iba.registry.priorityBands[iba.bandPriority] + if !ok { + errMsg := fmt.Sprintf("invariant violation: internalBandStateAccessor.CapacityBytes() called for non-existent band %d", iba.bandPriority) + iba.registry.logger.Error(fmt.Errorf("%s", errMsg), "Critical internal state error") + panic(errMsg) + } + return bandState.config.MaxBytes +} + +func (iba *internalBandStateAccessor) Priority() uint { + return iba.bandPriority +} + +func (iba *internalBandStateAccessor) PriorityName() string { + iba.registry.mu.RLock() + defer iba.registry.mu.RUnlock() + bandState, ok := iba.registry.priorityBands[iba.bandPriority] + if !ok { + errMsg := fmt.Sprintf("invariant violation: internalBandStateAccessor.PriorityName() called for non-existent band %d", iba.bandPriority) + iba.registry.logger.Error(fmt.Errorf("%s", errMsg), "Critical internal state error") + panic(errMsg) + } + return bandState.config.PriorityName +} + +func (iba *internalBandStateAccessor) FlowIDs() []string { + iba.registry.mu.RLock() + defer iba.registry.mu.RUnlock() + + bandState, ok := iba.registry.priorityBands[iba.bandPriority] + if !ok { + errMsg := fmt.Sprintf("invariant violation: internalBandStateAccessor.FlowIDs() called for non-existent band %d", iba.bandPriority) + iba.registry.logger.Error(fmt.Errorf("%s", errMsg), "Critical internal state error") + panic(errMsg) + } + + ids := make([]string, 0, len(bandState.flowInstances)) + for flowID := range bandState.flowInstances { + // Include all flows (registered/unregisterd active/draining). + // This is a crucial part of enabling the flow queues to completely drain. + ids = append(ids, flowID) + } + return ids +} + +func (iba *internalBandStateAccessor) Queue(flowID string) types.FlowQueueAccessor { + iba.registry.mu.RLock() + defer iba.registry.mu.RUnlock() + + bandState, ok := iba.registry.priorityBands[iba.bandPriority] + if !ok { + errMsg := fmt.Sprintf("invariant violation: internalBandStateAccessor.Queue() called for non-existent band %d", iba.bandPriority) + iba.registry.logger.Error(fmt.Errorf("%s", errMsg), "Critical internal state error") + panic(errMsg) + } + + instance, ok := bandState.flowInstances[flowID] + if !ok { // This is not an invariant; a flowID might not be in this specific band. + return nil + } + + if instance.queue == nil { + errMsg := fmt.Sprintf("invariant violation: flow instance %s in band %d has a nil queue", flowID, iba.bandPriority) + iba.registry.logger.Error(fmt.Errorf("%s", errMsg), "Critical internal state error") + panic(errMsg) + } + + // It's okay to return queue of an unregistered or draining instance if the policy needs to inspect it. + // This is a crucial part of enabling the flow queues to completely drain. + return instance.queue.FlowQueueAccessor() +} + +func (iba *internalBandStateAccessor) IterateQueues(callback func(q types.FlowQueueAccessor) (keepIterating bool)) { + iba.registry.mu.RLock() + defer iba.registry.mu.RUnlock() + + bandState, ok := iba.registry.priorityBands[iba.bandPriority] + if !ok { + errMsg := fmt.Sprintf("invariant violation: internalBandStateAccessor.IterateQueues() called for non-existent band %d", iba.bandPriority) + iba.registry.logger.Error(fmt.Errorf("%s", errMsg), "Critical internal state error") + panic(errMsg) + } + + // Iterate through all flow instances in the band (register/unregisterd active/draining). + // This is a crucial part of enabling the flow queues to completely drain. + for _, instance := range bandState.flowInstances { + if instance.queue == nil { + errMsg := fmt.Sprintf("invariant violation: flow instance %s in band %d has a nil queue during IterateQueues", instance.id, iba.bandPriority) + iba.registry.logger.Error(fmt.Errorf("%s", errMsg), "Critical internal state error") + panic(errMsg) + } + if !callback(instance.queue.FlowQueueAccessor()) { + return // Stop iteration if callback returns false + } + } +} + +// --- managedQueueWrapper --- + +// managedQueueWrapper implements types.ManagedQueue. +// It wraps a types.SafeQueue and handles atomic statistics updates with the FlowRegistry. +type managedQueueWrapper struct { + safeQ types.SafeQueue + registry *FlowRegistry + flowSpec types.FlowSpecification + comparator types.ItemComparator + byteSize atomic.Uint64 + len atomic.Uint64 + logger logr.Logger +} + +var _ types.ManagedQueue = &managedQueueWrapper{} // Compile-time validation + +func newManagedQueueWrapper( + safeQ types.SafeQueue, + registry *FlowRegistry, + spec types.FlowSpecification, + comparator types.ItemComparator, +) *managedQueueWrapper { + queueLogger := registry.logger.WithName("managed-queue").WithValues( + "flowID", spec.ID(), + "priority", spec.Priority(), + "queueType", safeQ.Name(), + ) + mqw := &managedQueueWrapper{ + safeQ: safeQ, + registry: registry, + flowSpec: spec, + comparator: comparator, + logger: queueLogger, + } + mqw.len.Store(uint64(safeQ.Len())) + mqw.byteSize.Store(safeQ.ByteSize()) + return mqw +} + +// FlowQueueAccessor returns a new flowQueueAccessorImpl instance. +func (mqw *managedQueueWrapper) FlowQueueAccessor() types.FlowQueueAccessor { + return &flowQueueAccessorImpl{ + managedQueue: mqw, + flowSpec: mqw.flowSpec, + comparator: mqw.comparator, + } +} + +// Add wraps SafeQueue.Add and updates registry statistics. +func (mqw *managedQueueWrapper) Add(item types.QueueItemAccessor) (newLen uint64, newByteSize uint64, err error) { + if !mqw.registry.doesFlowInstanceExist(mqw.flowSpec.ID(), mqw.flowSpec.Priority()) { + err := fmt.Errorf("%w: flow instance %s (priority %d) no longer exists in registry", + types.ErrFlowInstanceNotFound, mqw.flowSpec.ID(), mqw.flowSpec.Priority()) + mqw.logger.Error(err, "Cannot Add item to a non-existent/cleaned-up flow instance.") + return mqw.len.Load(), mqw.byteSize.Load(), err + } + + len, byteSize := mqw.len.Load(), mqw.byteSize.Load() + newLen, newByteSize, err = mqw.safeQ.Add(item) + + deltaLen, deltaByteSize := int64(newLen)-int64(len), int64(newByteSize)-int64(byteSize) + if err == nil && item != nil { + if deltaLen != 1 || deltaByteSize != int64(item.ByteSize()) { + mqw.logger.V(logutil.DEBUG).Info("Inconsistent queue stats after Add", + "expectedLenDelta", 1, "expectedByteSizeDelta", item.ByteSize(), + "actualLenDelta", deltaLen, "actualByteSizeDelta", deltaByteSize) + } + } else { // Add failed or item was nil + if deltaLen != 0 || deltaByteSize != 0 { + mqw.logger.V(logutil.DEBUG).Info("Inconsistent queue stats after failed Add", + "expectedLenDelta", 0, "expectedByteSizeDelta", 0, + "actualLenDelta", deltaLen, "actualByteSizeDelta", deltaByteSize) + } + } + + mqw.len.Store(newLen) + mqw.byteSize.Store(newByteSize) + mqw.registry.reconcileStats(mqw.flowSpec.Priority(), deltaLen, deltaByteSize) + return newLen, newByteSize, err +} + +// Remove wraps SafeQueue.Remove and updates registry statistics. +func (mqw *managedQueueWrapper) Remove(handle types.QueueItemHandle) (removedItem types.QueueItemAccessor, newLen uint64, newByteSize uint64, err error) { + if !mqw.registry.doesFlowInstanceExist(mqw.flowSpec.ID(), mqw.flowSpec.Priority()) { + err := fmt.Errorf("%w: flow instance %s (priority %d) no longer exists in registry", + types.ErrFlowInstanceNotFound, mqw.flowSpec.ID(), mqw.flowSpec.Priority()) + mqw.logger.Error(err, "Cannot Remove item from a non-existent/cleaned-up flow instance.") + return nil, mqw.len.Load(), mqw.byteSize.Load(), err + } + + len, byteSize := mqw.len.Load(), mqw.byteSize.Load() + removedItem, newLen, newByteSize, err = mqw.safeQ.Remove(handle) + + deltaLen, deltaByteSize := int64(newLen)-int64(len), int64(newByteSize)-int64(byteSize) + if err == nil && removedItem != nil { + if deltaLen != -1 || deltaByteSize != -int64(removedItem.ByteSize()) { + mqw.logger.V(logutil.DEBUG).Info("Inconsistent queue stats after Remove", + "expectedLenDelta", -1, "expectedByteSizeDelta", -int64(removedItem.ByteSize()), + "actualLenDelta", deltaLen, "actualByteSizeDelta", deltaByteSize) + } + } else { // Removal failed or queue was empty + if deltaLen != 0 || deltaByteSize != 0 { + mqw.logger.V(logutil.DEBUG).Info("Inconsistent queue stats after failed Remove", + "expectedLenDelta", 0, "expectedByteSizeDelta", 0, + "actualLenDelta", deltaLen, "actualByteSizeDelta", deltaByteSize) + } + } + + mqw.len.Store(newLen) + mqw.byteSize.Store(newByteSize) + mqw.registry.reconcileStats(mqw.flowSpec.Priority(), deltaLen, deltaByteSize) + + if newLen == 0 { + mqw.registry.signalQueueEmptied(mqw.flowSpec.ID(), mqw.flowSpec.Priority()) + } + return removedItem, newLen, newByteSize, err +} + +// CleanupExpired wraps SafeQueue.CleanupExpired and updates registry statistics. +func (mqw *managedQueueWrapper) CleanupExpired(currentTime time.Time, isItemExpired types.IsItemExpiredFunc) (removedItemsInfo []types.ExpiredItemInfo, err error) { + len, byteSize := mqw.len.Load(), mqw.byteSize.Load() + removedItemsInfo, err = mqw.safeQ.CleanupExpired(currentTime, isItemExpired) + newLen, newByteSize := mqw.safeQ.Len(), mqw.safeQ.ByteSize() + + deltaLen, deltaByteSize := int64(newLen)-int64(len), int64(newByteSize)-int64(byteSize) + // Skip tracking delta against our expectations here since that would involve iterating through all removed items + // solely for logging purposes. + + mqw.len.Store(uint64(newLen)) + mqw.byteSize.Store(newByteSize) + mqw.registry.reconcileStats(mqw.flowSpec.Priority(), deltaLen, deltaByteSize) + + if newLen == 0 { + mqw.registry.signalQueueEmptied(mqw.flowSpec.ID(), mqw.flowSpec.Priority()) + } + return removedItemsInfo, err +} + +func (mqw *managedQueueWrapper) Len() int { + return mqw.safeQ.Len() +} + +func (mqw *managedQueueWrapper) ByteSize() uint64 { + return mqw.safeQ.ByteSize() +} + +func (mqw *managedQueueWrapper) Name() string { + return mqw.safeQ.Name() +} + +func (mqw *managedQueueWrapper) Capabilities() []types.QueueCapability { + return mqw.safeQ.Capabilities() +} + +func (mqw *managedQueueWrapper) PeekHead() (types.QueueItemAccessor, error) { + return mqw.safeQ.PeekHead() +} + +func (mqw *managedQueueWrapper) PeekTail() (types.QueueItemAccessor, error) { + return mqw.safeQ.PeekTail() +} + +// FlowSpec returns the flow specification associated with this managed queue. +func (mqw *managedQueueWrapper) FlowSpec() types.FlowSpecification { + // Note: No explicit check for doesFlowInstanceExist here. If the instance is gone, this returns the cached spec. + // Mutating operations on ManagedQueue *will* check and fail if the instance is gone. + // This method fulfills its non-nil contract. + return mqw.flowSpec +} + +// --- flowQueueAccessorImpl --- + +// flowQueueAccessorImpl implements types.FlowQueueAccessor. +// It provides a read-only view for policies. +type flowQueueAccessorImpl struct { + managedQueue *managedQueueWrapper // To access underlying SafeQueue's inspection methods + flowSpec types.FlowSpecification + comparator types.ItemComparator +} + +var _ types.FlowQueueAccessor = &flowQueueAccessorImpl{} // Compile-time validation + +func (fqa *flowQueueAccessorImpl) Comparator() types.ItemComparator { + return fqa.comparator +} + +func (fqa *flowQueueAccessorImpl) FlowSpec() types.FlowSpecification { + return fqa.flowSpec +} + +func (fqa *flowQueueAccessorImpl) Len() int { + return fqa.managedQueue.Len() +} + +func (fqa *flowQueueAccessorImpl) ByteSize() uint64 { + return fqa.managedQueue.ByteSize() +} + +func (fqa *flowQueueAccessorImpl) Name() string { + return fqa.managedQueue.Name() +} + +func (fqa *flowQueueAccessorImpl) Capabilities() []types.QueueCapability { + return fqa.managedQueue.Capabilities() +} + +func (fqa *flowQueueAccessorImpl) PeekHead() (types.QueueItemAccessor, error) { + return fqa.managedQueue.PeekHead() +} + +func (fqa *flowQueueAccessorImpl) PeekTail() (types.QueueItemAccessor, error) { + return fqa.managedQueue.PeekTail() +} diff --git a/pkg/epp/flowcontroller/flowregistry_test.go b/pkg/epp/flowcontroller/flowregistry_test.go new file mode 100644 index 000000000..553900abd --- /dev/null +++ b/pkg/epp/flowcontroller/flowregistry_test.go @@ -0,0 +1,1049 @@ +/* +Copyright 2025 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 flowcontroller + +import ( + "fmt" + "sort" + "sync" + "testing" + "time" + + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/config" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/queue" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/testing/mocks" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/types" + + // Import default plugins to ensure they are registered for the tests. + _ "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/dispatch/interflow" + _ "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/dispatch/intraflow" + _ "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/preemption/interflow" + _ "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/preemption/intraflow" +) + +// --- Test Constants and Helpers --- + +const ( + testPriorityCritical uint = 0 + testPriorityStandard uint = 1 + testPrioritySheddable uint = 2 + + testFlowID1 = "test-flow-1" + testFlowID2 = "test-flow-2" + testFlowID3 = "test-flow-3" + + mockQueueNameForRegistryTests = "MockQueueForRegistryTests" + mockQueueNameForCapabilityMismatchTest = "MockQueueForCapabilityMismatchTest" + failingQueueTypeForCreationFailureTest = "AlwaysFailingQueueTypeForTest" + mockQueueItemHandleValuePrefix = "mock-handle-" +) + +var ( + logger = logr.Discard() + // Standard test config with default ListQueues for all bands. + defaultTestRegistryConfig = config.FlowRegistryConfig{ + PriorityBands: []config.PriorityBandConfig{ + {Priority: testPriorityCritical, PriorityName: "Critical"}, + {Priority: testPriorityStandard, PriorityName: "Standard"}, + {Priority: testPrioritySheddable, PriorityName: "Sheddable"}, + }, + } + // Config using mock queues for specific tests. + mockQueueTestRegistryConfig = config.FlowRegistryConfig{ + PriorityBands: []config.PriorityBandConfig{ + {Priority: testPriorityCritical, PriorityName: "Critical-MockQ", QueueType: mockQueueNameForRegistryTests}, + {Priority: testPriorityStandard, PriorityName: "Standard-MockQ", QueueType: mockQueueNameForRegistryTests}, + {Priority: testPrioritySheddable, PriorityName: "Sheddable-MockQ", QueueType: mockQueueNameForRegistryTests}, + }, + } +) + +func init() { + // Register a versatile mock queue. + queue.RegisterQueue(mockQueueNameForRegistryTests, + func(_ types.ItemComparator) (types.SafeQueue, error) { + return newMockSafeQueue(mockQueueNameForRegistryTests, + []types.QueueCapability{ + types.CapabilityFIFO, + types.CapabilityDoubleEnded, + types.CapabilityPriorityConfigurable, // Assume it can take a comparator + }, + ), nil + }) + // Register a mock queue with no capabilities for mismatch tests. + queue.RegisterQueue(mockQueueNameForCapabilityMismatchTest, + func(_ types.ItemComparator) (types.SafeQueue, error) { + return newMockSafeQueue(mockQueueNameForCapabilityMismatchTest, nil), nil + }) + // Register a queue type that always fails creation. + queue.RegisterQueue(failingQueueTypeForCreationFailureTest, + func(_ types.ItemComparator) (types.SafeQueue, error) { + return nil, fmt.Errorf("queue factory deliberate failure for %s", failingQueueTypeForCreationFailureTest) + }) +} + +// mockSafeQueue is a more functional mock for types.SafeQueue. +type mockSafeQueue struct { + nameVal string + capabilitiesVal []types.QueueCapability + items map[string]types.QueueItemAccessor // item handle string -> item + itemOrder []string // Simulates order for PeekHead/Tail + mu sync.Mutex + lenVal int + byteSizeVal uint64 + comparator types.ItemComparator // Store if configured + cleanupExpiredError error +} + +var _ types.SafeQueue = &mockSafeQueue{} // Compile-time validation + +func newMockSafeQueue(name string, caps []types.QueueCapability) *mockSafeQueue { + return &mockSafeQueue{ + nameVal: name, + capabilitiesVal: caps, + items: make(map[string]types.QueueItemAccessor), + } +} + +func (mq *mockSafeQueue) Len() int { + mq.mu.Lock() + defer mq.mu.Unlock() + return mq.lenVal +} + +func (mq *mockSafeQueue) ByteSize() uint64 { + mq.mu.Lock() + defer mq.mu.Unlock() + return mq.byteSizeVal +} + +func (mq *mockSafeQueue) Name() string { return mq.nameVal } +func (mq *mockSafeQueue) Capabilities() []types.QueueCapability { return mq.capabilitiesVal } + +func (mq *mockSafeQueue) PeekHead() (types.QueueItemAccessor, error) { + mq.mu.Lock() + defer mq.mu.Unlock() + if len(mq.itemOrder) == 0 { + return nil, types.ErrQueueEmpty + } + item, ok := mq.items[mq.itemOrder[0]] + if !ok { // Should not happen if itemOrder is consistent + return nil, types.ErrQueueItemNotFound + } + return item, nil +} + +func (mq *mockSafeQueue) PeekTail() (types.QueueItemAccessor, error) { + mq.mu.Lock() + defer mq.mu.Unlock() + if len(mq.itemOrder) == 0 { + return nil, types.ErrQueueEmpty + } + if !mq.hasCapability(types.CapabilityDoubleEnded) { + return nil, types.ErrOperationNotSupported + } + item, ok := mq.items[mq.itemOrder[len(mq.itemOrder)-1]] + if !ok { // Should not happen if itemOrder is consistent + return nil, types.ErrQueueItemNotFound + } + return item, nil +} + +func (mq *mockSafeQueue) Add(item types.QueueItemAccessor) (uint64, uint64, error) { + mq.mu.Lock() + defer mq.mu.Unlock() + if item == nil { + return uint64(mq.lenVal), mq.byteSizeVal, types.ErrNilQueueItem + } + + handleValue := fmt.Sprintf("%s%s-%d", mockQueueItemHandleValuePrefix, item.RequestID(), len(mq.items)) + handle := mocks.NewMockQueueItemHandle(handleValue) + item.SetHandle(handle) + + mq.items[handleValue] = item + mq.itemOrder = append(mq.itemOrder, handleValue) // Add to end for FIFO behavior + mq.lenVal++ + mq.byteSizeVal += item.ByteSize() + + // If priority configurable, re-sort (simple sort for mock) + if mq.comparator != nil && mq.hasCapability(types.CapabilityPriorityConfigurable) { + sort.SliceStable(mq.itemOrder, func(i, j int) bool { + itemI := mq.items[mq.itemOrder[i]] + itemJ := mq.items[mq.itemOrder[j]] + return mq.comparator.Func()(itemI, itemJ) // true if itemI is higher priority + }) + } + return uint64(mq.lenVal), mq.byteSizeVal, nil +} + +func (mq *mockSafeQueue) Remove(handle types.QueueItemHandle) (types.QueueItemAccessor, uint64, uint64, error) { + mq.mu.Lock() + defer mq.mu.Unlock() + + if handle == nil || handle.Handle() == nil { + return nil, uint64(mq.lenVal), mq.byteSizeVal, types.ErrInvalidQueueItemHandle + } + handleStr, ok := handle.Handle().(string) + if !ok { + return nil, uint64(mq.lenVal), mq.byteSizeVal, types.ErrInvalidQueueItemHandle + } + + item, exists := mq.items[handleStr] + if !exists || handle.IsInvalidated() { // Check if already invalidated + return nil, uint64(mq.lenVal), mq.byteSizeVal, types.ErrQueueItemNotFound + } + + delete(mq.items, handleStr) + newOrder := make([]string, 0, len(mq.itemOrder)-1) // Remove from itemOrder + for _, h := range mq.itemOrder { + if h != handleStr { + newOrder = append(newOrder, h) + } + } + mq.itemOrder = newOrder + + mq.lenVal-- + mq.byteSizeVal -= item.ByteSize() + handle.Invalidate() // Mark handle as invalid after removal + + return item, uint64(mq.lenVal), mq.byteSizeVal, nil +} + +func (mq *mockSafeQueue) CleanupExpired( + currentTime time.Time, + isItemExpired types.IsItemExpiredFunc, +) ([]types.ExpiredItemInfo, error) { + mq.mu.Lock() + defer mq.mu.Unlock() + + if mq.cleanupExpiredError != nil { + return nil, mq.cleanupExpiredError + } + + var removedInfos []types.ExpiredItemInfo + newOrder := make([]string, 0, len(mq.itemOrder)) + itemsActuallyRemoved := make(map[string]bool) // Track by handle string + + for _, handleStr := range mq.itemOrder { + item, exists := mq.items[handleStr] + if !exists { + // Should not happen if itemOrder and items are consistent + continue + } + + expired, outcome, err := isItemExpired(item, currentTime) + if expired { + removedInfos = append(removedInfos, types.ExpiredItemInfo{ + Item: item, + Outcome: outcome, + Error: err, + }) + itemsActuallyRemoved[handleStr] = true + item.Handle().Invalidate() // Invalidate the handle of the expired item + mq.lenVal-- + mq.byteSizeVal -= item.ByteSize() + delete(mq.items, handleStr) // Remove from main map + } else { + newOrder = append(newOrder, handleStr) // Keep in order + } + } + mq.itemOrder = newOrder + return removedInfos, nil +} + +func (mq *mockSafeQueue) hasCapability(cap types.QueueCapability) bool { + for _, c := range mq.capabilitiesVal { + if c == cap { + return true + } + } + return false +} + +func (mq *mockSafeQueue) setCleanupExpiredError(err error) { + mq.mu.Lock() + defer mq.mu.Unlock() + mq.cleanupExpiredError = err +} + +// Helper to create a FlowRegistry with a specific config for a test. +func newTestFlowRegistry(t *testing.T, cfg config.FlowRegistryConfig) *FlowRegistry { + t.Helper() + fr, err := NewFlowRegistry(cfg, logger) + require.NoError(t, err, "NewFlowRegistry should not fail with valid test config") + require.NotNil(t, fr) + return fr +} + +// Helper to assert basic flow instance properties. +func assertFlowInstance( + t *testing.T, + fr *FlowRegistry, + flowID string, + priority uint, + expectedActive, + expectedRegistered bool, +) *flowInstance { + t.Helper() + fr.mu.RLock() // RLock for inspection + defer fr.mu.RUnlock() + + priorityMap, ok := fr.allFlowInstances[flowID] + require.True(t, ok, "FlowID %s not found in allFlowInstances", flowID) + instance, ok := priorityMap[priority] + require.True(t, ok, "FlowID %s at priority %d not found in its priorityMap", flowID, priority) + require.NotNil(t, instance) + + instance.instanceMu.Lock() + defer instance.instanceMu.Unlock() + assert.Equal(t, expectedActive, instance.isActive, + "Instance isActive mismatch for flow %s, priority %d", flowID, priority) + assert.Equal(t, expectedRegistered, instance.isRegistered, + "Instance isRegistered mismatch for flow %s, priority %d", flowID, priority) + assert.Equal(t, flowID, instance.id) + assert.Equal(t, priority, instance.priority) + require.NotNil(t, instance.queue, "Instance queue should not be nil") + require.NotNil(t, instance.intraFlowDispatchPolicy, "Instance intra-dispatch policy should not be nil") + require.NotNil(t, instance.intraFlowPreemptionPolicy, "Instance intra-preemption policy should not be nil") + + if expectedActive { + activeInstance, activeOK := fr.activeFlowInstances[flowID] + require.True(t, activeOK, "FlowID %s expected to be active but not in activeFlowInstances", flowID) + assert.Same(t, instance, activeInstance, "Active instance in map does not match instance") + } else { + _, activeOK := fr.activeFlowInstances[flowID] + assert.False(t, activeOK, "FlowID %s expected to be inactive but found in activeFlowInstances", flowID) + } + return instance +} + +// assertBandStats checks stats for a specific band. +func assertBandStats(t *testing.T, fr *FlowRegistry, priority uint, expectedLen, expectedSize uint64) { + t.Helper() + stats := fr.GetStats() + bandStats, ok := stats.PerPriorityBandStats[priority] + require.True(t, ok, "Stats for priority band %d not found", priority) + assert.Equal(t, expectedLen, bandStats.Len, "Band %d item count mismatch", priority) + assert.Equal(t, expectedSize, bandStats.ByteSize, "Band %d byte size mismatch", priority) +} + +// assertGlobalStats checks global stats. +func assertGlobalStats(t *testing.T, fr *FlowRegistry, expectedLen, expectedSize uint64) { + t.Helper() + stats := fr.GetStats() + assert.Equal(t, expectedLen, stats.GlobalLen, "Global item count mismatch") + assert.Equal(t, expectedSize, stats.GlobalByteSize, "Global byte size mismatch") +} + +// --- Test Cases --- + +func TestFlowRegistry_NewFlowRegistry(t *testing.T) { + t.Parallel() + t.Run("ValidConfig_DefaultPolicies", func(t *testing.T) { + t.Parallel() + fr := newTestFlowRegistry(t, defaultTestRegistryConfig) + assert.Len(t, fr.priorityBands, 3) + for _, priority := range []uint{testPriorityCritical, testPriorityStandard, testPrioritySheddable} { + band, ok := fr.priorityBands[priority] + require.True(t, ok) + assert.NotNil(t, band.interFlowDispatchPolicy, "Priority %d inter-dispatch policy", priority) + assert.NotNil(t, band.interFlowPreemptionPolicy, "Priority %d inter-preemption policy", priority) + assert.NotNil(t, band.accessor, "Priority %d accessor", priority) + assert.Equal(t, priority, band.priorityLevel) + assert.NotEmpty(t, band.config.PriorityName) + // Default policies are set by config.PriorityBandConfig.validateAndApplyDefaults. + assert.NotEmpty(t, band.config.IntraFlowDispatchPolicy, "Priority %d intra-dispatch policy name", priority) + assert.NotEmpty(t, band.config.IntraFlowPreemptionPolicy, "Priority %d intra-preemption policy name", priority) + assert.NotEmpty(t, band.config.QueueType, "Priority %d queue type", priority) + } + assertGlobalStats(t, fr, 0, 0) + }) + + t.Run("EmptyConfig_NoBands", func(t *testing.T) { + t.Parallel() + fr := newTestFlowRegistry(t, config.FlowRegistryConfig{PriorityBands: []config.PriorityBandConfig{}}) + assert.Empty(t, fr.priorityBands) + assert.Empty(t, fr.AllOrderedPriorityLevels()) + assertGlobalStats(t, fr, 0, 0) + }) + + t.Run("Error_DuplicatePriorityBandLevels", func(t *testing.T) { + t.Parallel() + cfg := config.FlowRegistryConfig{ + PriorityBands: []config.PriorityBandConfig{ + {Priority: testPriorityStandard, PriorityName: "Std1"}, + {Priority: testPriorityStandard, PriorityName: "Std2Dup"}, + }, + } + _, err := NewFlowRegistry(cfg, logger) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate priority band level") + }) + + t.Run("Error_Invalidconfig.PriorityBandConfig_MissingName", func(t *testing.T) { + t.Parallel() + cfg := config.FlowRegistryConfig{PriorityBands: []config.PriorityBandConfig{{ + Priority: testPriorityStandard, + PriorityName: "", + }}} + _, err := NewFlowRegistry(cfg, logger) + require.Error(t, err) + assert.Contains(t, err.Error(), "PriorityName cannot be empty") + }) + + t.Run("Error_PolicyCreationFailure", func(t *testing.T) { + t.Parallel() + bandCfg := config.PriorityBandConfig{ + Priority: testPriorityStandard, + PriorityName: "Std", + InterFlowDispatchPolicy: "NonExistentPolicy", + } + cfg := config.FlowRegistryConfig{PriorityBands: []config.PriorityBandConfig{bandCfg}} + _, err := NewFlowRegistry(cfg, logger) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to create inter-flow dispatch policy 'NonExistentPolicy'") + }) +} + +func TestFlowRegistry_RegisterOrUpdateFlow(t *testing.T) { + t.Parallel() + + t.Run("RegisterNewFlow_Success", func(t *testing.T) { + t.Parallel() + fr := newTestFlowRegistry(t, defaultTestRegistryConfig) + spec := mocks.NewMockFlowSpecification(testFlowID1, testPriorityStandard) + err := fr.RegisterOrUpdateFlow(spec) + require.NoError(t, err) + + assertFlowInstance(t, fr, testFlowID1, testPriorityStandard, true, true) + assertGlobalStats(t, fr, 0, 0) // Queue is empty + assertBandStats(t, fr, testPriorityStandard, 0, 0) + }) + + t.Run("UpdateFlow_SamePriority_UpdatesSpec", func(t *testing.T) { + t.Parallel() + fr := newTestFlowRegistry(t, defaultTestRegistryConfig) + spec1 := mocks.NewMockFlowSpecification(testFlowID1, testPriorityStandard) + _ = fr.RegisterOrUpdateFlow(spec1) + instance1 := assertFlowInstance(t, fr, testFlowID1, testPriorityStandard, true, true) + + spec2 := mocks.NewMockFlowSpecification(testFlowID1, testPriorityStandard) // Same ID, same priority + err := fr.RegisterOrUpdateFlow(spec2) + require.NoError(t, err) + + instance2 := assertFlowInstance(t, fr, testFlowID1, testPriorityStandard, true, true) + assert.Same(t, instance1, instance2, "Instance pointer should be the same") + assert.Equal(t, spec2, instance2.spec, "Instance spec should be updated") + }) + + t.Run("UpdateFlow_DifferentPriority_MigratesAndDrains", func(t *testing.T) { + t.Parallel() + fr := newTestFlowRegistry(t, mockQueueTestRegistryConfig) // Use mock queues + specOld := mocks.NewMockFlowSpecification(testFlowID1, testPriorityStandard) + _ = fr.RegisterOrUpdateFlow(specOld) + oldInstance := assertFlowInstance(t, fr, testFlowID1, testPriorityStandard, true, true) + + // Add an item to the old queue to ensure it drains + item := mocks.NewMockQueueItemAccessor("req1", testFlowID1, 100, time.Now()) + _, _, err := oldInstance.queue.Add(item) + require.NoError(t, err) + assertGlobalStats(t, fr, 1, 100) + assertBandStats(t, fr, testPriorityStandard, 1, 100) + + specNew := mocks.NewMockFlowSpecification(testFlowID1, testPriorityCritical) + err = fr.RegisterOrUpdateFlow(specNew) + require.NoError(t, err) + + // Old instance should be inactive but registered (draining). + assertFlowInstance(t, fr, testFlowID1, testPriorityStandard, false, true) + assert.Equal(t, 1, oldInstance.queue.Len(), "Old queue should retain its item for draining") + + // New instance should be active and registered at the new priority. + newInstance := assertFlowInstance(t, fr, testFlowID1, testPriorityCritical, true, true) + assert.Equal(t, 0, newInstance.queue.Len(), "New queue should be empty") + assert.NotSame(t, oldInstance.queue, newInstance.queue, "Queues should be different instances") + + // Stats should reflect the item still in the old queue, and new queue being empty. + assertGlobalStats(t, fr, 1, 100) + assertBandStats(t, fr, testPriorityStandard, 1, 100) + assertBandStats(t, fr, testPriorityCritical, 0, 0) + + // Simulate old queue becoming empty. + _, _, _, err = oldInstance.queue.Remove(item.Handle()) + require.NoError(t, err) + // managedQueueWrapper should signal FlowRegistry, leading to cleanup. + // Check if old instance is cleaned up. + fr.mu.RLock() + _, stillExistsInAll := fr.allFlowInstances[testFlowID1][testPriorityStandard] + fr.mu.RUnlock() + assert.False(t, stillExistsInAll, "Old instance should be cleaned up after its queue empties") + assertGlobalStats(t, fr, 0, 0) // All items gone + }) + + t.Run("UpdateFlow_ReactivateExistingInstance", func(t *testing.T) { + t.Parallel() + fr := newTestFlowRegistry(t, mockQueueTestRegistryConfig) + specStd := mocks.NewMockFlowSpecification(testFlowID1, testPriorityStandard) + _ = fr.RegisterOrUpdateFlow(specStd) + instanceStd := assertFlowInstance(t, fr, testFlowID1, testPriorityStandard, true, true) + item := mocks.NewMockQueueItemAccessor("req1", testFlowID1, 50, time.Now()) + _, _, _ = instanceStd.queue.Add(item) // Add item to standard queue + + specCrit := mocks.NewMockFlowSpecification(testFlowID1, testPriorityCritical) + _ = fr.RegisterOrUpdateFlow(specCrit) // Migrate to critical + + specStdAgain := mocks.NewMockFlowSpecification(testFlowID1, testPriorityStandard) + err := fr.RegisterOrUpdateFlow(specStdAgain) // Migrate back to standard + require.NoError(t, err) + + currentActive := assertFlowInstance(t, fr, testFlowID1, testPriorityStandard, true, true) + assert.Same(t, instanceStd, currentActive, "Should reactivate the original Standard instance") + assert.Equal(t, 1, currentActive.queue.Len(), "Reactivated queue should retain its item") + assert.Equal(t, specStdAgain, currentActive.spec, "Spec should be updated on reactivated instance") + }) + + t.Run("Error_EmptyFlowID", func(t *testing.T) { + t.Parallel() + fr := newTestFlowRegistry(t, defaultTestRegistryConfig) + err := fr.RegisterOrUpdateFlow(mocks.NewMockFlowSpecification("", testPriorityStandard)) + assert.ErrorIs(t, err, types.ErrFlowIDEmpty) + }) + + t.Run("Error_InvalidPriority", func(t *testing.T) { + t.Parallel() + fr := newTestFlowRegistry(t, defaultTestRegistryConfig) + err := fr.RegisterOrUpdateFlow(mocks.NewMockFlowSpecification(testFlowID1, 999)) + assert.ErrorIs(t, err, types.ErrInvalidFlowPriority) + }) + + t.Run("Error_QueueCreationFailure", func(t *testing.T) { + t.Parallel() + cfg := config.FlowRegistryConfig{PriorityBands: []config.PriorityBandConfig{{ + Priority: testPriorityStandard, + PriorityName: "Std-Fail", + QueueType: failingQueueTypeForCreationFailureTest, + }}} + fr := newTestFlowRegistry(t, cfg) + err := fr.RegisterOrUpdateFlow(mocks.NewMockFlowSpecification(testFlowID1, testPriorityStandard)) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to create queue") + assert.Contains(t, err.Error(), failingQueueTypeForCreationFailureTest) + }) + + t.Run("Error_PolicyCreationFailure", func(t *testing.T) { + t.Parallel() + cfg := config.FlowRegistryConfig{PriorityBands: []config.PriorityBandConfig{{ + Priority: testPriorityStandard, + PriorityName: "Std-PolicyFail", + IntraFlowDispatchPolicy: "NonExistentPolicy", + }}} + fr := newTestFlowRegistry(t, cfg) + err := fr.RegisterOrUpdateFlow(mocks.NewMockFlowSpecification(testFlowID1, testPriorityStandard)) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to create intra-flow dispatch policy") + }) + + t.Run("Error_QueueCapabilityMismatch", func(t *testing.T) { + t.Parallel() + // Default FCFS dispatch policy requires CapabilityFIFO. + // mockQueueNameForCapabilityMismatchTest provides no capabilities. + cfg := config.FlowRegistryConfig{PriorityBands: []config.PriorityBandConfig{{ + Priority: testPriorityStandard, + PriorityName: "Std-CapMismatch", + QueueType: mockQueueNameForCapabilityMismatchTest, + }}} + fr := newTestFlowRegistry(t, cfg) + err := fr.RegisterOrUpdateFlow(mocks.NewMockFlowSpecification(testFlowID1, testPriorityStandard)) + require.Error(t, err) + assert.Contains(t, err.Error(), "queue 'MockQueueForCapabilityMismatchTest' is missing capabilities") + assert.Contains(t, err.Error(), string(types.CapabilityFIFO)) // Check that FIFO is mentioned as missing + }) +} + +func TestFlowRegistry_UnregisterFlow(t *testing.T) { + t.Parallel() + + t.Run("UnregisterActiveFlow_NotEmpty_BecomesInactiveUnregistered", func(t *testing.T) { + t.Parallel() + fr := newTestFlowRegistry(t, mockQueueTestRegistryConfig) + spec := mocks.NewMockFlowSpecification(testFlowID1, testPriorityStandard) + _ = fr.RegisterOrUpdateFlow(spec) + instance := assertFlowInstance(t, fr, testFlowID1, testPriorityStandard, true, true) + item := mocks.NewMockQueueItemAccessor("req1", testFlowID1, 10, time.Now()) + _, _, _ = instance.queue.Add(item) // Make queue non-empty + + err := fr.UnregisterFlow(testFlowID1) + require.NoError(t, err) + + assertFlowInstance(t, fr, testFlowID1, testPriorityStandard, false, false) // Now inactive and unregistered + assert.Equal(t, 1, instance.queue.Len(), "Queue should retain item for draining") + assertGlobalStats(t, fr, 1, 10) // Item still contributes to stats + }) + + t.Run("UnregisterActiveFlow_Empty_CleansUpImmediately", func(t *testing.T) { + t.Parallel() + fr := newTestFlowRegistry(t, defaultTestRegistryConfig) // Uses ListQueue which is empty + spec := mocks.NewMockFlowSpecification(testFlowID1, testPriorityStandard) + _ = fr.RegisterOrUpdateFlow(spec) + assertFlowInstance(t, fr, testFlowID1, testPriorityStandard, true, true) + + err := fr.UnregisterFlow(testFlowID1) + require.NoError(t, err) + + fr.mu.RLock() + _, ok := fr.allFlowInstances[testFlowID1] + fr.mu.RUnlock() + assert.False(t, ok, "Flow should be completely removed from allFlowInstances") + assertGlobalStats(t, fr, 0, 0) + }) + + t.Run("UnregisterInactiveDrainingFlow_NotEmpty_MarksUnregistered", func(t *testing.T) { + t.Parallel() + fr := newTestFlowRegistry(t, mockQueueTestRegistryConfig) + specStd := mocks.NewMockFlowSpecification(testFlowID1, testPriorityStandard) + _ = fr.RegisterOrUpdateFlow(specStd) + instanceStd := assertFlowInstance(t, fr, testFlowID1, testPriorityStandard, true, true) + item := mocks.NewMockQueueItemAccessor("req1", testFlowID1, 10, time.Now()) + _, _, _ = instanceStd.queue.Add(item) + + _ = fr.RegisterOrUpdateFlow(mocks.NewMockFlowSpecification(testFlowID1, testPriorityCritical)) // Migrate + + assertFlowInstance(t, fr, testFlowID1, testPriorityStandard, false, true) // Now inactive, registered. + + err := fr.UnregisterFlow(testFlowID1) + require.NoError(t, err) + + assertFlowInstance(t, fr, testFlowID1, testPriorityStandard, false, false) // Still inactive, now unregistered + assert.Equal(t, 1, instanceStd.queue.Len(), "Queue should retain item") + }) + + t.Run("Error_NonExistentFlow", func(t *testing.T) { + t.Parallel() + fr := newTestFlowRegistry(t, defaultTestRegistryConfig) + err := fr.UnregisterFlow("non-existent") + assert.ErrorIs(t, err, types.ErrFlowNotRegistered) + }) + + t.Run("Error_EmptyFlowID", func(t *testing.T) { + t.Parallel() + fr := newTestFlowRegistry(t, defaultTestRegistryConfig) + err := fr.UnregisterFlow("") + assert.ErrorIs(t, err, types.ErrFlowIDEmpty) + }) +} + +func TestFlowRegistry_Accessors(t *testing.T) { + t.Parallel() + fr := newTestFlowRegistry(t, defaultTestRegistryConfig) + specStd := mocks.NewMockFlowSpecification(testFlowID1, testPriorityStandard) + _ = fr.RegisterOrUpdateFlow(specStd) + instanceStd := fr.activeFlowInstances[testFlowID1] + + specCrit := mocks.NewMockFlowSpecification(testFlowID2, testPriorityCritical) + _ = fr.RegisterOrUpdateFlow(specCrit) + + t.Run("ActiveManagedQueue", func(t *testing.T) { + t.Parallel() + mq, err := fr.ActiveManagedQueue(testFlowID1) + require.NoError(t, err) + assert.Same(t, instanceStd.queue, mq) + _, err = fr.ActiveManagedQueue("non-existent") + assert.ErrorIs(t, err, types.ErrFlowNotRegistered) + }) + + t.Run("ManagedQueue", func(t *testing.T) { + t.Parallel() + mq, err := fr.ManagedQueue(testFlowID1, testPriorityStandard) + require.NoError(t, err) + assert.Same(t, instanceStd.queue, mq) + _, err = fr.ManagedQueue(testFlowID1, testPriorityCritical) // testFlowID1 not at critical + assert.ErrorIs(t, err, types.ErrFlowInstanceNotFound) + }) + + // Test policy accessors. + for _, policyType := range []string{"IntraDispatch", "IntraPreemption", "InterDispatch", "InterPreemption"} { + policyType := policyType // Capture range variable + t.Run(policyType+"PolicyAccess", func(t *testing.T) { + t.Parallel() + var err error + var policy any + switch policyType { + case "IntraDispatch": + policy, err = fr.IntraFlowDispatchPolicy(testFlowID1, testPriorityStandard) + case "IntraPreemption": + policy, err = fr.IntraFlowPreemptionPolicy(testFlowID1, testPriorityStandard) + case "InterDispatch": + policy, err = fr.InterFlowDispatchPolicy(testPriorityStandard) + case "InterPreemption": + policy, err = fr.InterFlowPreemptionPolicy(testPriorityStandard) + } + require.NoError(t, err) + assert.NotNil(t, policy) + + // Test error cases. + switch policyType { + case "IntraDispatch": + _, err = fr.IntraFlowDispatchPolicy("non-existent", testPriorityStandard) + case "IntraPreemption": + _, err = fr.IntraFlowPreemptionPolicy("non-existent", testPriorityStandard) + case "InterDispatch": + _, err = fr.InterFlowDispatchPolicy(999) + case "InterPreemption": + _, err = fr.InterFlowPreemptionPolicy(999) + } + if policyType == "InterDispatch" || policyType == "InterPreemption" { + assert.ErrorIs(t, err, types.ErrPriorityBandNotFound) + } else { + assert.ErrorIs(t, err, types.ErrFlowInstanceNotFound) + } + }) + } + + t.Run("PriorityBandAccessor", func(t *testing.T) { + t.Parallel() + acc, err := fr.PriorityBandAccessor(testPriorityStandard) + require.NoError(t, err) + assert.NotNil(t, acc) + assert.Equal(t, testPriorityStandard, acc.Priority()) + _, err = fr.PriorityBandAccessor(999) + assert.ErrorIs(t, err, types.ErrPriorityBandNotFound) + }) + + t.Run("AllOrderedPrioritys", func(t *testing.T) { + t.Parallel() + levels := fr.AllOrderedPriorityLevels() + expected := []uint{testPriorityCritical, testPriorityStandard, testPrioritySheddable} + assert.Equal(t, expected, levels) + }) + + t.Run("GetStats", func(t *testing.T) { + t.Parallel() + stats := fr.GetStats() + assertGlobalStats(t, fr, 0, 0) // Initially empty + assert.Len(t, stats.PerPriorityBandStats, 3) + }) +} + +func TestFlowRegistry_Panics(t *testing.T) { + t.Parallel() + fr := newTestFlowRegistry(t, defaultTestRegistryConfig) + spec := mocks.NewMockFlowSpecification(testFlowID1, testPriorityStandard) + _ = fr.RegisterOrUpdateFlow(spec) + + t.Run("ActiveManagedQueue_InvariantViolation", func(t *testing.T) { + t.Parallel() + // Corrupt state: make active instance inactive internally + fr.mu.Lock() + instance := fr.activeFlowInstances[testFlowID1] + instance.instanceMu.Lock() + instance.isActive = false // Inconsistency + instance.instanceMu.Unlock() + fr.mu.Unlock() + + assert.Panics(t, func() { _, _ = fr.ActiveManagedQueue(testFlowID1) }) + }) + + t.Run("ManagedQueue_NilQueueInvariant", func(t *testing.T) { + t.Parallel() + fr.mu.Lock() + instance := fr.allFlowInstances[testFlowID1][testPriorityStandard] + instance.queue = nil // Corrupt + fr.mu.Unlock() + + assert.Panics(t, func() { _, _ = fr.ManagedQueue(testFlowID1, testPriorityStandard) }) + }) +} + +func TestManagedQueueWrapper_Operations(t *testing.T) { + t.Parallel() + spec := mocks.NewMockFlowSpecification(testFlowID1, testPriorityStandard) + item1 := mocks.NewMockQueueItemAccessor("req1", testFlowID1, 100, time.Now()) + item2 := mocks.NewMockQueueItemAccessor("req2", testFlowID1, 50, time.Now()) + + t.Run("Add_Success", func(t *testing.T) { + t.Parallel() + // Need a fresh registry and queue for parallel stat testing. + fr := newTestFlowRegistry(t, mockQueueTestRegistryConfig) + _ = fr.RegisterOrUpdateFlow(spec) + mq, _ := fr.ActiveManagedQueue(testFlowID1) + + newLen, newSize, err := mq.Add(item1) + require.NoError(t, err) + assert.Equal(t, uint64(1), newLen) + assert.Equal(t, uint64(100), newSize) + assertGlobalStats(t, fr, 1, 100) + assertBandStats(t, fr, testPriorityStandard, 1, 100) + assert.NotNil(t, item1.Handle(), "Item handle should be set by mockSafeQueue.Add via ManagedQueue") + }) + + t.Run("Add_ToNonExistentInstance_Fails", func(t *testing.T) { + t.Parallel() + fr := newTestFlowRegistry(t, mockQueueTestRegistryConfig) + _ = fr.RegisterOrUpdateFlow(spec) + mq, _ := fr.ActiveManagedQueue(testFlowID1) + _ = fr.UnregisterFlow(testFlowID1) // Make instance "non-existent" for new operations + + _, _, err := mq.Add(mocks.NewMockQueueItemAccessor("req-fail", testFlowID1, 10, time.Now())) + assert.ErrorIs(t, err, types.ErrFlowInstanceNotFound) + }) + + t.Run("Remove_Success", func(t *testing.T) { + t.Parallel() + fr := newTestFlowRegistry(t, mockQueueTestRegistryConfig) + _ = fr.RegisterOrUpdateFlow(spec) + mqLocal, _ := fr.ActiveManagedQueue(testFlowID1) + + _, _, _ = mqLocal.Add(item1) + _, _, _ = mqLocal.Add(item2) // Global: 2, 150. Band: 2, 150 + + removedItem, newLen, newSize, err := mqLocal.Remove(item1.Handle()) + require.NoError(t, err) + assert.Same(t, item1, removedItem) + assert.Equal(t, uint64(1), newLen) + assert.Equal(t, uint64(50), newSize) // item2 (50) remains + assertGlobalStats(t, fr, 1, 50) + assertBandStats(t, fr, testPriorityStandard, 1, 50) + assert.True(t, item1.Handle().IsInvalidated(), "Removed item's handle should be invalidated") + + // Test remove last item, triggers signalQueueEmptied. + _, _, _, err = mqLocal.Remove(item2.Handle()) + require.NoError(t, err) + assertGlobalStats(t, fr, 0, 0) + // Instance should be cleaned up if it was inactive/unregistered (not the case here). + // For an active, registered instance, it remains. + _, instanceStillExists := fr.allFlowInstances[testFlowID1] + assert.True(t, instanceStillExists, "Active, registered instance should not be cleaned up by signalQueueEmptied") + }) + + t.Run("Remove_InvalidHandle_Fails", func(t *testing.T) { + t.Parallel() + fr := newTestFlowRegistry(t, mockQueueTestRegistryConfig) + _ = fr.RegisterOrUpdateFlow(spec) + mqLocal, _ := fr.ActiveManagedQueue(testFlowID1) + _, _, _ = mqLocal.Add(item1) + + invalidHandle := mocks.NewMockQueueItemHandle("invalid-raw-handle") + _, _, _, err := mqLocal.Remove(invalidHandle) + assert.ErrorIs(t, err, types.ErrQueueItemNotFound) // Mock returns NotFound for unrecognised handles + }) + + t.Run("Remove_FromNonExistentInstance_Fails", func(t *testing.T) { + t.Parallel() + fr := newTestFlowRegistry(t, mockQueueTestRegistryConfig) + _ = fr.RegisterOrUpdateFlow(spec) + mq, _ := fr.ActiveManagedQueue(testFlowID1) + // Add an item so we have a valid handle, then unregister. + tempItem := mocks.NewMockQueueItemAccessor("temp", testFlowID1, 10, time.Now()) + _, _, _ = mq.Add(tempItem) + _ = fr.UnregisterFlow(testFlowID1) // Make instance "non-existent" for new operations + + _, _, _, err := mq.Remove(tempItem.Handle()) + assert.ErrorIs(t, err, types.ErrFlowInstanceNotFound) + }) + + t.Run("CleanupExpired_Success", func(t *testing.T) { + t.Parallel() + fr := newTestFlowRegistry(t, mockQueueTestRegistryConfig) + _ = fr.RegisterOrUpdateFlow(spec) // testFlowID1, testPriorityStandard + mq, _ := fr.ActiveManagedQueue(testFlowID1) + + itemToExpire := mocks.NewMockQueueItemAccessor("reqExpire", testFlowID1, 70, time.Now().Add(-time.Hour)) // TTL + itemToKeep := mocks.NewMockQueueItemAccessor("reqKeep", testFlowID1, 30, time.Now()) + + _, _, _ = mq.Add(itemToExpire) + _, _, _ = mq.Add(itemToKeep) + assertGlobalStats(t, fr, 2, 100) + assertBandStats(t, fr, testPriorityStandard, 2, 100) + + // Define an isItemExpiredFunc for the test. + testIsItemExpired := func(item types.QueueItemAccessor, currentTime time.Time) (bool, types.QueueOutcome, error) { + if item.RequestID() == "reqExpire" { + return true, types.QueueOutcomeEvictedTTL, types.ErrTTLExpired + } + return false, types.QueueOutcomeDispatched, nil + } + + removedInfos, err := mq.CleanupExpired(time.Now(), testIsItemExpired) + require.NoError(t, err) + + require.Len(t, removedInfos, 1, "Expected one item to be removed by CleanupExpired") + assert.Same(t, itemToExpire, removedInfos[0].Item) + assert.Equal(t, types.QueueOutcomeEvictedTTL, removedInfos[0].Outcome) + assert.ErrorIs(t, removedInfos[0].Error, types.ErrTTLExpired) + + assert.True(t, itemToExpire.Handle().IsInvalidated(), "Expired item's handle should be invalidated") + assert.False(t, itemToKeep.Handle().IsInvalidated(), "Kept item's handle should not be invalidated") + + assert.Equal(t, 1, mq.Len(), "ManagedQueue length after CleanupExpired") + assert.Equal(t, uint64(30), mq.ByteSize(), "ManagedQueue byte size after CleanupExpired") + + assertGlobalStats(t, fr, 1, 30) + assertBandStats(t, fr, testPriorityStandard, 1, 30) + + // Test cleanup that empties the queue (if flow was unregistered). + _ = fr.UnregisterFlow(testFlowID1) // Mark for cleanup + _, _, _, _ = mq.Remove(itemToKeep.Handle()) // This will make the queue empty + + fr.mu.RLock() + _, stillExists := fr.allFlowInstances[testFlowID1] + fr.mu.RUnlock() + assert.False(t, stillExists, "Flow instance should be cleaned up after queue empties and flow is unregistered") + }) + + t.Run("CleanupExpired_OnNonExistentInstance_Fails", func(t *testing.T) { + t.Parallel() + fr := newTestFlowRegistry(t, mockQueueTestRegistryConfig) + _ = fr.RegisterOrUpdateFlow(spec) + mq, _ := fr.ActiveManagedQueue(testFlowID1) + _ = fr.UnregisterFlow(testFlowID1) // Make instance "non-existent" for new operations + + _, err := mq.CleanupExpired(time.Now(), func(item types.QueueItemAccessor, currentTime time.Time) (bool, types.QueueOutcome, error) { + return false, types.QueueOutcomeDispatched, nil + }) + assert.ErrorIs(t, err, types.ErrFlowInstanceNotFound) + }) + + t.Run("CleanupExpired_SafeQueueReturnsError", func(t *testing.T) { + t.Parallel() + fr := newTestFlowRegistry(t, mockQueueTestRegistryConfig) + _ = fr.RegisterOrUpdateFlow(spec) + mq, _ := fr.ActiveManagedQueue(testFlowID1) + + // Add an item so the queue is not empty. + _, _, _ = mq.Add(mocks.NewMockQueueItemAccessor("item-err", testFlowID1, 10, time.Now())) + initialGlobalLen, initialGlobalSize := fr.globalLen.Load(), fr.globalByteSize.Load() + initialBandLen, initialBandSize := fr.priorityBands[testPriorityStandard].bandLen.Load(), fr.priorityBands[testPriorityStandard].bandByteSize.Load() + + // Configure the underlying mockSafeQueue to return an error. + underlyingSafeQ, ok := mq.(*managedQueueWrapper).safeQ.(*mockSafeQueue) + require.True(t, ok, "Failed to cast to *mockSafeQueue") + testError := fmt.Errorf("simulated SafeQueue.CleanupExpired error") + underlyingSafeQ.setCleanupExpiredError(testError) + + _, err := mq.CleanupExpired(time.Now(), func(item types.QueueItemAccessor, currentTime time.Time) (bool, types.QueueOutcome, error) { + return false, types.QueueOutcomeDispatched, nil // Callback will not be hit if mock errors early + }) + require.Error(t, err) + assert.ErrorIs(t, err, testError, "Error from ManagedQueue should wrap the SafeQueue's error") + + // Verify stats remain unchanged as the operation failed before any items were processed by the mock. + assertGlobalStats(t, fr, initialGlobalLen, initialGlobalSize) + assertBandStats(t, fr, testPriorityStandard, initialBandLen, initialBandSize) + }) +} + +func TestManagedQueueWrapper_Accessors(t *testing.T) { + t.Parallel() + fr := newTestFlowRegistry(t, mockQueueTestRegistryConfig) + spec := mocks.NewMockFlowSpecification(testFlowID1, testPriorityStandard) + _ = fr.RegisterOrUpdateFlow(spec) + mq, _ := fr.ActiveManagedQueue(testFlowID1) + require.NotNil(t, mq) + + assert.Equal(t, spec, mq.FlowSpec(), "ManagedQueue.FlowSpec() mismatch") + + fqa := mq.FlowQueueAccessor() + require.NotNil(t, fqa) + assert.Equal(t, spec, fqa.FlowSpec(), "FlowQueueAccessor.FlowSpec() mismatch") + assert.NotNil(t, fqa.Comparator(), "FlowQueueAccessor.Comparator() should not be nil") + + // Test embedded SafeQueue inspection methods + assert.Contains(t, mq.Name(), mockQueueNameForRegistryTests) + assert.NotEmpty(t, mq.Capabilities()) + _, err := mq.PeekHead() // Mock returns ErrQueueEmpty or ErrOpNotSupported + assert.Error(t, err) // Exact error depends on mock's PeekHead +} + +func TestFlowQueueAccessorImpl_Methods(t *testing.T) { + t.Parallel() + fr := newTestFlowRegistry(t, mockQueueTestRegistryConfig) + spec := mocks.NewMockFlowSpecification(testFlowID1, testPriorityStandard) + _ = fr.RegisterOrUpdateFlow(spec) + mq, _ := fr.ActiveManagedQueue(testFlowID1) + fqa := mq.FlowQueueAccessor() + require.NotNil(t, fqa) + + assert.Equal(t, spec, fqa.FlowSpec()) + assert.NotNil(t, fqa.Comparator()) + assert.Equal(t, 0, fqa.Len()) // Initially empty + assert.Equal(t, uint64(0), fqa.ByteSize()) + assert.Contains(t, fqa.Name(), mockQueueNameForRegistryTests) + assert.NotEmpty(t, fqa.Capabilities()) +} + +func TestInternalBandStateAccessor_Methods(t *testing.T) { + t.Parallel() + cfgWithCapacity := config.FlowRegistryConfig{ + PriorityBands: []config.PriorityBandConfig{ + { + Priority: testPriorityStandard, + PriorityName: "StdBandWithCap", + MaxBytes: 1024, + QueueType: mockQueueNameForRegistryTests, + }, + { + Priority: testPriorityCritical, + PriorityName: "CritBand", + QueueType: mockQueueNameForRegistryTests, + }, + }, + } + fr := newTestFlowRegistry(t, cfgWithCapacity) + _ = fr.RegisterOrUpdateFlow(mocks.NewMockFlowSpecification(testFlowID1, testPriorityStandard)) + _ = fr.RegisterOrUpdateFlow(mocks.NewMockFlowSpecification(testFlowID2, testPriorityStandard)) + _ = fr.RegisterOrUpdateFlow(mocks.NewMockFlowSpecification(testFlowID3, testPriorityCritical)) // Different band + + accessor, err := fr.PriorityBandAccessor(testPriorityStandard) + require.NoError(t, err) + require.NotNil(t, accessor) + + assert.Equal(t, testPriorityStandard, accessor.Priority()) + assert.Equal(t, "StdBandWithCap", accessor.PriorityName()) + assert.Equal(t, uint64(1024), accessor.CapacityBytes()) + + flowIDs := accessor.FlowIDs() + sort.Strings(flowIDs) + assert.Equal(t, []string{testFlowID1, testFlowID2}, flowIDs) + + q1Accessor := accessor.Queue(testFlowID1) + require.NotNil(t, q1Accessor) + assert.Equal(t, testFlowID1, q1Accessor.FlowSpec().ID()) + assert.Nil(t, accessor.Queue(testFlowID3), "FlowID3 should not be in standard band accessor") + + iteratedCount := 0 + accessor.IterateQueues(func(q types.FlowQueueAccessor) bool { + iteratedCount++ + assert.Contains(t, []string{testFlowID1, testFlowID2}, q.FlowSpec().ID()) + return true // Continue iterating + }) + assert.Equal(t, 2, iteratedCount, "IterateQueues should visit 2 queues in standard band") + + // Test iteration stop. + iteratedCount = 0 + accessor.IterateQueues(func(q types.FlowQueueAccessor) bool { + iteratedCount++ + return false // Stop after first + }) + assert.Equal(t, 1, iteratedCount, "IterateQueues should stop after first if callback returns false") + + // Panic on non-existent band (whitebox test for internal accessor). + nonExistentAccessor := &internalBandStateAccessor{registry: fr, bandPriority: 999} + assert.Panics(t, func() { _ = nonExistentAccessor.PriorityName() }) +} diff --git a/pkg/epp/flowcontroller/plugins/dispatch/interflow/README.md b/pkg/epp/flowcontroller/plugins/dispatch/interflow/README.md new file mode 100644 index 000000000..3fab42fd1 --- /dev/null +++ b/pkg/epp/flowcontroller/plugins/dispatch/interflow/README.md @@ -0,0 +1,57 @@ +# FlowController Inter-Flow Dispatch Policy Plugins (`plugins/dispatch/interflow/`) + +This directory contains concrete implementations of the `types.InterFlowDispatchPolicy` interface. These policies are responsible for selecting *which flow's queue* to service next from all eligible flows within a single flow priority band. + +## Overview + +The FlowController manages requests organized into **flow priority bands** based on each flow's `FlowSpecification.Priority()`. Within each priority band, multiple "flows" (representing different models, tenants, or workloads) can exist, each with its own queue of requests. The `InterFlowDispatchPolicy` determines the fairness or priority mechanism for choosing among these competing flows within that specific priority band. + +Key responsibilities and characteristics of an `InterFlowDispatchPolicy`: + +1. **Flow Queue Selection (`SelectQueue`)**: The primary method, `SelectQueue(band types.PriorityBandAccessor) (types.FlowQueueAccessor, error)`, inspects all the flow queues within the given priority band (via a read-only accessor) and returns the `FlowQueueAccessor` of the chosen flow. + - If no flow is selected (e.g., all queues are empty, or the policy decides to pause), it returns `(nil, nil)`. + - If an irrecoverable issue occurs that prevents selection (e.g., a `PriorityScoreType` mismatch when comparing queues), it returns `(nil, relevantError)`. + - Implementations should also aim to be resilient to transient issues with individual queues (e.g., a temporary failure to `PeekHead`), attempting to select from other available queues before returning an error. + +2. **Fairness Criteria**: Policies can implement various fairness criteria: + - **Score-based**: Like `BestHeadPriorityScore`, which looks at the `PriorityScore()` of the head item of each queue (as defined by each queue's `IntraFlowDispatchPolicy`) and picks the flow with the "best" score (e.g., numerically lowest, per convention). This type of policy must be careful about comparing scores from queues with different `PriorityScoreTypes`. + - **Structural/Round-Robin**: Policies like Round Robin cycle through available flow queues without necessarily inspecting item scores. + - **Consumption-based**: More advanced policies could track historical dispatch counts, token counts, or other resource consumption metrics per flow to make fairness decisions (e.g., [VTC-like algorithms](https://arxiv.org/abs/2401.00588) (Sheng et al.)). + +3. **Stateless or Stateful**: Policies can be stateless (making decisions based only on the current snapshot of the band) or stateful (e.g., remembering the last flow selected for Round Robin, or tracking consumption metrics). + +The `InterFlowDispatchPolicy` is crucial for ensuring that different workloads sharing an inference pool receive equitable opportunities for their requests to be processed, according to the configured fairness objectives for that priority level. It works in conjunction with `IntraFlowDispatchPolicy` (which defines order within a flow's queue) to determine the overall dispatch order. + +## Contributing a New Inter-Flow Dispatch Policy + +To contribute a new inter-flow dispatch policy: + +1. **Define Your Policy Implementation**: + - Create a new Go file in this directory (e.g., `myfairnesspolicy.go`). + - Define a struct for your policy. If it's stateful, include fields to hold that state. + - Implement all methods of the `types.InterFlowDispatchPolicy` interface on your struct: + - `SelectQueue(band types.PriorityBandAccessor) (types.FlowQueueAccessor, error)` + - `Name() string` (return a unique name for your policy type) + +2. **Register Your Policy**: + - To make your policy discoverable by the system and automatically included in conformance tests, register it with the central factory. This is typically done in an `init()` function within your policy's Go file (e.g., `myfairnesspolicy.go`). + - Call `interflowdispatch.RegisterPolicy()` from [`plugins/dispatch/interflow/factory.go`](factory.go), passing your policy's unique name and a constructor function. + - If your policy is intended to be a generally available type (e.g., one of the default options for the system), define its `RegisteredInterFlowDispatchPolicyName` constant within your policy's Go file. This makes it easily referenceable from configurations or other parts of the system. + - Conformance tests in [`plugins/dispatch/interflow/conformance_test.go`](conformance_test.go) automatically iterate over all policies registered with the factory, so your policy will be included in these checks once registered. + +3. **Testing**: + - **Conformance Tests**: The tests in [`plugins/dispatch/interflow/conformance_test.go`](conformance_test.go) verify that any `InterFlowDispatchPolicy` implementation adheres to the basic contractual obligations of the interface (e.g., `Name()` is not empty, `SelectQueue` handles nil/empty bands gracefully). Registering your policy with the factory (as described in step 2) will automatically include it in these checks. + - **Implementation-Specific Tests**: Create a new test file (e.g., `myfairnesspolicy_test.go`) in this directory. Add unit tests that cover the unique logic of your `SelectQueue` method, including how it handles different queue states, score comparisons (if applicable), error conditions (like `PriorityScoreType` mismatches), and state updates (for stateful policies). + +4. **Documentation**: + - Add GoDoc comments to your new policy struct and its methods, explaining its selection logic, fairness criteria, any state it maintains, and its intended use cases. + +## Example Implementation + +Refer to: + +- [`bestheadpriorityscore.go`](bestheadpriorityscore.go): For an example of a policy that selects based on the priority scores of head items. +- [`bestheadpriorityscore_test.go`](bestheadpriorityscore_test.go): For examples of implementation-specific tests. +- [`conformance_test.go`](conformance_test.go): To understand the baseline behaviors tested for all inter-flow dispatch policies. + +By following these steps, you can introduce new strategies for arbitrating access between different flows, enhancing the fairness and flexibility of the FlowController. diff --git a/pkg/epp/flowcontroller/plugins/dispatch/interflow/bestheadpriorityscore.go b/pkg/epp/flowcontroller/plugins/dispatch/interflow/bestheadpriorityscore.go new file mode 100644 index 000000000..7cb393796 --- /dev/null +++ b/pkg/epp/flowcontroller/plugins/dispatch/interflow/bestheadpriorityscore.go @@ -0,0 +1,106 @@ +/* +Copyright 2025 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 interflowdispatch + +// import ( +// "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/types" +// ) + +const BestHeadPriorityScoreDispatchPolicyName RegisteredInterFlowDispatchPolicyName = "BestHeadPriorityScore" + +// func init() { +// RegisterPolicy(BestHeadPriorityScoreDispatchPolicyName, func() (types.InterFlowDispatchPolicy, error) { +// return NewBestHeadPriorityScore(), nil +// }) +// } + +// // BestHeadPriorityScore implements the types.InterFlowDispatchPolicy interface. +// // It selects the flow queue whose head item has the "best" (numerically lowest score as established by convention; +// // e.g., earliest enqueue time) PriorityScore. +// // It requires all compared queues to have the same PriorityScoreType. +// type BestHeadPriorityScore struct{} + +// var _ types.InterFlowDispatchPolicy = &BestHeadPriorityScore{} // Compile-time validation + +// // NewBestHeadPriorityScore creates a new BestHeadPriorityScore InterFlowDispatchPolicy policy. +// func NewBestHeadPriorityScore() *BestHeadPriorityScore { +// return &BestHeadPriorityScore{} +// } + +// // SelectQueue inspects the queues within the band and returns the QueueAccessor of the flow queue whose head item has +// // the best (numerically lowest) PriorityScore. +// // It returns (nil, ErrIncompatiblePriorityType) if a mismatch in PriorityScoreType is detected among the queues being +// // compared. +// // It returns (nil, nil) if no suitable queue is found (e.g., all queues are empty). +// func (p *BestHeadPriorityScore) SelectQueue(band types.PriorityBandAccessor) (types.FlowQueueAccessor, error) { +// if band == nil { +// return nil, nil +// } + +// var bestQueue types.FlowQueueAccessor +// var bestScore float64 +// var firstScoreType string +// initialized := false + +// var iterationErr error +// band.IterateQueues(func(q types.FlowQueueAccessor) bool { +// if q == nil || q.Len() == 0 { +// return true // Skip nil or empty queues +// } + +// headItem, err := q.PeekHead() +// if err != nil || headItem == nil { +// // This is a transient issue with one queue; continue to check other queues. +// return true // Skip if can't peek head +// } + +// currentScoreType := q.PriorityScoreType() +// currentScore := headItem.PriorityScore() + +// if !initialized { +// // First valid queue encountered, set it as the current best +// bestQueue = q +// bestScore = currentScore +// firstScoreType = currentScoreType +// initialized = true +// return true +// } + +// // Check for PriorityScoreType compatibility +// if currentScoreType != firstScoreType { +// bestQueue = nil // Invalidate current selection +// iterationErr = types.ErrIncompatiblePriorityType +// return false // Stop iteration +// } + +// if currentScore < bestScore { +// bestScore = currentScore +// bestQueue = q +// } +// return true +// }) + +// if iterationErr != nil { +// return nil, iterationErr +// } +// return bestQueue, nil +// } + +// // Name returns the unique string identifier for this policy implementation. +// func (p *BestHeadPriorityScore) Name() string { +// return string(BestHeadPriorityScoreDispatchPolicyName) +// } diff --git a/pkg/epp/flowcontroller/plugins/dispatch/interflow/bestheadpriorityscore_test.go b/pkg/epp/flowcontroller/plugins/dispatch/interflow/bestheadpriorityscore_test.go new file mode 100644 index 000000000..a5ab69a14 --- /dev/null +++ b/pkg/epp/flowcontroller/plugins/dispatch/interflow/bestheadpriorityscore_test.go @@ -0,0 +1,121 @@ +/* +Copyright 2025 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 interflowdispatch + +// import ( +// "testing" +// "time" + +// "github.com/stretchr/testify/assert" +// "github.com/stretchr/testify/require" +// "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/testing/mocks" +// "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/types" +// ) + +// func TestBestHeadPriorityScore_SelectQueue(t *testing.T) { +// policy := NewBestHeadPriorityScore() + +// commonScoreType := "enqueue_time_ns" + +// flowSpec1 := mocks.NewMockFlowSpecification("flow1", 0) +// flowSpec2 := mocks.NewMockFlowSpecification("flow2", 0) +// flowSpec3 := mocks.NewMockFlowSpecification("flow3", 0) + +// item1_q1 := mocks.NewMockQueueItemAccessor("item1_q1", flowSpec1.ID(), 0, time.Now(), 100) +// item1_q2 := mocks.NewMockQueueItemAccessor("item1_q2", flowSpec2.ID(), 0, time.Now(), 50) // Best score (lowest) +// item1_q3 := mocks.NewMockQueueItemAccessor("item1_q3", flowSpec3.ID(), 0, time.Now(), 200) + +// q1 := mocks.NewMockFlowQueueAccessor(flowSpec1, "q1", commonScoreType, nil) +// q1.MockLenVal = 1 +// q1.MockPeekHeadItemVal = item1_q1 + +// q2 := mocks.NewMockFlowQueueAccessor(flowSpec2, "q2", commonScoreType, nil) +// q2.MockLenVal = 1 +// q2.MockPeekHeadItemVal = item1_q2 + +// q3 := mocks.NewMockFlowQueueAccessor(flowSpec3, "q3", commonScoreType, nil) +// q3.MockLenVal = 1 +// q3.MockPeekHeadItemVal = item1_q3 + +// t.Run("SelectsQueueWithLowestScore", func(t *testing.T) { +// bandQueues := map[string]types.FlowQueueAccessor{"flow1": q1, "flow2": q2, "flow3": q3} +// bandFlowIDs := []string{"flow1", "flow2", "flow3"} +// band := mocks.NewMockPriorityBandAccessor(0, "TestBand1", 0, bandQueues, bandFlowIDs) + +// selected, err := policy.SelectQueue(band) +// require.NoError(t, err) +// require.NotNil(t, selected) +// assert.Same(t, q2, selected, "Should select q2 with the lowest score (50)") +// }) + +// t.Run("HandlesOneQueueEmpty", func(t *testing.T) { +// emptyQ1 := mocks.NewMockFlowQueueAccessor(flowSpec1, "emptyQ1", commonScoreType, nil) +// emptyQ1.MockLenVal = 0 +// emptyQ1.MockPeekHeadErrorVal = types.ErrQueueEmpty + +// bandQueues := map[string]types.FlowQueueAccessor{"flow1": emptyQ1, "flow2": q2, "flow3": q3} +// bandFlowIDs := []string{"flow1", "flow2", "flow3"} +// band := mocks.NewMockPriorityBandAccessor(0, "TestBandEmptyQ", 0, bandQueues, bandFlowIDs) +// selected, err := policy.SelectQueue(band) +// require.NoError(t, err) +// require.NotNil(t, selected) +// assert.Same(t, q2, selected) +// }) + +// t.Run("PriorityScoreTypeMismatchReturnsError", func(t *testing.T) { +// mismatchItem := mocks.NewMockQueueItemAccessor("mismatch_item", flowSpec3.ID(), 0, time.Now(), 10) +// qMismatch := mocks.NewMockFlowQueueAccessor(flowSpec3, "qMismatch", "different_score_type", nil) +// qMismatch.MockLenVal = 1 +// qMismatch.MockPeekHeadItemVal = mismatchItem + +// bandQueues := map[string]types.FlowQueueAccessor{"flow1": q1, "flow2": q2, "flow3_mismatch": qMismatch} +// // Order matters for when mismatch is detected by the policy's iteration. +// bandFlowIDs := []string{"flow1", "flow2", "flow3_mismatch"} +// band := mocks.NewMockPriorityBandAccessor(0, "TestBandMismatch", 0, bandQueues, bandFlowIDs) +// selected, err := policy.SelectQueue(band) + +// assert.ErrorIs(t, err, types.ErrIncompatiblePriorityType, "Should return ErrIncompatiblePriorityType if PriorityScoreType mismatches") +// assert.Nil(t, selected, "Selected queue should be nil on error") +// }) + +// t.Run("SingleNonEmptyQueueIsSelected", func(t *testing.T) { +// bandQueues := map[string]types.FlowQueueAccessor{"flow2": q2} +// bandFlowIDs := []string{"flow2"} +// band := mocks.NewMockPriorityBandAccessor(0, "TestBandEmptyQ", 0, bandQueues, bandFlowIDs) +// selected, err := policy.SelectQueue(band) + +// require.NoError(t, err) +// require.NotNil(t, selected) +// assert.Same(t, q2, selected) +// }) + +// t.Run("PeekHeadErrorOnOneQueueStillSelectsOther", func(t *testing.T) { +// qError := mocks.NewMockFlowQueueAccessor(flowSpec1, "qError", commonScoreType, nil) +// qError.MockLenVal = 1 // Has an item conceptually +// qError.MockPeekHeadItemVal = nil // but PeekHead will error +// qError.MockPeekHeadErrorVal = assert.AnError + +// bandQueues := map[string]types.FlowQueueAccessor{"flowError": qError, "flow2": q2} +// bandFlowIDs := []string{"flowError", "flow2"} +// band := mocks.NewMockPriorityBandAccessor(0, "TestBandPeekError", 0, bandQueues, bandFlowIDs) + +// selected, err := policy.SelectQueue(band) +// require.NoError(t, err, "Policy should not error out if one queue's PeekHead fails transiently") +// require.NotNil(t, selected, "Should still select q2 if qError.PeekHead fails") +// assert.Same(t, q2, selected) +// }) +// } diff --git a/pkg/epp/flowcontroller/plugins/dispatch/interflow/conformance_test.go b/pkg/epp/flowcontroller/plugins/dispatch/interflow/conformance_test.go new file mode 100644 index 000000000..c658790da --- /dev/null +++ b/pkg/epp/flowcontroller/plugins/dispatch/interflow/conformance_test.go @@ -0,0 +1,98 @@ +/* +Copyright 2025 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 interflowdispatch + +// import ( +// "testing" +// "time" + +// "github.com/stretchr/testify/assert" +// "github.com/stretchr/testify/require" +// "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/testing/mocks" +// "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/types" +// ) + +// func TestInterDispatchPolicy_Conformance(t *testing.T) { +// for policyName, factory := range registeredInterFlowDispatchPolicies { +// policyName := policyName +// factory := factory + +// t.Run(string(policyName), func(t *testing.T) { +// t.Run("Properties", func(t *testing.T) { +// policy, err := factory() +// require.NoError(t, err, "Policy factory failed") +// require.NotNil(t, policy, "Policy factory returned nil") +// assert.NotEmpty(t, policy.Name(), "Policy Name() should not be empty") +// assert.Equal(t, string(policyName), policy.Name(), "Policy Name() should match registered name") +// }) + +// t.Run("SelectQueueFromEmptyBand", func(t *testing.T) { +// policy, err := factory() +// require.NoError(t, err, "Policy factory failed") +// band := mocks.NewMockPriorityBandAccessor(0, "ConfPrioEmpty", 0, map[string]types.FlowQueueAccessor{}, []string{}) +// selectedQueue, err := policy.SelectQueue(band) +// assert.NoError(t, err, "SelectQueue from an empty band should not error") +// assert.Nil(t, selectedQueue, "SelectQueue from an empty band should return nil queue") +// }) + +// t.Run("SelectQueueFromBandWithEmptyQueues", func(t *testing.T) { +// policy, err := factory() +// require.NoError(t, err, "Policy factory failed") +// flowSpec := mocks.NewMockFlowSpecification("conf-flow-emptyq", 0) +// qEmpty := mocks.NewMockFlowQueueAccessor(flowSpec, "conf-q-empty", "conf-score-type", nil) +// qEmpty.MockLenVal = 0 +// qEmpty.MockPeekHeadErrorVal = types.ErrQueueEmpty + +// band := mocks.NewMockPriorityBandAccessor(0, "ConfPrioEmptyQs", 0, map[string]types.FlowQueueAccessor{"flow1": qEmpty}, []string{"flow1"}) +// selectedQueue, err := policy.SelectQueue(band) +// assert.NoError(t, err, "SelectQueue from a band with only empty queues should not error") +// assert.Nil(t, selectedQueue, "SelectQueue from a band with only empty queues should return nil queue") +// }) + +// t.Run("SelectQueueFromBandWithNonEmptyQueues", func(t *testing.T) { +// policy, err := factory() +// require.NoError(t, err, "Policy factory failed") +// flowSpec := mocks.NewMockFlowSpecification("conf-flow-nonempty", 0) +// item := mocks.NewMockQueueItemAccessor("conf-item", flowSpec.ID(), 0, time.Now(), 1.0) +// qNonEmpty := mocks.NewMockFlowQueueAccessor(flowSpec, "conf-q-nonempty", "conf-score-type", nil) +// qNonEmpty.MockLenVal = 1 +// qNonEmpty.MockPeekHeadItemVal = item + +// band := mocks.NewMockPriorityBandAccessor(0, "ConfPrioNonEmptyQs", 0, map[string]types.FlowQueueAccessor{"flow1": qNonEmpty}, []string{"flow1"}) +// selectedQueue, err := policy.SelectQueue(band) + +// // Error handling depends on the policy (e.g., BestHeadPriorityScore might error on type mismatch). +// // For basic conformance, if an error occurs, selectedQueue should be nil. +// if err != nil { +// assert.Nil(t, selectedQueue, "If SelectQueue errors, selected queue must be nil") +// } else if selectedQueue != nil { +// // If no error and a queue is selected, it must be one from the band. +// assert.Same(t, qNonEmpty, selectedQueue, "SelectQueue returned an unexpected queue") +// } +// // If selectedQueue is nil and err is nil, it means the policy chose not to select. +// }) + +// t.Run("SelectQueueWithNilBand", func(t *testing.T) { +// policy, err := factory() +// require.NoError(t, err, "Policy factory failed") +// selectedQueue, err := policy.SelectQueue(nil) +// assert.NoError(t, err, "SelectQueue with a nil band should not error") +// assert.Nil(t, selectedQueue, "SelectQueue with a nil band should return nil queue") +// }) +// }) +// } +// } diff --git a/pkg/epp/flowcontroller/plugins/dispatch/interflow/factory.go b/pkg/epp/flowcontroller/plugins/dispatch/interflow/factory.go new file mode 100644 index 000000000..c3cef32ab --- /dev/null +++ b/pkg/epp/flowcontroller/plugins/dispatch/interflow/factory.go @@ -0,0 +1,61 @@ +/* +Copyright 2025 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 interflowdispatch + +import ( + "fmt" + "sync" + + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/types" +) + +type RegisteredInterFlowDispatchPolicyName string + +// InterFlowDispatchPolicyConstructor defines the function signature for creating an InterFlowDispatchPolicy. +// It can accept configuration parameters in the future if needed. +type InterFlowDispatchPolicyConstructor func() (types.InterFlowDispatchPolicy, error) + +var ( + // mu guards the registration maps. + mu sync.RWMutex + // registeredInterFlowDispatchPolicies stores the constructors for all registered inter-flow dispatch policies. + registeredInterFlowDispatchPolicies = make(map[RegisteredInterFlowDispatchPolicyName]InterFlowDispatchPolicyConstructor) +) + +// RegisterPolicy registers a new inter-flow dispatch policy constructor. +// This function is called by policy implementations in their init() function. +func RegisterPolicy(name RegisteredInterFlowDispatchPolicyName, constructor InterFlowDispatchPolicyConstructor) { + mu.Lock() + defer mu.Unlock() + if _, ok := registeredInterFlowDispatchPolicies[name]; ok { + panic(fmt.Sprintf("InterFlowDispatchPolicy named %s already registered", name)) + } + registeredInterFlowDispatchPolicies[name] = constructor +} + +// NewPolicyFromName creates a new InterFlowDispatchPolicy given its registered name. +// This is called by the FlowRegistry during initialization. +// It can be extended to pass configuration to the constructor if policies become configurable. +func NewPolicyFromName(name RegisteredInterFlowDispatchPolicyName) (types.InterFlowDispatchPolicy, error) { + mu.RLock() + defer mu.RUnlock() + constructor, ok := registeredInterFlowDispatchPolicies[name] + if !ok { + return nil, fmt.Errorf("no InterFlowDispatchPolicy registered with name %q", name) + } + return constructor() +} diff --git a/pkg/epp/flowcontroller/plugins/dispatch/intraflow/README.md b/pkg/epp/flowcontroller/plugins/dispatch/intraflow/README.md new file mode 100644 index 000000000..6ead3060f --- /dev/null +++ b/pkg/epp/flowcontroller/plugins/dispatch/intraflow/README.md @@ -0,0 +1,57 @@ +# FlowController Intra-Flow Dispatch Policy Plugins (`plugins/dispatch/intraflow/`) + +This directory contains concrete implementations of the `types.IntraFlowDispatchPolicy` interface. These policies are responsible for determining the order in which requests are selected for dispatch *from within a single flow's queue*. + +## Overview + +The FlowController processes requests belonging to different logical "flows" (e.g., different models or workloads). These flows are first organized by the FlowController into **flow priority bands** based on their `FlowSpecification.Priority()`. Within each such band, each individual flow has its own queue. The `IntraFlowDispatchPolicy` assigned to a flow's queue defines the "local" scheduling or ordering discipline for requests that share the same `FlowID` (and thus the same high-level flow priority). + +Key responsibilities and characteristics of an `IntraFlowDispatchPolicy`: + +1. **Request Selection (`SelectItem`)**: The primary method, `SelectItem(queue types.FlowQueueAccessor) types.QueueItemAccessor`, inspects the given flow's queue (via a read-only accessor) and decides which item, if any, should be dispatched nex from *that specific queue*. + +2. **Priority Definition (`PriorityScoreType`, `ItemComparator`)**: + - This policy type is unique because it defines the nature of priority for items *within its specific managed queue*. This "item priority" or "score" is distinct from the overall "flow priority" itself and operates within this broader priority determined by `FlowSpecification.Priority()` which is used by the FlowController to assign flows to priority bands. + - It declares a `PriorityScoreType()` string (e.g., `"enqueue_time_ns"`, `"slo_deadline_urgency"`) that signifies how `QueueItemAccessor.PriorityScore()` should be interpreted for items in queues governed by this policy. + - If the policy requires a queue that can order items based on a custom comparison logic (rather than relying on simple FIFO/LIFO), it must provide an `ItemComparator()` function and declare `types.CapabilityPriorityConfigurable` in its `RequiredQueueCapabilities()`. The FlowController would then use this comparator to configure a suitable priority queue. For simpler policies like FCFS that rely on inherent queue ordering (e.g., `ListQueue`), `ItemComparator()` can return `nil`. + +3. **Queue Compatibility (`RequiredQueueCapabilities`)**: The policy specifies the capabilities its associated `FlowQueue` must support for it to function correctly (e.g., `types.CapabilityFIFO` for an FCFS policy, `types.CapabilityPriorityConfigurable` for a policy that uses a custom comparator). + +The `IntraFlowDispatchPolicy` allows for fine-grained control over how individual requests within a single flow are serviced, enabling strategies like basic FCFS, or more advanced schemes based on SLOs, deadlines, or predicted request costs for items belonging to that flow. This policy operates *after* the `InterFlowDispatchPolicy` has selected which flow's queue (from a given flow priority band) gets the next dispatch opportunity. + +## Contributing a New Intra-Flow Dispatch Policy + +To contribute a new intra-flow dispatch policy: + +1. **Define Your Policy Implementation**: + - Create a new Go file in this directory (e.g., `mycustompolicy.go`). + - Define a struct for your policy. + - Implement all methods of the `types.IntraFlowDispatchPolicy` interface on your struct: + - `SelectItem(queue types.FlowQueueAccessor) types.QueueItemAccessor` + - `ItemComparator() types.ItemComparator` (return `nil` if not using a custom priority queue) + - `PriorityScoreType() string` (define a clear string for your score type) + - `RequiredQueueCapabilities() []types.QueueCapability` + - `Name() string` (return a unique name for your policy type) + +2. **Register Your Policy (Optional but Recommended for Defaults)**: + - To make your policy discoverable by the system and automatically included in conformance tests, register it with the central factory. This is typically done in an `init()` function within your policy's Go file (e.g., `mycustompolicy.go`). + - Call `intraflowdispatch.RegisterPolicy()` from [`plugins/dispatch/intraflow/factory.go`](factory.go), passing your policy's unique name and a constructor function. + - If your policy is intended to be a generally available type (e.g., one of the default options for the system), define its `RegisteredIntraFlowDispatchPolicyName` constant within your policy's Go file. This makes it easily referenceable from configurations or other parts of the system. + - Conformance tests in [`plugins/dispatch/intraflow/conformance_test.go`](conformance_test.go) automatically iterate over all policies registered with the factory, so your policy will be included in these checks once registered. + +3. **Testing**: + - **Conformance Tests**: The tests in [`plugins/dispatch/intraflow/conformance_test.go`](conformance_test.go) verify that any `IntraFlowDispatchPolicy` implementation adheres to the basic contractual obligations of the interface (e.g., `Name()` is not empty, `SelectItem` handles nil/empty queues gracefully). Registering your policy will include it in these checks. + - **Implementation-Specific Tests**: Create a new test file (e.g., `mycustompolicy_test.go`) in this directory. Add unit tests that cover the unique logic of your `SelectItem` method, the behavior of your `ItemComparator` (if any), and verify that `PriorityScoreType()` and `RequiredQueueCapabilities()` return the correct values. + +4. **Documentation**: + - Add GoDoc comments to your new policy struct and its methods, explaining its selection logic, how it defines priority, any specific queue capabilities it relies on, and its intended use cases. + +## Example Implementation + +Refer to: + +- [`fcfs.go`](fcfs.go): For an example of a simple FCFS policy. +- [`fcfs_test.go`](fcfs_test.go): For examples of implementation-specific tests for the FCFS policy. +- [`conformance_test.go`](conformance_test.go): To understand the baseline behaviors tested for all intra-flow dispatch policies. + +By following these steps, you can introduce new per-flow request ordering strategies into the FlowController, enabling more tailored and sophisticated scheduling behaviors. diff --git a/pkg/epp/flowcontroller/plugins/dispatch/intraflow/conformance_test.go b/pkg/epp/flowcontroller/plugins/dispatch/intraflow/conformance_test.go new file mode 100644 index 000000000..f1b84a062 --- /dev/null +++ b/pkg/epp/flowcontroller/plugins/dispatch/intraflow/conformance_test.go @@ -0,0 +1,89 @@ +/* +Copyright 2025 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 intraflowdispatch + +// import ( +// "testing" +// "time" + +// "github.com/stretchr/testify/assert" +// "github.com/stretchr/testify/require" +// "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/testing/mocks" +// "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/types" +// ) + +// func TestIntraDispatchPolicy_Conformance(t *testing.T) { +// for policyName, factory := range registeredIntraFlowDispatchPolicies { +// policyName := policyName +// factory := factory + +// t.Run(string(policyName), func(t *testing.T) { +// t.Parallel() + +// t.Run("Properties", func(t *testing.T) { +// policy, err := factory() +// require.NoError(t, err, "Policy factory failed") +// require.NotNil(t, policy, "Policy factory returned nil") + +// assert.NotEmpty(t, policy.Name(), "Policy Name() should not be empty") +// assert.Equal(t, string(policyName), policy.Name(), "Policy Name() should match registered name") + +// assert.NotNil(t, policy.RequiredQueueCapabilities(), "RequiredQueueCapabilities() should not return nil (can be empty slice)") +// assert.NotEmpty(t, policy.PriorityScoreType(), "PriorityScoreType() should not be empty") + +// // ItemComparator can be nil (e.g., for FCFS relying on queue order). +// _ = policy.ItemComparator() // Just call it to ensure it doesn't panic +// }) + +// t.Run("SelectItemFromEmptyQueue", func(t *testing.T) { +// policy, err := factory() +// require.NoError(t, err, "Policy factory failed") +// mockQueue := mocks.NewMockFlowQueueAccessor(nil, "conf-empty-q", policy.PriorityScoreType(), policy.RequiredQueueCapabilities()) +// mockQueue.MockLenVal = 0 +// mockQueue.MockPeekHeadErrorVal = types.ErrQueueEmpty // FCFS uses PeekHead + +// selectedItem := policy.SelectItem(mockQueue) +// assert.Nil(t, selectedItem, "SelectItem from an empty queue should return nil") +// }) + +// t.Run("SelectItemFromNonEmptyQueue", func(t *testing.T) { +// policy, err := factory() +// require.NoError(t, err, "Policy factory failed") +// flowSpec := mocks.NewMockFlowSpecification("conf-flow", 0) +// item1 := mocks.NewMockQueueItemAccessor("item1", flowSpec.ID(), 0, time.Now(), 0) + +// mockQueue := mocks.NewMockFlowQueueAccessor(flowSpec, "conf-nonempty-q", policy.PriorityScoreType(), policy.RequiredQueueCapabilities()) +// mockQueue.MockLenVal = 1 +// mockQueue.MockPeekHeadItemVal = item1 // FCFS uses PeekHead + +// selectedItem := policy.SelectItem(mockQueue) +// // It's okay if it returns nil (e.g. policy decides not to select yet), but if it returns an item, it should be +// // one from the queue. +// if selectedItem != nil { +// assert.Equal(t, item1.RequestID(), selectedItem.RequestID(), "SelectItem returned an unexpected item") +// } +// }) + +// t.Run("SelectItemWithNilQueue", func(t *testing.T) { +// policy, err := factory() +// require.NoError(t, err, "Policy factory failed") +// selectedItem := policy.SelectItem(nil) +// assert.Nil(t, selectedItem, "SelectItem with a nil queue should return nil") +// }) +// }) +// } +// } diff --git a/pkg/epp/flowcontroller/plugins/dispatch/intraflow/factory.go b/pkg/epp/flowcontroller/plugins/dispatch/intraflow/factory.go new file mode 100644 index 000000000..cb30db206 --- /dev/null +++ b/pkg/epp/flowcontroller/plugins/dispatch/intraflow/factory.go @@ -0,0 +1,68 @@ +/* +Copyright 2025 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 intraflowdispatch + +import ( + "fmt" + "sync" + + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/types" +) + +type RegisteredIntraFlowDispatchPolicyName string + +type PriorityScoreType string + +// Common priority score types shared between policies. +// Policy-specific priority score types should be declared in their respective implementation files. +const ( + // EnqueueTimePriorityScoreType is a common priority score type for FCFS queues. + EnqueueTimePriorityScoreType PriorityScoreType = "enqueue_time_ns" +) + +type IntraFlowDispatchPolicyConstructor func() (types.IntraFlowDispatchPolicy, error) + +var ( + // mu guards the registration maps. + mu sync.RWMutex + // registeredInterFlowDispatchPolicies stores the constructors for all registered intra-flow dispatch policies. + registeredIntraFlowDispatchPolicies = make(map[RegisteredIntraFlowDispatchPolicyName]IntraFlowDispatchPolicyConstructor) +) + +// RegisterPolicy registers a new intra-flow dispatch policy constructor. +// This function is called by policy implementations in their init() function. +func RegisterPolicy(name RegisteredIntraFlowDispatchPolicyName, constructor IntraFlowDispatchPolicyConstructor) { + mu.Lock() + defer mu.Unlock() + if _, ok := registeredIntraFlowDispatchPolicies[name]; ok { + panic(fmt.Sprintf("IntraFlowDispatchPolicy named %s already registered", name)) + } + registeredIntraFlowDispatchPolicies[name] = constructor +} + +// NewPolicyFromName creates a new IntraFlowDispatchPolicy given its registered name. +// This is called by the FlowRegistry during initialization. +// It can be extended to pass configuration to the constructor if policies become configurable. +func NewPolicyFromName(name RegisteredIntraFlowDispatchPolicyName) (types.IntraFlowDispatchPolicy, error) { + mu.RLock() + defer mu.RUnlock() + constructor, ok := registeredIntraFlowDispatchPolicies[name] + if !ok { + return nil, fmt.Errorf("no IntraFlowDispatchPolicy registered with name %q", name) + } + return constructor() +} diff --git a/pkg/epp/flowcontroller/plugins/dispatch/intraflow/fcfs.go b/pkg/epp/flowcontroller/plugins/dispatch/intraflow/fcfs.go new file mode 100644 index 000000000..2ad19434d --- /dev/null +++ b/pkg/epp/flowcontroller/plugins/dispatch/intraflow/fcfs.go @@ -0,0 +1,70 @@ +/* +Copyright 2025 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 intraflowdispatch + +import ( + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/types" +) + +const FCFSDispatchPolicyName RegisteredIntraFlowDispatchPolicyName = "FCFS" + +func init() { + RegisterPolicy(FCFSDispatchPolicyName, func() (types.IntraFlowDispatchPolicy, error) { + return NewFCFS(), nil + }) +} + +// FCFS (First-Come, First-Served) implements the types.IntraFlowDispatchPolicy interface. +// It selects the item at the head of the queue, assuming the queue itself maintains FIFO order. +type FCFS struct{} + +var _ types.IntraFlowDispatchPolicy = &FCFS{} // Compile-time validation + +// NewFCFS creates a new FCFS IntraFlowDispatchPolicy. +func NewFCFS() *FCFS { + return &FCFS{} +} + +// SelectItem selects the next item from the queue. +// If the queue is the preferred "listqueue" type, it uses PeekHead(). +// Otherwise, it iterates through all items to find the one with the oldest EnqueueTime. +func (p *FCFS) SelectItem(queue types.FlowQueueAccessor) types.QueueItemAccessor { + if queue == nil { + return nil + } + // For FCFS, we simply peek the head. The error is ignored here as per typical policy plugin behavior; if PeekHead + // fails (e.g., empty queue), it returns nil, which is the correct signal for "no item selected". + item, _ := queue.PeekHead() + return item +} + +// ItemComparator returns nil because FCFS relies on the queue's inherent FIFO ordering (e.g., a ListQueue) and does +// not require a custom comparator for a generic priority queue. +func (p *FCFS) Comparator() types.ItemComparator { + return nil +} + +// RequiredQueueCapabilities specifies that this policy needs a queue that supports basic FIFO operations (implicitly, +// PeekHead). +func (p *FCFS) RequiredQueueCapabilities() []types.QueueCapability { + return []types.QueueCapability{types.CapabilityFIFO} +} + +// Name returns the unique string identifier for this policy implementation. +func (p *FCFS) Name() string { + return string(FCFSDispatchPolicyName) +} diff --git a/pkg/epp/flowcontroller/plugins/dispatch/intraflow/fcfs_test.go b/pkg/epp/flowcontroller/plugins/dispatch/intraflow/fcfs_test.go new file mode 100644 index 000000000..967366a50 --- /dev/null +++ b/pkg/epp/flowcontroller/plugins/dispatch/intraflow/fcfs_test.go @@ -0,0 +1,54 @@ +/* +Copyright 2025 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 intraflowdispatch + +// import ( +// "testing" +// "time" + +// "github.com/stretchr/testify/assert" +// "github.com/stretchr/testify/require" +// "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/testing/mocks" +// "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/types" +// ) + +// func TestFCFS_SelectItem(t *testing.T) { +// policy := NewFCFS() + +// t.Run("NonEmptyQueue", func(t *testing.T) { +// flowSpec := mocks.NewMockFlowSpecification("fcfs-flow", 0) +// item1 := mocks.NewMockQueueItemAccessor("item1", flowSpec.ID(), 0, time.Now(), 0) +// item1.MockEnqueueTimeVal = time.Now() // Explicitly set for clarity, though mock constructor does it. + +// mockQueue := mocks.NewMockFlowQueueAccessor(flowSpec, "fcfs-test-q", policy.PriorityScoreType(), policy.RequiredQueueCapabilities()) +// mockQueue.MockLenVal = 1 +// mockQueue.MockPeekHeadItemVal = item1 + +// selected := policy.SelectItem(mockQueue) +// require.NotNil(t, selected, "SelectItem from non-empty queue should return an item") +// assert.Equal(t, item1.RequestID(), selected.RequestID(), "SelectItem should return the item from PeekHead") +// }) +// } + +// func TestFCFS_PolicyProperties(t *testing.T) { +// policy := NewFCFS() + +// assert.Nil(t, policy.ItemComparator(), "FCFS ItemComparator should be nil") +// assert.Equal(t, string(EnqueueTimePriorityScoreType), policy.PriorityScoreType(), "FCFS PriorityScoreType mismatch") +// assert.Contains(t, policy.RequiredQueueCapabilities(), types.CapabilityFIFO, "FCFS should require CapabilityFIFO") +// assert.Equal(t, string(FCFSDispatchPolicyName), policy.Name(), "FCFS Name mismatch") +// } diff --git a/pkg/epp/flowcontroller/plugins/preemption/interflow/README.md b/pkg/epp/flowcontroller/plugins/preemption/interflow/README.md new file mode 100644 index 000000000..af3f16e95 --- /dev/null +++ b/pkg/epp/flowcontroller/plugins/preemption/interflow/README.md @@ -0,0 +1,86 @@ +# FlowController Inter-Flow Preemption Policy Plugins (`plugins/preemption/interflow/`) + +This directory contains concrete implementations of the `types.InterFlowPreemptionPolicy` interface. These policies are +responsible for selecting *which flow's queue* to target for preemption when the FlowController needs to make space for +a higher-priority request and has decided to look for victims in a specific lower flow priority band. + +## Overview + +When an incoming request cannot be enqueued due to capacity limits (either per-flow-priority-band or global), the +FlowController's preemption logic is triggered. It iterates through flow priority bands *lower* than that of the +incoming request, starting from the very lowest. For each such "victim band," it invokes the configured +`InterFlowPreemptionPolicy`. + +The role of the `InterFlowPreemptionPolicy` is to inspect the victim band and decide which specific flow (i.e., which +flow's queue) within that band should be considered for preemption. If this policy selects a flow's queue, the +FlowController then uses an `IntraFlowPreemptionPolicy` (e.g., "Tail") to pick an actual item from that chosen queue. + +Key responsibilities and characteristics of an `InterFlowPreemptionPolicy`: + +1. **Victim Flow Queue Selection (`SelectVictimQueue`)**: The primary method, + `SelectVictimQueue(victimBand types.PriorityBandAccessor) (types.FlowQueueAccessor, error)`, inspects all flow + queues within the given `victimBand` (which is of strictly lower priority than the preemptor) and returns the + `FlowQueueAccessor` of the flow queue chosen as the target. + - If no suitable flow queue is found (e.g., all queues in the band are empty, or the policy decides not to select\ + any), it returns `(nil, nil)`. + - An error should generally not be returned unless there's an unexpected issue with the policy's execution. + +2. **Selection Strategy**: Policies can implement various strategies for choosing a victim flow: + - **Structural/Iterative**: Like `RoundRobin`, which cycles through the available non-empty flow queues in the band + to distribute preemption attempts. + - **Attribute-based**: Policies could target flows based on attributes like current queue length + (`FlowQueueAccessor.Len()`), total byte size (`FlowQueueAccessor.ByteSize()`), or historical metrics like least + recently serviced. + - **Score-based**: Policies could potentially peek at items (e.g., head/tail) in different queues and use their + respective `ItemComparator`s (obtained via `FlowQueueAccessor.Comparator()`) to assess preemptability, assuming + compatible `ScoreType`s and a clear convention for what constitutes "worst" for preemption. This often overlaps + with dispatch logic. + - **Consumption-based**: More advanced policies might track historical resource consumption or dispatch counts per + flow to make fairness-oriented preemption decisions. + +3. **Stateless or Stateful**: Policies like Round Robin are stateful (they need to remember the last selected flow per + band). Others might be stateless. + +This policy allows the FlowController to strategically decide which broader workload/flow should bear the cost of +preemption. + +## Contributing a New Inter-Flow Preemption Policy + +To contribute a new inter-flow preemption policy: + +1. **Define Your Policy Implementation**: + - Create a new Go file in this directory (e.g., `mylargeflowpreemption.go`). + - Define a struct for your policy, including any state it needs to maintain (e.g., for Round Robin). Remember to + handle concurrency if state is shared. + - Implement the `types.InterFlowPreemptionPolicy` interface: + - `SelectVictimQueue(victimBand types.PriorityBandAccessor) (types.FlowQueueAccessor, error)` + - `Name() string` + +2. **Register Your Policy**: + - To make your policy discoverable by the system and automatically included in conformance tests, register it with + the central factory. This is typically done in an `init()` function within your policy's Go file (e.g., + `mylargeflowpreemption.go`). + - Call `interflowpreemption.RegisterPolicy()` from [`plugins/preemption/interflow/factory.go`](factory.go), passing + your policy's unique name and a constructor function. + - If your policy is intended to be a generally available type (e.g., one of the default options for the system), + define its `RegisteredInterFlowPreemptionPolicyName` constant within your policy's Go file. This makes it easily + referenceable from configurations or other parts of the system. + - Conformance tests in [`plugins/preemption/interflow/conformance_test.go`](conformance_test.go) automatically + iterate over all policies registered with the factory, so your policy will be included in these checks once + registered. + +3. **Testing**: + - **Conformance Tests**: Ensure basic contractual obligations are met. + - **Implementation-Specific Tests**: Test the unique logic of your `SelectVictimQueue` method, especially its state + management and selection criteria across various band states. + +4. **Documentation**: + - Add GoDoc comments explaining your policy's selection strategy. + +## Example Implementation + +Refer to: + +- [`roundrobin.go`](roundrobin.go): For an example of a stateful round-robin policy. +- [`roundrobin_test.go`](roundrobin_test.go): For its specific tests. +- [`conformance_test.go`](conformance_test.go): For baseline behaviors. diff --git a/pkg/epp/flowcontroller/plugins/preemption/interflow/conformance_test.go b/pkg/epp/flowcontroller/plugins/preemption/interflow/conformance_test.go new file mode 100644 index 000000000..ba69f8302 --- /dev/null +++ b/pkg/epp/flowcontroller/plugins/preemption/interflow/conformance_test.go @@ -0,0 +1,104 @@ +/* +Copyright 2025 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 interflowpreemption + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/testing/mocks" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/types" +) + +func TestInterFlowPreemptionPolicy_Conformance(t *testing.T) { + t.Parallel() + + for policyName, factory := range registeredInterFlowPreemptionPolicies { + policyName := policyName + factory := factory + + t.Run(string(policyName), func(t *testing.T) { + t.Parallel() + + t.Run("Properties", func(t *testing.T) { + t.Parallel() + policy, err := factory() + require.NoError(t, err, "Policy factory failed") + require.NotNil(t, policy, "Policy factory returned nil") + assert.NotEmpty(t, policy.Name(), "Policy Name() should not be empty") + assert.Equal(t, string(policyName), policy.Name(), "Policy Name() should match registered name") + }) + + t.Run("SelectVictimQueue_EmptyBand", func(t *testing.T) { + t.Parallel() + policy, err := factory() + require.NoError(t, err, "Policy factory failed") + band := mocks.NewMockPriorityBandAccessor(0, "conf-empty-band", 0, map[string]types.FlowQueueAccessor{}, []string{}) + selectedQueue, err := policy.SelectVictimQueue(band) + assert.NoError(t, err, "SelectVictimQueue from an empty band should not error") + assert.Nil(t, selectedQueue, "SelectVictimQueue from an empty band should return nil queue") + }) + + t.Run("SelectVictimQueue_BandWithEmptyQueues", func(t *testing.T) { + t.Parallel() + policy, err := factory() + require.NoError(t, err, "Policy factory failed") + flowSpecEmpty := mocks.NewMockFlowSpecification("conf-q-empty", 0) + qEmpty := mocks.NewMockFlowQueueAccessor(flowSpecEmpty, "q-empty", nil, nil) + qEmpty.MockLenVal = 0 + + bandQueues := map[string]types.FlowQueueAccessor{"conf-q-empty": qEmpty} + bandFlowIDs := []string{"conf-q-empty"} + band := mocks.NewMockPriorityBandAccessor(0, "conf-band-q-empty", 0, bandQueues, bandFlowIDs) + + selectedQueue, err := policy.SelectVictimQueue(band) + assert.NoError(t, err, "SelectVictimQueue from a band with only empty queues should not error") + assert.Nil(t, selectedQueue, "SelectVictimQueue from a band with only empty queues should return nil queue") + }) + + t.Run("SelectVictimQueue_BandWithNonEmptyQueues", func(t *testing.T) { + t.Parallel() + policy, err := factory() + require.NoError(t, err, "Policy factory failed") + flowSpecNonEmpty := mocks.NewMockFlowSpecification("conf-q-non-empty", 0) + qNonEmpty := mocks.NewMockFlowQueueAccessor(flowSpecNonEmpty, "q-non-empty", nil, nil) + qNonEmpty.MockLenVal = 1 + + bandQueues := map[string]types.FlowQueueAccessor{"conf-q-non-empty": qNonEmpty} + bandFlowIDs := []string{"conf-q-non-empty"} + band := mocks.NewMockPriorityBandAccessor(0, "conf-band-q-non-empty", 0, bandQueues, bandFlowIDs) + + selectedQueue, err := policy.SelectVictimQueue(band) + assert.NoError(t, err, "SelectVictimQueue from a band with non-empty queues should not error") + // Policy might still return nil if it decides not to select, but if it does, it should be from the band. + if selectedQueue != nil { + assert.Same(t, qNonEmpty, selectedQueue, "SelectVictimQueue returned an unexpected queue") + } + }) + + t.Run("SelectVictimQueue_NilBand", func(t *testing.T) { + t.Parallel() + policy, err := factory() + require.NoError(t, err, "Policy factory failed") + selectedQueue, err := policy.SelectVictimQueue(nil) + assert.NoError(t, err, "SelectVictimQueue with a nil band should not error") + assert.Nil(t, selectedQueue, "SelectVictimQueue with a nil band should return nil queue") + }) + }) + } +} diff --git a/pkg/epp/flowcontroller/plugins/preemption/interflow/factory.go b/pkg/epp/flowcontroller/plugins/preemption/interflow/factory.go new file mode 100644 index 000000000..5accb5d0a --- /dev/null +++ b/pkg/epp/flowcontroller/plugins/preemption/interflow/factory.go @@ -0,0 +1,59 @@ +/* +Copyright 2025 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 interflowpreemption + +import ( + "fmt" + "sync" + + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/types" +) + +type RegisteredInterFlowPreemptionPolicyName string + +type InterFlowPreemptionPolicyConstructor func() (types.InterFlowPreemptionPolicy, error) + +var ( + // mu guards the registration maps. + mu sync.RWMutex + // registeredInterFlowPreemptionPolicies stores the constructors for all registered inter-flow preemption policies. + registeredInterFlowPreemptionPolicies = make(map[RegisteredInterFlowPreemptionPolicyName]InterFlowPreemptionPolicyConstructor) +) + +// RegisterPolicy registers a new inter-flow preemption policy constructor. +// This function is called by policy implementations in their init() function. +func RegisterPolicy(name RegisteredInterFlowPreemptionPolicyName, constructor InterFlowPreemptionPolicyConstructor) { + mu.Lock() + defer mu.Unlock() + if _, ok := registeredInterFlowPreemptionPolicies[name]; ok { + panic(fmt.Sprintf("InterFlowPreemptionPolicy named %s already registered", name)) + } + registeredInterFlowPreemptionPolicies[name] = constructor +} + +// NewPolicyFromName creates a new RegisteredInterFlowPreemptionPolicyName given its registered name. +// This is called by the FlowRegistry during initialization. +// It can be extended to pass configuration to the constructor if policies become configurable. +func NewPolicyFromName(name RegisteredInterFlowPreemptionPolicyName) (types.InterFlowPreemptionPolicy, error) { + mu.RLock() + defer mu.RUnlock() + constructor, ok := registeredInterFlowPreemptionPolicies[name] + if !ok { + return nil, fmt.Errorf("no InterFlowPreemptionPolicy registered with name %q", name) + } + return constructor() +} diff --git a/pkg/epp/flowcontroller/plugins/preemption/interflow/roundrobin.go b/pkg/epp/flowcontroller/plugins/preemption/interflow/roundrobin.go new file mode 100644 index 000000000..84b7e79a3 --- /dev/null +++ b/pkg/epp/flowcontroller/plugins/preemption/interflow/roundrobin.go @@ -0,0 +1,95 @@ +/* +Copyright 2025 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 interflowpreemption + +import ( + "sort" + "sync" + + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/types" +) + +const RoundRobinPreemptionPolicyName RegisteredInterFlowPreemptionPolicyName = "RoundRobin" + +func init() { + RegisterPolicy(RoundRobinPreemptionPolicyName, func() (types.InterFlowPreemptionPolicy, error) { + return NewRoundRobin(), nil + }) +} + +// RoundRobin implements the types.InterFlowPreemptionPolicy interface. +// It selects a victim flow's queue from a lower-priority band in a round-robin fashion. +// It aims to distribute preemption attempts fairly across flows within the target band. +// This policy instance is assumed to be scoped to a single priority band. +type RoundRobin struct { + mu sync.Mutex + // lastSelectedFlowIndex stores the index of the last flow ID selected from the sorted list of flow IDs + // within the band this policy instance is responsible for. + lastSelectedFlowIndex int +} + +// NewRoundRobin creates a new RoundRobin policy. +func NewRoundRobin() *RoundRobin { + return &RoundRobin{ + lastSelectedFlowIndex: -1, // Initialize to -1 to ensure the first selection starts at index 0 + } +} + +// SelectVictimQueue selects the next non-empty flow's queue from the victimBand in a round-robin order. +// It returns (nil, nil) if no non-empty queue is found in the band. +func (p *RoundRobin) SelectVictimQueue(victimBand types.PriorityBandAccessor) (types.FlowQueueAccessor, error) { + if victimBand == nil { + return nil, nil + } + + p.mu.Lock() + defer p.mu.Unlock() + + flowIDs := victimBand.FlowIDs() + if len(flowIDs) == 0 { + return nil, nil + } + + // Sort flowIDs to ensure consistent iteration order for round-robin. + sort.Strings(flowIDs) + + // Iterate up to twice the number of flows to ensure we check every flow at least once starting from the one after + // the last selected, and wrap around if needed. + startIndex := p.lastSelectedFlowIndex + for i := 0; i < len(flowIDs)*2; i++ { + currentIndex := (startIndex + 1 + i) % len(flowIDs) + currentFlowID := flowIDs[currentIndex] + queue := victimBand.Queue(currentFlowID) + + if queue != nil && queue.Len() > 0 { + p.lastSelectedFlowIndex = currentIndex + return queue, nil // Found a non-empty queue + } + } + + // No non-empty queue found in this band. + // p.lastSelectedFlowIndex remains unchanged, so if flows repopulate, it will continue from where it left off in the + // cycle. + return nil, nil +} + +// Name returns the unique string identifier for this policy implementation. +func (p *RoundRobin) Name() string { + return string(RoundRobinPreemptionPolicyName) +} + +var _ types.InterFlowPreemptionPolicy = &RoundRobin{} // Compile-time validation diff --git a/pkg/epp/flowcontroller/plugins/preemption/interflow/roundrobin_test.go b/pkg/epp/flowcontroller/plugins/preemption/interflow/roundrobin_test.go new file mode 100644 index 000000000..8b7370947 --- /dev/null +++ b/pkg/epp/flowcontroller/plugins/preemption/interflow/roundrobin_test.go @@ -0,0 +1,106 @@ +/* +Copyright 2025 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 interflowpreemption + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/testing/mocks" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/types" +) + +func TestRoundRobin_SelectVictimQueue(t *testing.T) { + t.Parallel() + policy := NewRoundRobin() + + // Use shared mocks for FlowSpecification + flowSpecA := mocks.NewMockFlowSpecification("flowA", 0) + flowSpecB := mocks.NewMockFlowSpecification("flowB", 0) + flowSpecC := mocks.NewMockFlowSpecification("flowC", 0) + flowSpecEmpty := mocks.NewMockFlowSpecification("flowEmpty", 0) + + // Use shared mocks for QueueAccessor + qA := mocks.NewMockFlowQueueAccessor(flowSpecA, "qA", nil, nil) + qA.MockLenVal = 1 + qB := mocks.NewMockFlowQueueAccessor(flowSpecB, "qB", nil, nil) + qB.MockLenVal = 1 + qC := mocks.NewMockFlowQueueAccessor(flowSpecC, "qC", nil, nil) + qC.MockLenVal = 1 + qEmpty := mocks.NewMockFlowQueueAccessor(flowSpecEmpty, "qEmpty", nil, nil) + qEmpty.MockLenVal = 0 + + t.Run("SelectsInRoundRobinOrder", func(t *testing.T) { + t.Parallel() + + // The RoundRobin policy sorts FlowIDs internally. + // We provide MockFlowIDsInOrder sorted for clarity. + bandQueues := map[string]types.FlowQueueAccessor{"flowA": qA, "flowB": qB, "flowC": qC} + bandFlowIDs := []string{"flowA", "flowB", "flowC"} // Expected sorted order + band := mocks.NewMockPriorityBandAccessor(0, "TestBand1", 0, bandQueues, bandFlowIDs) + + // Expected order based on sorted flowIDs: flowA, flowB, flowC. + selected, err := policy.SelectVictimQueue(band) + require.NoError(t, err) + require.NotNil(t, selected) + assert.Equal(t, "flowA", selected.FlowSpec().ID()) + + selected, err = policy.SelectVictimQueue(band) + require.NoError(t, err) + require.NotNil(t, selected) + assert.Equal(t, "flowB", selected.FlowSpec().ID()) + + selected, err = policy.SelectVictimQueue(band) + require.NoError(t, err) + require.NotNil(t, selected) + assert.Equal(t, "flowC", selected.FlowSpec().ID()) + + // Wraps around + selected, err = policy.SelectVictimQueue(band) + require.NoError(t, err) + require.NotNil(t, selected) + assert.Equal(t, "flowA", selected.FlowSpec().ID()) + }) + + t.Run("SkipsEmptyQueues", func(t *testing.T) { + t.Parallel() + + policy := NewRoundRobin() // Fresh policy for clean state + // The RoundRobin policy sorts FlowIDs internally. + // We provide MockFlowIDsInOrder sorted for clarity. + bandQueues := map[string]types.FlowQueueAccessor{"flowA": qA, "flowEmpty": qEmpty, "flowC": qC} + bandFlowIDs := []string{"flowA", "flowC", "flowEmpty"} // Expected sorted order of all flow IDs + band := mocks.NewMockPriorityBandAccessor(0, "TestBand2", 0, bandQueues, bandFlowIDs) + + // Expected order: flowA, flowC (flowEmpty is skipped). + selected, err := policy.SelectVictimQueue(band) // Should pick flowA + require.NoError(t, err) + require.NotNil(t, selected) + assert.Equal(t, "flowA", selected.FlowSpec().ID()) + + selected, err = policy.SelectVictimQueue(band) // Should pick flowC + require.NoError(t, err) + require.NotNil(t, selected) + assert.Equal(t, "flowC", selected.FlowSpec().ID()) + + selected, err = policy.SelectVictimQueue(band) // Wraps around to flowA + require.NoError(t, err) + require.NotNil(t, selected) + assert.Equal(t, "flowA", selected.FlowSpec().ID()) + }) +} diff --git a/pkg/epp/flowcontroller/plugins/preemption/intraflow/README.md b/pkg/epp/flowcontroller/plugins/preemption/intraflow/README.md new file mode 100644 index 000000000..d99467b83 --- /dev/null +++ b/pkg/epp/flowcontroller/plugins/preemption/intraflow/README.md @@ -0,0 +1,96 @@ +# FlowController Intra-Flow Preemption Policy Plugins (`plugins/preemption/intraflow/`) + +This directory contains concrete implementations of the `types.IntraFlowPreemptionPolicy` interface. These policies are +responsible for selecting a specific request (victim) to preempt *from within a single flow's queue*. This typically +occurs when a higher-priority flow needs capacity, and the FlowController has decided to target a lower-priority flow +for preemption. + +## Overview + +When the FlowController determines that preemption is necessary to make space for an incoming request, it first uses an +`InterFlowPreemptionPolicy` to select a victim *flow's queue* from a lower flow priority band. Once a victim flow's +queue is chosen, the `IntraFlowPreemptionPolicy` associated with that flow (or a default for that priority band) is +invoked to pick the actual item to remove from that queue. + +Key responsibilities and characteristics of an `IntraFlowPreemptionPolicy`: + +1. **Victim Selection (`SelectVictim`)**: The primary method, + `SelectVictim(queue types.FlowQueueAccessor) (types.QueueItemAccessor, error)`, inspects the given flow's queue (via + a read-only accessor) and returns the `QueueItemAccessor` of the item chosen for preemption. If no item is selected + (e.g., the queue is empty, or the policy decides not to preempt from this queue despite being asked), it returns + `(nil, nil)`. An error should only be returned for unexpected policy execution issues, not for failing to find a + victim. + +2. **Preemption Logic**: Policies can implement various victim selection strategies: + - **Structural (Current Scope)**: Given the current `types.FlowQueueAccessor` interface (which provides `PeekHead()` + and `PeekTail()`), policies are primarily structural. For example, the `Tail` policy selects the item at the tail of + the queue (often the newest in a FIFO queue). Similarly, a "Head" preemption policy could be implemented. These are + generic to how item priority is defined for dispatch. + - **Attribute-based (Future Work)**: To implement policies that select victims based on iterating through all items + in the queue to find one based on attributes not necessarily used for dispatch ordering (e.g., selecting the largest + or smallest request by `ByteSize()`, or an item with a specific characteristic from `OriginalRequest()`), the + `types.FlowQueueAccessor` would need to be enhanced or wrapped (see Future Work). It is important to note that full + queue iteration for preemption is a less efficient operation than typical dispatch (which often only inspects the + head) and should be designed with this trade-off in mind; the efficiency of the primary dispatch path (intra-flow + dispatch) remains paramount. + +3. **Queue Compatibility (`RequiredQueueCapabilities`)**: The policy specifies the capabilities its associated + `SafeQueue` must support. For example, a policy that preempts from the tail (`Tail`) requires + `types.CapabilityDoubleEnded`. + +The `IntraFlowPreemptionPolicy` allows for fine-grained control over which specific request within a flow is sacrificed +during contention. + +## Contributing a New Intra-Flow Preemption Policy + +To contribute a new intra-flow preemption policy: + +1. **Define Your Policy Implementation**: + - Create a new Go file in this directory (e.g., `mypreemptionpolicy.go`). + - Define a struct for your policy. + - Implement all methods of the `types.IntraFlowPreemptionPolicy` interface on your struct: + - `SelectVictim(queue types.FlowQueueAccessor) (types.QueueItemAccessor, error)` + - `RequiredQueueCapabilities() []types.QueueCapability` + - `Name() string` (return a unique name for your policy type) + +2. **Register Your Policy**: + - To make your policy discoverable by the system and automatically included in conformance tests, register it with + the central factory. This is typically done in an `init()` function within your policy's Go file (e.g., + `mypreemptionpolicy.go`). + - Call `intraflowpreemption.RegisterPolicy()` from [`plugins/preemption/intraflow/factory.go`](factory.go), passing + your policy's unique name and a constructor function. + - If your policy is intended to be a generally available type (e.g., one of the default options for the system), + define its `RegisteredIntraFlowPreemptionPolicyName` constant within your policy's Go file. This makes it easily + referenceable from configurations or other parts of the system. + - Conformance tests in [`plugins/preemption/intraflow/conformance_test.go`](conformance_test.go) automatically + iterate over all policies registered with the factory, so your policy will be included in these checks once + registered. + +3. **Testing**: + - **Conformance Tests**: The tests in [`plugins/preemption/intraflow/conformance_test.go`](conformance_test.go) + verify basic contractual obligations. Registering your policy includes it in these checks. + - **Implementation-Specific Tests**: Create a new test file (e.g., `mypreemptionpolicy_test.go`). Add unit tests + covering your `SelectVictim` logic, especially how it interacts with different queue states and required + capabilities. + +4. **Documentation**: + - Add GoDoc comments explaining your policy's victim selection strategy and any specific queue capabilities it + relies on. + +## Example Implementation + +Refer to: + +- [`tail.go`](tail.go): For an example of a policy that selects the tail item for preemption. +- [`tail_test.go`](tail_test.go): For examples of implementation-specific tests. +- [`conformance_test.go`](conformance_test.go): For baseline behaviors tested for all intra-flow preemption policies. + +## Future Work + +- **Iterable Queue Accessor**: For more advanced `IntraFlowPreemptionPolicy` implementations that need to inspect all + items in a queue (e.g., to find an item based on attributes like its `ByteSize()` if the queue isn't already ordered + by this, or to apply more complex selection logic across the entire queue), the `types.FlowQueueAccessor` would need + to be enhanced, or a new `IterableFlowQueueAccessor` wrapper/interface could be introduced. This would allow policies + to iterate through items beyond just peeking at the head or tail. + +This allows for flexible and targeted preemption strategies within the FlowController. diff --git a/pkg/epp/flowcontroller/plugins/preemption/intraflow/conformance_test.go b/pkg/epp/flowcontroller/plugins/preemption/intraflow/conformance_test.go new file mode 100644 index 000000000..f546b52bf --- /dev/null +++ b/pkg/epp/flowcontroller/plugins/preemption/intraflow/conformance_test.go @@ -0,0 +1,115 @@ +/* +Copyright 2025 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 intraflowpreemption + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/testing/mocks" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/types" +) + +func TestIntraPreemptionPolicy_Conformance(t *testing.T) { + t.Parallel() + + for policyName, factory := range registeredIntraFlowPreemptionPolicies { + policyName := policyName + factory := factory + + t.Run(string(policyName), func(t *testing.T) { + t.Parallel() + + t.Run("Properties", func(t *testing.T) { + t.Parallel() + policy, err := factory() + require.NoError(t, err, "Policy factory failed") + require.NotNil(t, policy, "Policy factory returned nil") + + assert.NotEmpty(t, policy.Name(), "Policy Name() should not be empty") + assert.Equal(t, string(policyName), policy.Name(), "Policy Name() should match registered name") + assert.NotNil(t, policy.RequiredQueueCapabilities(), "RequiredQueueCapabilities() should not return nil") + }) + + t.Run("SelectVictim_EmptyQueue", func(t *testing.T) { + t.Parallel() + policy, err := factory() + require.NoError(t, err, "Policy factory failed") + + mockQueue := mocks.NewMockFlowQueueAccessor(nil, "conf-empty-q", policy.RequiredQueueCapabilities(), nil) + mockQueue.MockLenVal = 0 + + victim, err := policy.SelectVictim(mockQueue) + assert.NoError(t, err, "Policy's SelectVictim from an empty queue should not error") + assert.Nil(t, victim, "SelectVictim from an empty or incompatible queue should return nil victim") + }) + + t.Run("SelectVictim_NonEmptyQueue", func(t *testing.T) { + t.Parallel() + policy, err := factory() + require.NoError(t, err, "Policy factory failed") + + item1 := mocks.NewMockQueueItemAccessor("item1", "", 0, time.Now()) + mockQueue := mocks.NewMockFlowQueueAccessor(nil, "conf-nonempty-q", policy.RequiredQueueCapabilities(), nil) + mockQueue.MockLenVal = 1 + // For a single item queue, both head and tail would point to this item. + mockQueue.MockPeekHeadItemVal = item1 + mockQueue.MockPeekTailItemVal = item1 + + victim, err := policy.SelectVictim(mockQueue) + assert.NoError(t, err, "Policy's SelectVictim from a non-empty queue should not error") + if victim != nil { // Policy may still chooses not to preempt + assert.Equal(t, item1.RequestID(), victim.RequestID(), "SelectVictim returned an unexpected item") + } + }) + + t.Run("SelectVictim_NilQueue", func(t *testing.T) { + t.Parallel() + policy, err := factory() + require.NoError(t, err, "Policy factory failed") + + victim, err := policy.SelectVictim(nil) + assert.NoError(t, err, "Policy's SelectVictim with a nil queue should not error") + assert.Nil(t, victim, "SelectVictim with a nil queue should return nil victim") + }) + + t.Run("SelectVictim_QueueDoesNotSupportCapability", func(t *testing.T) { + t.Parallel() + policy, err := factory() + require.NoError(t, err, "Policy factory failed") + + requiredCaps := policy.RequiredQueueCapabilities() + if len(requiredCaps) == 0 { + t.Skip("Policy does not require any queue capabilities, skipping capability check.") + } + + // Mock a queue that explicitly has no capabilities. + mockQueue := mocks.NewMockFlowQueueAccessor(nil, "conf-no-cap-q", []types.QueueCapability{}, nil) + mockQueue.MockLenVal = 1 // Make it non-empty to distinguish from empty queue case + mockQueue.MockPeekHeadErrorVal = types.ErrOperationNotSupported + mockQueue.MockPeekTailErrorVal = types.ErrOperationNotSupported + + victim, err := policy.SelectVictim(mockQueue) + assert.ErrorIs(t, err, types.ErrPolicyQueueMismatch, + "Policy should return ErrPolicyQueueMismatch when queue lacks required capability") + assert.Nil(t, victim, "SelectVictim should return nil victim when returning an error due to capability mismatch") + }) + }) + } +} diff --git a/pkg/epp/flowcontroller/plugins/preemption/intraflow/factory.go b/pkg/epp/flowcontroller/plugins/preemption/intraflow/factory.go new file mode 100644 index 000000000..e992efa91 --- /dev/null +++ b/pkg/epp/flowcontroller/plugins/preemption/intraflow/factory.go @@ -0,0 +1,59 @@ +/* +Copyright 2025 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 intraflowpreemption + +import ( + "fmt" + "sync" + + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/types" +) + +type RegisteredIntraFlowPreemptionPolicyName string + +type IntraFlowPreemptionPolicyConstructor func() (types.IntraFlowPreemptionPolicy, error) + +var ( + // mu guards the registration maps. + mu sync.RWMutex + // registeredIntraFlowPreemptionPolicies stores the constructors for all registered intra-flow preemption policies. + registeredIntraFlowPreemptionPolicies = make(map[RegisteredIntraFlowPreemptionPolicyName]IntraFlowPreemptionPolicyConstructor) +) + +// RegisterPolicy registers a new intra-flow preemption policy constructor. +// This function is called by policy implementations in their init() function. +func RegisterPolicy(name RegisteredIntraFlowPreemptionPolicyName, constructor IntraFlowPreemptionPolicyConstructor) { + mu.Lock() + defer mu.Unlock() + if _, ok := registeredIntraFlowPreemptionPolicies[name]; ok { + panic(fmt.Sprintf("IntraFlowPreemptionPolicy named %s already registered", name)) + } + registeredIntraFlowPreemptionPolicies[name] = constructor +} + +// NewPolicyFromName creates a new IntraFlowPreemptionPolicy given its registered name. +// This is called by the FlowRegistry during initialization. +// It can be extended to pass configuration to the constructor if policies become configurable. +func NewPolicyFromName(name RegisteredIntraFlowPreemptionPolicyName) (types.IntraFlowPreemptionPolicy, error) { + mu.RLock() + defer mu.RUnlock() + constructor, ok := registeredIntraFlowPreemptionPolicies[name] + if !ok { + return nil, fmt.Errorf("no IntraFlowPreemptionPolicy registered with name %q", name) + } + return constructor() +} diff --git a/pkg/epp/flowcontroller/plugins/preemption/intraflow/tail.go b/pkg/epp/flowcontroller/plugins/preemption/intraflow/tail.go new file mode 100644 index 000000000..d460629b0 --- /dev/null +++ b/pkg/epp/flowcontroller/plugins/preemption/intraflow/tail.go @@ -0,0 +1,73 @@ +/* +Copyright 2025 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 intraflowpreemption + +import ( + "errors" + "fmt" + + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/types" +) + +const TailPreemptionPolicyName RegisteredIntraFlowPreemptionPolicyName = "Tail" + +func init() { + RegisterPolicy(TailPreemptionPolicyName, func() (types.IntraFlowPreemptionPolicy, error) { + return NewTail(), nil + }) +} + +// Tail implements the types.IntraFlowPreemptionPolicy interface. +// It selects the item at the tail of the queue as the victim for preemption. +// For a typical FIFO queue (like ListQueue where items are added to the back/tail), this means the newest item in the +// queue would be selected. For a priority queue, this would be the lowest priority request. +type Tail struct{} + +// NewTail creates a new Tail IntraFlowPreemptionPolicy. +func NewTail() *Tail { + return &Tail{} +} + +// SelectVictim returns the item at the tail of the queue. +// It relies on the queue's PeekTail() method. +func (p *Tail) SelectVictim(queue types.FlowQueueAccessor) (types.QueueItemAccessor, error) { + if queue == nil { + return nil, nil // No error for nil queue, just no victim + } + + victim, err := queue.PeekTail() + if err != nil { + if errors.Is(err, types.ErrOperationNotSupported) { + // If the queue doesn't support PeekTail, this policy cannot function. + return nil, fmt.Errorf("%w: Tail policy requires PeekTail capability: %w", types.ErrPolicyQueueMismatch, err) + } + return nil, nil // For other errors like ErrQueueEmpty, still return no victim, no unrecoverable policy error. + } + return victim, nil +} + +// RequiredQueueCapabilities specifies that this policy needs a queue that supports peeking at its tail end. +func (p *Tail) RequiredQueueCapabilities() []types.QueueCapability { + return []types.QueueCapability{types.CapabilityDoubleEnded} +} + +// Name returns the unique string identifier for this policy implementation. +func (p *Tail) Name() string { + return string(TailPreemptionPolicyName) +} + +var _ types.IntraFlowPreemptionPolicy = &Tail{} // Compile-time validation diff --git a/pkg/epp/flowcontroller/plugins/preemption/intraflow/tail_test.go b/pkg/epp/flowcontroller/plugins/preemption/intraflow/tail_test.go new file mode 100644 index 000000000..758477a82 --- /dev/null +++ b/pkg/epp/flowcontroller/plugins/preemption/intraflow/tail_test.go @@ -0,0 +1,52 @@ +/* +Copyright 2025 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 intraflowpreemption + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/testing/mocks" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/types" +) + +func TestTail_SelectVictim_NonEmptyQueueSupportingDoubleEnded(t *testing.T) { + t.Parallel() + policy := NewTail() + + tailItem := mocks.NewMockQueueItemAccessor("tailItem", "", 0, time.Now()) + mockQueue := mocks.NewMockFlowQueueAccessor(nil, "tail-test-q-nonempty", + []types.QueueCapability{types.CapabilityDoubleEnded}, nil) + mockQueue.MockLenVal = 2 // Simulate a queue with items + mockQueue.MockPeekTailItemVal = tailItem + + selected, err := policy.SelectVictim(mockQueue) + require.NoError(t, err, "SelectVictim should not error for valid operations") + require.NotNil(t, selected, "SelectVictim from non-empty queue should return an item") + assert.Equal(t, tailItem.RequestID(), selected.RequestID(), "SelectVictim should return the item from PeekTail") +} + +func TestTail_Properties(t *testing.T) { + t.Parallel() + policy := NewTail() + + assert.Equal(t, string(TailPreemptionPolicyName), policy.Name(), "Policy name should match constant") + expectedCaps := []types.QueueCapability{types.CapabilityDoubleEnded} + assert.ElementsMatch(t, expectedCaps, policy.RequiredQueueCapabilities(), "Required capabilities do not match") +} diff --git a/pkg/epp/flowcontroller/plugins/queue/README.md b/pkg/epp/flowcontroller/plugins/queue/README.md new file mode 100644 index 000000000..9af4258f4 --- /dev/null +++ b/pkg/epp/flowcontroller/plugins/queue/README.md @@ -0,0 +1,92 @@ +# FlowController Queue Plugins (`plugins/queue/`) + +This directory contains concrete implementations of the `types.SafeQueue` interface, which defines the contract for +core, self-contained queue data structures used by the FlowController. + +## Overview + +The FlowController manages requests by organizing them into queues. Each logical "flow" (e.g., representing a specific +model or workload) within a given priority band will have its own `types.ManagedQueue` instance, which wraps a +`types.SafeQueue`. This allows the FlowController to apply policies at both the inter-flow (across different flows) and +intra-flow (within a single flow's queue) levels. + +The `types.SafeQueue` interface abstracts the underlying data structure and its specific ordering or access mechanisms. +This pluggable design, in conjunction with the `types.ManagedQueue` wrapper and `types.FlowQueueAccessor` for policy +inspection, allows for: + +- **Different Queuing Disciplines**: While a basic FIFO (First-In, First-Out) queue (`ListQueue`) is provided as a + default, future needs or specific policies might require other disciplines (e.g., priority queues, LIFO queues). +- **Specialized Capabilities**: Policies can declare `RequiredQueueCapabilities()` (e.g., `types.CapabilityFIFO`, + `types.CapabilityPriorityConfigurable`, `types.CapabilityDoubleEnded`). The FlowController ensures that a policy is + paired with a queue implementation that provides the necessary capabilities. +- **Performance Optimization**: Different queue implementations might offer varying performance characteristics suitable + for different scales or workload patterns. +- **Future-Proofing**: This abstraction is key to potentially supporting more advanced in-memory queue structures (e.g., + min-max heaps for efficient double-ended priority queue operations) or even external, persistent, or distributed + queues (e.g., Redis-backed) for scenarios requiring higher availability or different operational models (like offline + batch processing or durable HA). + +## Contributing a New `SafeQueue` Implementation + +To contribute a new queue implementation: + +1. **Define Your Implementation**: + - Create a new Go file in this directory (e.g., `mycustomqueue.go`). + - Define a struct that will hold the state for your queue. This struct should include any necessary fields for + managing the queue's data and state (e.g., a slice or linked list for storing items, mutexes for synchronization). + - Implement all methods of the `types.SafeQueue` interface on your struct. This includes: + - `Add(item types.QueueItemAccessor) (uint64, uint64, error)` + - `Remove(handle types.QueueItemHandle) (types.QueueItemAccessor, uint64, uint64, error)` + - `CleanupExpired(currentTime time.Time, isItemExpired types.IsItemExpiredFunc) ([]types.ExpiredItemInfo, error)` + - And all methods from the embedded `types.QueueInspectionMethods`: + - `Len() int` + - `ByteSize() uint64` + - `Name() string` (return a unique name for your queue type) + - `Capabilities() []types.QueueCapability` (declare what your queue can do) + - `PeekHead() (types.QueueItemAccessor, error)` + - `PeekTail() (types.QueueItemAccessor, error)` + - You will also need to implement `types.QueueItemHandle` for the handles your queue issues (see `listqueue.go` + for an example with `listItemHandle`). + - Remember that all methods of `types.SafeQueue` (including those from the embedded `types.QueueInspectionMethods` + and the write/mutating methods) MUST be goroutine-safe for concurrent access with respect to the queue's own + internal data structures. + - If your queue declares `types.CapabilityPriorityConfigurable`, it MUST use the `types.ItemComparator` passed to + its constructor for ordering items. + - The `ManagedQueue` wrapper provided by the FlowRegistry will handle higher-level serialization for write + operations concerning FlowRegistry state and statistics. + +2. **Register Your Queue**: + - To make your queue discoverable by the system and automatically included in conformance tests, register it with + the central factory. This is typically done in an `init()` function within your queue's Go file (e.g., + `mycustomqueue.go`). + - Call `queue.RegisterQueue()` from [`plugins/queue/factory.go`](factory.go), passing your queue's unique name and a + constructor function. + - Define a `RegisteredQueueName` constant for your queue's name within your queue's Go file. This makes it easily + referenceable. + - Conformance tests in [`plugins/queue/conformance_test.go`](conformance_test.go) automatically iterate over all + queues registered with the factory, so your queue will be included in these checks once registered. + +3. **Testing**: + - **Conformance Tests**: The tests in [`plugins/queue/conformance_test.go`](conformance_test.go) are designed to + verify that any `SafeQueue` implementation correctly adheres to the contractual obligations of the + `types.SafeQueue` interface (e.g., behavior with empty queues, handle invalidation, state updates on + Add/Remove/CleanupExpired, and ordering based on the provided `ItemComparator`). If you register your queue with + the factory, it will automatically be covered by these tests. + - **Implementation-Specific Tests (If Necessary)**: If your queue has unique internal logic or behaviors not + directly covered by the `SafeQueue` interface (e.g., specific constructor validations, internal data structure + invariants not exposed via the interface), you can add a test file (e.g., `mycustomqueue_test.go`) for these. + However, for simple queues like `ListQueue`, the conformance tests are often sufficient. + +4. **Documentation**: + - Add GoDoc comments to your new queue type and its methods, explaining its behavior, any specific capabilities, and + its intended use cases. + +## Example Implementation + +Refer to: + +- [`listqueue.go`](listqueue.go): For an example of a FIFO queue based on `container/list`. +- [`conformance_test.go`](conformance_test.go): To understand the baseline behaviors tested for all queues. + +By following these steps, you can integrate new queueing mechanisms into the FlowController, enabling more sophisticated +and adaptable request management. diff --git a/pkg/epp/flowcontroller/plugins/queue/conformance_test.go b/pkg/epp/flowcontroller/plugins/queue/conformance_test.go new file mode 100644 index 000000000..975e8b23e --- /dev/null +++ b/pkg/epp/flowcontroller/plugins/queue/conformance_test.go @@ -0,0 +1,361 @@ +/* +Copyright 2025 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 queue + +import ( + "testing" + "time" + + "slices" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/plugins/testing/mocks" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/types" +) + +func TestQueue_Conformance(t *testing.T) { + t.Parallel() + for queueName, factory := range registeredQueues { + queueName := queueName + factory := factory + + t.Run(string(queueName), func(t *testing.T) { + t.Parallel() + flowSpec := mocks.NewMockFlowSpecification("test-flow-1", 0) + + // Define a specific ItemComparatorFunc based on enqueue time (First-Come, First-Served). + // This comparator is passed to each queue factory. + // For queues declaring CapabilityPriorityConfigurable, they MUST use this comparator for ordering. + // For queues with inherent FIFO behavior, this comparator aligns with their natural ordering. + // This allows the conformance tests to deterministically verify PeekHead/PeekTail's behavior against a known + // ordering principle for any arbitrary queue implementation. + comparator := mocks.NewMockItemComparator(func(a, b types.QueueItemAccessor) bool { + return a.EnqueueTime().Before(b.EnqueueTime()) + }, "enqueue_time_ns_asc") + + t.Run("NewQueueInitialization", func(t *testing.T) { + t.Parallel() + q, err := factory(comparator) + require.NoError(t, err, "Queue factory failed") + + require.NotNil(t, q, "Queue instance should not be nil") + assert.Equal(t, 0, q.Len(), "Queue length should be 0") + assert.Equal(t, uint64(0), q.ByteSize(), "Queue byte size should be 0") + assert.Equal(t, string(queueName), q.Name(), "Queue name should match expected") + assert.NotNil(t, q.Capabilities(), "Queue capabilities should not be nil") + }) + + t.Run("Lifecycle", func(t *testing.T) { + t.Parallel() + q, err := factory(comparator) + require.NoError(t, err, "Queue factory failed") + + hasCapabilityDoubleEnded := hasCapability(q, types.CapabilityDoubleEnded) + + now := time.Now() + item1Time := now.Add(-2 * time.Second) // Earliest + item2Time := now.Add(-1 * time.Second) // Middle + item3Time := now // Latest + + item1 := mocks.NewMockQueueItemAccessor("item1", flowSpec.ID(), 100, item1Time) + item2 := mocks.NewMockQueueItemAccessor("item2", flowSpec.ID(), 50, item2Time) + item3 := mocks.NewMockQueueItemAccessor("item3", flowSpec.ID(), 20, item3Time) + itemsInOrder := []types.QueueItemAccessor{item1, item2, item3} + + // PeekHead on empty queue + peeked, err := q.PeekHead() + assert.ErrorIs(t, err, types.ErrQueueEmpty, "PeekHead on empty queue should return ErrQueueEmpty") + assert.Nil(t, peeked, "PeekHead on empty queue should return nil item") + + // PeekTail on empty queue + if hasCapabilityDoubleEnded { + peeked, err := q.PeekTail() + assert.ErrorIs(t, err, types.ErrQueueEmpty, "PeekTail on empty queue should return ErrQueueEmpty") + assert.Nil(t, peeked, "PeekTail on empty queue should return nil item") + } + + // Add + currentExpectedLen := 0 + var currentExpectedByteSize uint64 + for i, item := range itemsInOrder { + newLen, newByteSize, err := q.Add(item) + require.NoError(t, err, "Failed to add item %s", item.RequestID()) + require.NotNil(t, item.Handle(), "Handle for item %s should not be nil after Add", item.RequestID()) + require.False(t, item.Handle().IsInvalidated(), + "Handle for item %s should not be invalidated after Add", item.RequestID()) + + currentExpectedLen++ + currentExpectedByteSize += item.ByteSize() + assert.Equal(t, uint64(currentExpectedLen), newLen, + "newLen after adding item %s (index %d)", item.RequestID(), i) + assert.Equal(t, currentExpectedByteSize, newByteSize, + "newByteSize after adding item %s (index %d)", item.RequestID(), i) + } + initialLen := len(itemsInOrder) + initialByteSize := item1.ByteSize() + item2.ByteSize() + item3.ByteSize() + assert.Equal(t, initialLen, q.Len(), "Queue length after adding all items") + assert.Equal(t, initialByteSize, q.ByteSize(), "Queue byte size after adding all items") + + // Peek and Remove cycle (verifying FCFS order due to provided comparator) + expectedLen := initialLen + expectedByteSize := initialByteSize + for i, expectedItem := range itemsInOrder { + t.Logf("Peek/Remove cycle for item: %s", expectedItem.RequestID()) + + // PeekHead + peeked, err := q.PeekHead() + require.NoError(t, err, "PeekHead should not error on non-empty queue (iteration %d)", i) + require.NotNil(t, peeked, "PeekHead should return a non-nil item (iteration %d)", i) + assert.Equal(t, expectedItem.RequestID(), peeked.RequestID(), + "PeekHead should return item %s (earliest enqueued)", expectedItem.RequestID()) + peekedHandle := peeked.Handle() + require.NotNil(t, peekedHandle, "Handle from peeked head item %s should not be nil", peeked.RequestID()) + require.False(t, peekedHandle.IsInvalidated(), + "Handle from peeked head item %s should not be invalidated", peeked.RequestID()) + assert.Equal(t, expectedLen, q.Len(), + "Queue length should be unchanged after PeekHead (item: %s, iteration %d)", expectedItem.RequestID(), i) + assert.Equal(t, expectedByteSize, q.ByteSize(), + "Queue byte size should be unchanged after PeekHead (item: %s, iteration %d)", expectedItem.RequestID(), i) + + // PeekTail + if hasCapabilityDoubleEnded { + peeked, err := q.PeekTail() + require.NoError(t, err, "PeekTail should not error on non-empty queue (iteration %d)", i) + require.NotNil(t, peeked, "PeekTail should return a non-nil item (iteration %d)", i) + assert.Equal(t, item3.RequestID(), peeked.RequestID(), + "PeekTail should return item %s (latest enqueued)", item3.RequestID()) + peekedHandle := peeked.Handle() + require.NotNil(t, peekedHandle, "Handle from peeked tail item %s should not be nil", peeked.RequestID()) + require.False(t, peekedHandle.IsInvalidated(), + "Handle from peeked tail item %s should not be invalidated", peeked.RequestID()) + assert.Equal(t, expectedLen, q.Len(), + "Queue length should be unchanged after PeekTail (item: %s, iteration %d)", expectedItem.RequestID(), i) + assert.Equal(t, expectedByteSize, q.ByteSize(), + "Queue byte size should be unchanged after PeekTail (item: %s, iteration %d)", + expectedItem.RequestID(), i) + } + + // Remove the peeked (head) item by its handle + removed, newLen, newByteSize, err := q.Remove(peekedHandle) + require.NoError(t, err, "Remove(peekedHandle) for item %s failed", peeked.RequestID()) + require.NotNil(t, removed, "Remove(peekedHandle) for item %s returned nil", peeked.RequestID()) + assert.Equal(t, expectedItem.RequestID(), removed.RequestID(), + "Removed item should be %s", expectedItem.RequestID()) + assert.True(t, peekedHandle.IsInvalidated(), + "Handle of removed item %s should be invalidated", removed.RequestID()) + + expectedLen-- + expectedByteSize -= removed.ByteSize() + assert.Equal(t, uint64(expectedLen), newLen, "newLen after removing %s", removed.RequestID()) + assert.Equal(t, expectedByteSize, newByteSize, "newByteSize after removing %s", removed.RequestID()) + assert.Equal(t, expectedLen, q.Len(), "Queue length after removing %s", removed.RequestID()) + assert.Equal(t, expectedByteSize, q.ByteSize(), "Queue byte size after removing %s", removed.RequestID()) + } + + assert.Equal(t, 0, q.Len(), "Queue should be empty after all items are removed") + assert.Equal(t, uint64(0), q.ByteSize(), "Queue byte size should be 0 after all items are removed") + + // PeekHead on empty queue again + peeked, err = q.PeekHead() + assert.ErrorIs(t, err, types.ErrQueueEmpty, "PeekHead on empty queue should return ErrQueueEmpty") + assert.Nil(t, peeked, "PeekHead on empty queue should return nil item") + + // PeekTail on empty queue again + if hasCapabilityDoubleEnded { + peeked, err := q.PeekTail() + assert.ErrorIs(t, err, types.ErrQueueEmpty, "PeekTail on empty queue should return ErrQueueEmpty") + assert.Nil(t, peeked, "PeekTail on empty queue should return nil item") + } + }) + + t.Run("Add_Nil", func(t *testing.T) { + t.Parallel() + q, err := factory(comparator) + require.NoError(t, err, "Queue factory failed") + + currentLen := q.Len() + currentByteSize := q.ByteSize() + newLen, newByteSize, err := q.Add(nil) + assert.ErrorIs(t, err, types.ErrNilQueueItem, "Add(nil) should return ErrNilQueueItem") + assert.Equal(t, uint64(currentLen), newLen, "Add(nil) should return current length") + assert.Equal(t, currentByteSize, newByteSize, "Add(nil) should return current byte size") + assert.Equal(t, currentLen, q.Len(), "Queue length should be unchanged after Add(nil)") + assert.Equal(t, currentByteSize, q.ByteSize(), "Queue byte size should be unchanged after Add(nil)") + }) + + t.Run("Remove_InvalidHandle", func(t *testing.T) { + t.Parallel() + q, err := factory(comparator) + require.NoError(t, err, "Queue factory failed") + + item := mocks.NewMockQueueItemAccessor("item", flowSpec.ID(), 100, time.Now()) + _, _, err = q.Add(item) + require.NoError(t, err, "Add item failed in Remove_InvalidHandle setup") + + for _, test := range []struct { + name string + setupHandle func() types.QueueItemHandle + }{ + { + name: "nil handle", + setupHandle: func() types.QueueItemHandle { return nil }, + }, + { + name: "invalidated handle", + setupHandle: func() types.QueueItemHandle { + mockHandle := mocks.NewMockQueueItemHandle(nil) + mockHandle.Invalidate() + return mockHandle + }, + }, + { + name: "alien handle", + setupHandle: func() types.QueueItemHandle { + otherFlowSpec := mocks.NewMockFlowSpecification("other-flow", 1) + otherQ, errFactoryOther := factory(comparator) + require.NoError(t, errFactoryOther, "Queue factory failed for otherQ") + otherItem := mocks.NewMockQueueItemAccessor("otherItem", otherFlowSpec.ID(), 10, time.Now()) + _, _, errAddOther := otherQ.Add(otherItem) + require.NoError(t, errAddOther) + otherHandle := otherItem.Handle() + require.NotNil(t, otherHandle) + return otherHandle + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + h := test.setupHandle() + currentLen := q.Len() + currentByteSize := q.ByteSize() + + _, newLen, newByteSize, err := q.Remove(h) + assert.ErrorIs(t, err, types.ErrInvalidQueueItemHandle, "Remove should return ErrInvalidQueueItemHandle") + assert.Equal(t, uint64(currentLen), newLen, "newLen after Remove with %s", test.name) + assert.Equal(t, currentByteSize, newByteSize, "newByteSize after Remove with %s", test.name) + assert.Equal(t, currentLen, q.Len(), "Queue length should be unchanged after Remove with %s", test.name) + assert.Equal(t, currentByteSize, q.ByteSize(), + "Queue byte size should be unchanged after Remove with %s", test.name) + }) + } + }) + + t.Run("Remove_NonHead", func(t *testing.T) { + t.Parallel() + q, err := factory(comparator) + require.NoError(t, err, "Queue factory failed") + + now := time.Now() + item1Time := now.Add(-2 * time.Second) // Earliest + item2Time := now.Add(-1 * time.Second) // Middle + item3Time := now // Latest + + item1 := mocks.NewMockQueueItemAccessor("item1", flowSpec.ID(), 10, item1Time) + item2 := mocks.NewMockQueueItemAccessor("item2", flowSpec.ID(), 20, item2Time) + item3 := mocks.NewMockQueueItemAccessor("item3", flowSpec.ID(), 30, item3Time) + _, _, _ = q.Add(item1) + _, _, _ = q.Add(item2) + _, _, _ = q.Add(item3) + handleNonHead := item2.Handle() + + removed, newLen, newByteSize, err := q.Remove(handleNonHead) + require.NoError(t, err, "Error removing non-head item item2") + require.NotNil(t, removed, "Removed item should not be nil when removing non-head item2") + assert.Equal(t, item2.RequestID(), removed.RequestID(), "Removed item ID should be item2's ID") + assert.True(t, handleNonHead.IsInvalidated(), "Handle for item2 should be invalidated after removal") + assert.Equal(t, uint64(2), newLen, "newLen should be 2 after removing item2") + assert.Equal(t, item1.ByteSize()+item3.ByteSize(), newByteSize, + "newByteSize should be sum of item1 and item3 after removing item2") + assert.Equal(t, item1.ByteSize()+item3.ByteSize(), q.ByteSize(), + "Queue ByteSize should be sum of item1 and item3 after removing item2") + assert.Equal(t, 2, q.Len(), "Queue Len should be 2 after removing item2") + + peeked, _ := q.PeekHead() + require.NotNil(t, peeked, "PeekHead should not return nil after removing a non-head item") + assert.Equal(t, item1.RequestID(), peeked.RequestID(), "PeekHead should still be item1 after removing item2") + + _, _, _, errStaleNonHead := q.Remove(handleNonHead) + assert.ErrorIs(t, errStaleNonHead, types.ErrInvalidQueueItemHandle) + }) + + t.Run("CleanupExpired", func(t *testing.T) { + t.Parallel() + q, err := factory(comparator) + require.NoError(t, err, "Queue factory failed") + now := time.Now() + + expireAfter := 7 * time.Second + item1 := mocks.NewMockQueueItemAccessor("item1", flowSpec.ID(), 10, now.Add(-10*time.Second)) // Expired + item2 := mocks.NewMockQueueItemAccessor("item2", flowSpec.ID(), 20, now.Add(-5*time.Second)) // Not expired + item3 := mocks.NewMockQueueItemAccessor("item3", flowSpec.ID(), 30, now) // Not expired + item4 := mocks.NewMockQueueItemAccessor("item4", flowSpec.ID(), 40, now.Add(-15*time.Second)) // Expired + + _, _, err = q.Add(item1) + require.NoError(t, err, "Failed to add item1 for CleanupExpired test") + handle1 := item1.Handle() + _, _, err = q.Add(item2) + require.NoError(t, err, "Failed to add item2 for CleanupExpired test") + handle2 := item2.Handle() + _, _, err = q.Add(item3) + require.NoError(t, err, "Failed to add item3 for CleanupExpired test") + handle3 := item3.Handle() + _, _, err = q.Add(item4) + require.NoError(t, err, "Failed to add item4 for CleanupExpired test") + handle4 := item4.Handle() + + isExpiredFunc := func(item types.QueueItemAccessor, currentTime time.Time) (bool, types.QueueOutcome, error) { + if currentTime.Sub(item.EnqueueTime()) > expireAfter { + return true, types.QueueOutcomeEvictedTTL, types.ErrTTLExpired + } + return false, types.QueueOutcomeDispatched, nil // Outcome and error are ignored if not expired + } + + removedInfos, errClean := q.CleanupExpired(now, isExpiredFunc) + require.NoError(t, errClean, "CleanupExpired should not return an error") + require.Len(t, removedInfos, 2, "Should remove 2 expired items (item1, item4)") + + assert.Equal(t, 2, q.Len()) // item2 and item3 remain + assert.Equal(t, item2.ByteSize()+item3.ByteSize(), q.ByteSize(), + "ByteSize should be sum of remaining items after CleanupExpired") + assert.True(t, handle1.IsInvalidated(), "Handle for item1 should be invalidated after CleanupExpired") + assert.False(t, handle2.IsInvalidated(), "Handle for item2 should NOT be invalidated") + assert.False(t, handle3.IsInvalidated(), "Handle for item3 should NOT be invalidated") + assert.True(t, handle4.IsInvalidated(), "Handle for item4 should be invalidated after CleanupExpired") + + removedIDsFound := make(map[string]bool) + for _, info := range removedInfos { + assert.Equal(t, types.QueueOutcomeEvictedTTL, info.Outcome, + "Outcome for expired item %s should be EvictedTTL", info.Item.RequestID()) + assert.ErrorIs(t, info.Error, types.ErrTTLExpired, + "Error for expired item %s should be ErrTTLExpired", info.Item.RequestID()) + removedIDsFound[info.Item.RequestID()] = true + itemHandle := info.Item.Handle() + require.NotNil(t, itemHandle, "Handle from ExpiredItemInfo.Item should not be nil") + assert.True(t, itemHandle.IsInvalidated(), + "Handle from ExpiredItemInfo.Item should be invalidated by CleanupExpired") + } + assert.True(t, removedIDsFound[item1.RequestID()], "item1 should be in removedItemsInfo") + assert.True(t, removedIDsFound[item4.RequestID()], "item4 should be in removedItemsInfo") + }) + }) + } +} + +func hasCapability(q types.SafeQueue, cap types.QueueCapability) bool { + return slices.Contains(q.Capabilities(), cap) +} diff --git a/pkg/epp/flowcontroller/plugins/queue/factory.go b/pkg/epp/flowcontroller/plugins/queue/factory.go new file mode 100644 index 000000000..dcbfcfab0 --- /dev/null +++ b/pkg/epp/flowcontroller/plugins/queue/factory.go @@ -0,0 +1,64 @@ +/* +Copyright 2025 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 queue defines interfaces and implementations for various queue data structures used by the FlowController. +package queue + +import ( + "fmt" + "sync" + + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/types" +) + +type RegisteredQueueName string + +// QueueConstructor defines the function signature for creating a SafeQueue. +// It accepts the ItemComparator that will be optionally used to configure this queue (provided it declares +// CapabilityPriorityConfigurable). +type QueueConstructor func(policyDefinedOrder types.ItemComparator) (types.SafeQueue, error) + +var ( + // mu guards the registration maps. + mu sync.RWMutex + // registeredQueues stores the constructors for all registered queues. + registeredQueues = make(map[RegisteredQueueName]QueueConstructor) +) + +// RegisterQueue registers a new SafeQueue constructor. +// This function is called by queue implementations in their init() function. +func RegisterQueue(name RegisteredQueueName, constructor QueueConstructor) { + mu.Lock() + defer mu.Unlock() + if _, ok := registeredQueues[name]; ok { + panic(fmt.Sprintf("SafeQueue named %s already registered", name)) + } + registeredQueues[name] = constructor +} + +// NewQueueFromName creates a new SafeQueue given its registered name, its specification, and the ItemComparator that +// will be optionally used to configure the queue (provided it declares CapabilityPriorityConfigurable). +// This is called by the FlowRegistry during initialization of a flow's ManagedQueue. +func NewQueueFromName(name RegisteredQueueName, policyDefinedOrder types.ItemComparator) (types.SafeQueue, error) { + mu.RLock() + defer mu.RUnlock() + + constructor, ok := registeredQueues[name] + if !ok { + return nil, fmt.Errorf("no SafeQueue registered with name %q", name) + } + return constructor(policyDefinedOrder) +} diff --git a/pkg/epp/flowcontroller/plugins/queue/listqueue.go b/pkg/epp/flowcontroller/plugins/queue/listqueue.go new file mode 100644 index 000000000..a3a6f72db --- /dev/null +++ b/pkg/epp/flowcontroller/plugins/queue/listqueue.go @@ -0,0 +1,228 @@ +/* +Copyright 2025 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 queue + +import ( + "container/list" + "fmt" + "sync" + "sync/atomic" + "time" + + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/types" +) + +const ListQueueName = "ListQueue" + +func init() { + RegisterQueue(ListQueueName, func(_ types.ItemComparator) (types.SafeQueue, error) { + return NewListQueue(), nil + }) +} + +// ListQueue implements the types.SafeQueue interface using a standard container/list.List for FIFO (First-In, +// First-Out) behavior. +type ListQueue struct { + requests *list.List + byteSize atomic.Uint64 + mu sync.RWMutex +} + +// listItemHandle is the concrete type for types.QueueItemHandle used by ListQueue. +// It wraps the list.Element and includes a pointer to the owning ListQueue for validation, ensuring that a handle from +// one queue instance cannot be used to operate on another. +// It also tracks its invalidation state. +type listItemHandle struct { + element *list.Element // The actual element in the container/list + owner *ListQueue // Pointer to the ListQueue instance that owns this handle + isInvalidated bool + mu sync.Mutex +} + +// Handle returns the underlying queue-specific raw handle, which is the *list.Element. +func (lh *listItemHandle) Handle() any { + return lh.element +} + +// Invalidate marks this handle instance as no longer valid for future operations. +// It is idempotent. +func (lh *listItemHandle) Invalidate() { + lh.mu.Lock() + defer lh.mu.Unlock() + lh.isInvalidated = true +} + +// IsInvalidated returns true if this handle instance has been marked as invalid. +func (lh *listItemHandle) IsInvalidated() bool { + lh.mu.Lock() + defer lh.mu.Unlock() + return lh.isInvalidated +} + +var _ types.QueueItemHandle = &listItemHandle{} // Compile-time validation + +// NewListQueue creates a new ListQueue. +func NewListQueue() *ListQueue { + return &ListQueue{ + requests: list.New(), + } +} + +// --- SafeQueue Interface Implementation --- + +// Add attempts to enqueue an item to the back of the list. +func (lq *ListQueue) Add(item types.QueueItemAccessor) (newLen uint64, newByteSize uint64, err error) { + lq.mu.Lock() + defer lq.mu.Unlock() + + if item == nil { + return uint64(lq.requests.Len()), lq.byteSize.Load(), types.ErrNilQueueItem + } + + element := lq.requests.PushBack(item) + lq.byteSize.Add(item.ByteSize()) + item.SetHandle(&listItemHandle{element: element, owner: lq}) + return uint64(lq.requests.Len()), lq.byteSize.Load(), nil +} + +// Remove removes and returns the QueueItemAccessor for the item identified by the given handle. +func (lq *ListQueue) Remove( + handle types.QueueItemHandle, +) (removedItem types.QueueItemAccessor, newLen uint64, newByteSize uint64, err error) { + lq.mu.Lock() + defer lq.mu.Unlock() + + if handle == nil { + return nil, uint64(lq.requests.Len()), lq.byteSize.Load(), types.ErrInvalidQueueItemHandle + } + + if handle.IsInvalidated() { + return nil, uint64(lq.requests.Len()), lq.byteSize.Load(), + fmt.Errorf("%w: provided handle is already marked as invalidated", types.ErrInvalidQueueItemHandle) + } + + lh, ok := handle.(*listItemHandle) + if !ok { + return nil, uint64(lq.requests.Len()), lq.byteSize.Load(), + fmt.Errorf("%w: expected *listItemHandle, got %T", types.ErrInvalidQueueItemHandle, handle) + } + + if lh.owner != lq { + return nil, uint64(lq.requests.Len()), lq.byteSize.Load(), + fmt.Errorf("%w: handle owner mismatch, invalid for this ListQueue instance", types.ErrInvalidQueueItemHandle) + } + + if lh.element == nil { + // This case implies the handle itself is malformed. + // Since IsInvalidated() was false, this indicates an inconsistent or improperly managed handle state. + handle.Invalidate() // Mark it as invalid now + return nil, uint64(lq.requests.Len()), lq.byteSize.Load(), + fmt.Errorf("%w: handle's internal element is nil", types.ErrInvalidQueueItemHandle) + } + + item := lh.element.Value.(types.QueueItemAccessor) + lq.requests.Remove(lh.element) + lq.byteSize.Add(^(item.ByteSize() - 1)) + handle.Invalidate() + return item, uint64(lq.requests.Len()), lq.byteSize.Load(), nil +} + +// CleanupExpired iterates through items, using isItemExpired to check each one. +// If an item is expired, it is removed. The ExpiredItemInfo will contain the QueueItemAccessor of the removed item. +// Any QueueItemHandle associated with a removed item should be considered invalidated. +func (lq *ListQueue) CleanupExpired( + currentTime time.Time, + isItemExpired types.IsItemExpiredFunc, +) ([]types.ExpiredItemInfo, error) { + lq.mu.Lock() + defer lq.mu.Unlock() + + var removedItemsInfo []types.ExpiredItemInfo + var next *list.Element + + for e := lq.requests.Front(); e != nil; e = next { + next = e.Next() // Get next element before potentially removing current 'e' + + item := e.Value.(types.QueueItemAccessor) + expired, outcome, errForExpiry := isItemExpired(item, currentTime) + if expired { + lq.requests.Remove(e) + lq.byteSize.Add(^(item.ByteSize() - 1)) + if itemHandle := item.Handle(); itemHandle != nil { + itemHandle.Invalidate() + } + + removedItemsInfo = append(removedItemsInfo, types.ExpiredItemInfo{ + Item: item, + Outcome: outcome, + Error: errForExpiry, + }) + } + } + return removedItemsInfo, nil +} + +// --- SafeQueue Interface: QueueInspectionMethods Implementation --- + +// Len returns the current number of items in the queue. +func (lq *ListQueue) Len() int { + lq.mu.RLock() + defer lq.mu.RUnlock() + return lq.requests.Len() +} + +// ByteSize returns the current total byte size of all items in the queue. +func (lq *ListQueue) ByteSize() uint64 { + return lq.byteSize.Load() +} + +// Name returns a string identifier for this type of concrete queue implementation. +func (lq *ListQueue) Name() string { + return ListQueueName +} + +// Capabilities returns the set of capabilities this queue instance provides. +func (lq *ListQueue) Capabilities() []types.QueueCapability { + // container/list supports efficient Front and Back operations, so it can be considered DoubleEnded for peeking. + return []types.QueueCapability{types.CapabilityFIFO, types.CapabilityDoubleEnded} +} + +// PeekHead returns a QueueItemAccessor for the item at the front of the list, without removing it. +func (lq *ListQueue) PeekHead() (types.QueueItemAccessor, error) { + lq.mu.RLock() + defer lq.mu.RUnlock() + + if lq.requests.Len() == 0 { + return nil, types.ErrQueueEmpty + } + element := lq.requests.Front() + return element.Value.(types.QueueItemAccessor), nil +} + +// PeekTail returns a QueueItemAccessor for the item at the back of the list, without removing it. +func (lq *ListQueue) PeekTail() (types.QueueItemAccessor, error) { + lq.mu.RLock() + defer lq.mu.RUnlock() + + if lq.requests.Len() == 0 { + return nil, types.ErrQueueEmpty + } + element := lq.requests.Back() + return element.Value.(types.QueueItemAccessor), nil +} + +var _ types.SafeQueue = &ListQueue{} // Compile-time validation diff --git a/pkg/epp/flowcontroller/plugins/testing/mocks/mocks.go b/pkg/epp/flowcontroller/plugins/testing/mocks/mocks.go new file mode 100644 index 000000000..d7b1777e5 --- /dev/null +++ b/pkg/epp/flowcontroller/plugins/testing/mocks/mocks.go @@ -0,0 +1,298 @@ +/* +Copyright 2025 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 mocks provides shared mock implementations of core flowcontroller interfaces for use in plugin testing. +package mocks + +import ( + "context" + "sort" + "sync" + "time" + + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/flowcontroller/types" +) + +// --- MockFlowSpecification --- + +type MockFlowSpecification struct { + MockID string + MockPriority uint +} + +var _ types.FlowSpecification = &MockFlowSpecification{} + +func NewMockFlowSpecification(id string, priority uint) *MockFlowSpecification { + return &MockFlowSpecification{id, priority} +} + +func (m *MockFlowSpecification) ID() string { return m.MockID } +func (m *MockFlowSpecification) Priority() uint { return m.MockPriority } + +// --- MockFlowControlRequest --- + +type MockFlowControlRequest struct { + MockCtx context.Context + MockFlowIDVal string + MockSizeVal uint64 + MockInitialEffectiveTTL time.Duration + MockIDVal string +} + +var _ types.FlowControlRequest = &MockFlowControlRequest{} + +func NewMockFlowControlRequest(reqID, flowID string, size uint64, ttl time.Duration) *MockFlowControlRequest { + return &MockFlowControlRequest{ + MockCtx: context.Background(), // Default, can be overridden + MockIDVal: reqID, + MockFlowIDVal: flowID, + MockSizeVal: size, + MockInitialEffectiveTTL: ttl, + } +} + +func (m *MockFlowControlRequest) Context() context.Context { return m.MockCtx } +func (m *MockFlowControlRequest) ID() string { return m.MockIDVal } +func (m *MockFlowControlRequest) FlowID() string { return m.MockFlowIDVal } +func (m *MockFlowControlRequest) ByteSize() uint64 { return m.MockSizeVal } + +func (m *MockFlowControlRequest) InitialEffectiveTTL() time.Duration { + return m.MockInitialEffectiveTTL +} + +// --- MockQueueItemHandle --- + +type MockQueueItemHandle struct { + RawHandle any + Invalidated bool + invalidateLock sync.Mutex +} + +var _ types.QueueItemHandle = &MockQueueItemHandle{} + +func NewMockQueueItemHandle(raw any) *MockQueueItemHandle { + return &MockQueueItemHandle{RawHandle: raw} +} + +func (m *MockQueueItemHandle) Handle() any { + return m.RawHandle +} + +func (m *MockQueueItemHandle) Invalidate() { + m.invalidateLock.Lock() + defer m.invalidateLock.Unlock() + m.Invalidated = true +} + +func (m *MockQueueItemHandle) IsInvalidated() bool { + m.invalidateLock.Lock() + defer m.invalidateLock.Unlock() + return m.Invalidated +} + +// --- MockQueueItemAccessor --- + +type MockQueueItemAccessor struct { + MockEnqueueTimeVal time.Time + MockSizeVal uint64 + MockFlowIDVal string + MockEffectiveTTLVal time.Duration + MockRequestIDVal string + MockOriginalRequestVal types.FlowControlRequest + MockHandleVal types.QueueItemHandle +} + +var _ types.QueueItemAccessor = &MockQueueItemAccessor{} + +func NewMockQueueItemAccessor(reqID, flowID string, size uint64, enqueueTime time.Time) *MockQueueItemAccessor { + if flowID == "" { + flowID = "default-flow" + } + return &MockQueueItemAccessor{ + MockRequestIDVal: reqID, + MockFlowIDVal: flowID, + MockSizeVal: size, + MockEnqueueTimeVal: enqueueTime, + MockOriginalRequestVal: NewMockFlowControlRequest(reqID, flowID, size, 0), // Basic mock original request + MockHandleVal: NewMockQueueItemHandle(nil), // Basic mock handle + } +} + +func (m *MockQueueItemAccessor) EnqueueTime() time.Time { return m.MockEnqueueTimeVal } +func (m *MockQueueItemAccessor) ByteSize() uint64 { return m.MockSizeVal } +func (m *MockQueueItemAccessor) FlowID() string { return m.MockFlowIDVal } +func (m *MockQueueItemAccessor) EffectiveTTL() time.Duration { return m.MockEffectiveTTLVal } +func (m *MockQueueItemAccessor) RequestID() string { return m.MockRequestIDVal } +func (m *MockQueueItemAccessor) Handle() types.QueueItemHandle { return m.MockHandleVal } +func (m *MockQueueItemAccessor) SetHandle(h types.QueueItemHandle) { m.MockHandleVal = h } + +func (m *MockQueueItemAccessor) OriginalRequest() types.FlowControlRequest { + return m.MockOriginalRequestVal +} + +// --- MockItemComparator --- +type MockItemComparator struct { + MockFunc types.ItemComparatorFunc + MockScoreType string +} + +func NewMockItemComparator(f types.ItemComparatorFunc, scoreType string) *MockItemComparator { + if f == nil { + f = func(a, b types.QueueItemAccessor) bool { return a.EnqueueTime().Before(b.EnqueueTime()) } // Default FCFS + } + if scoreType == "" { + scoreType = "mock_fcfs_enqueue_time_ns_asc" // Default + } + return &MockItemComparator{MockFunc: f, MockScoreType: scoreType} +} + +var _ types.ItemComparator = &MockItemComparator{} + +func (m *MockItemComparator) Func() types.ItemComparatorFunc { return m.MockFunc } +func (m *MockItemComparator) ScoreType() string { return m.MockScoreType } + +// --- MockFlowQueueAccessor --- + +type MockFlowQueueAccessor struct { + MockLenVal int + MockByteSizeVal uint64 + MockNameVal string + MockComparatorVal types.ItemComparator + MockFlowSpecVal types.FlowSpecification + MockCapabilitiesVal []types.QueueCapability + MockPeekHeadItemVal types.QueueItemAccessor + MockPeekHeadErrorVal error + MockPeekTailItemVal types.QueueItemAccessor + MockPeekTailErrorVal error +} + +var _ types.FlowQueueAccessor = &MockFlowQueueAccessor{} + +func NewMockFlowQueueAccessor( + flowSpec types.FlowSpecification, + name string, + capabilities []types.QueueCapability, + comparator types.ItemComparator, +) *MockFlowQueueAccessor { + if flowSpec == nil { + flowSpec = NewMockFlowSpecification("default-flow-for-queue", 0) + } + return &MockFlowQueueAccessor{ + MockFlowSpecVal: flowSpec, + MockComparatorVal: comparator, + MockNameVal: name, + MockCapabilitiesVal: capabilities, + } +} + +func (m *MockFlowQueueAccessor) Len() int { return m.MockLenVal } +func (m *MockFlowQueueAccessor) ByteSize() uint64 { return m.MockByteSizeVal } +func (m *MockFlowQueueAccessor) Name() string { return m.MockNameVal } +func (m *MockFlowQueueAccessor) FlowSpec() types.FlowSpecification { return m.MockFlowSpecVal } +func (m *MockFlowQueueAccessor) Capabilities() []types.QueueCapability { return m.MockCapabilitiesVal } + +func (m *MockFlowQueueAccessor) PeekHead() (types.QueueItemAccessor, error) { + if m.MockPeekHeadErrorVal != nil { + return nil, m.MockPeekHeadErrorVal + } + if m.MockLenVal == 0 && m.MockPeekHeadItemVal == nil { + return nil, types.ErrQueueEmpty + } + return m.MockPeekHeadItemVal, nil +} + +func (m *MockFlowQueueAccessor) PeekTail() (types.QueueItemAccessor, error) { + if m.MockPeekTailErrorVal != nil { + return nil, m.MockPeekTailErrorVal + } + if m.MockLenVal == 0 && m.MockPeekTailItemVal == nil { + return nil, types.ErrQueueEmpty + } + return m.MockPeekTailItemVal, nil +} + +func (m *MockFlowQueueAccessor) Comparator() types.ItemComparator { + if m.MockComparatorVal != nil { + return m.MockComparatorVal + } + // Return a default comparator if none was provided during construction. + return NewMockItemComparator(nil, "") +} + +// --- MockPriorityBandAccessor --- + +type MockPriorityBandAccessor struct { + MockPriorityVal uint + MockPriorityNameVal string + MockCapacityBytes uint64 + MockQueues map[string]types.FlowQueueAccessor // flowID -> FlowQueueAccessor + MockFlowIDsInOrder []string // Controls iteration order for IterateQueues +} + +var _ types.PriorityBandAccessor = &MockPriorityBandAccessor{} + +func NewMockPriorityBandAccessor( + priority uint, + priorityName string, + capacityBytes uint64, + queues map[string]types.FlowQueueAccessor, + flowOrder []string, +) *MockPriorityBandAccessor { + return &MockPriorityBandAccessor{ + MockPriorityVal: priority, + MockPriorityNameVal: priorityName, + MockQueues: queues, + MockFlowIDsInOrder: flowOrder, + } +} + +func (m *MockPriorityBandAccessor) Priority() uint { return m.MockPriorityVal } +func (m *MockPriorityBandAccessor) PriorityName() string { return m.MockPriorityNameVal } +func (m *MockPriorityBandAccessor) CapacityBytes() uint64 { return m.MockCapacityBytes } + +func (m *MockPriorityBandAccessor) FlowIDs() []string { + if len(m.MockFlowIDsInOrder) > 0 { + // Return a copy to prevent modification. + ids := make([]string, len(m.MockFlowIDsInOrder)) + copy(ids, m.MockFlowIDsInOrder) + return ids + } + // Fallback if order not specified for mock, iterate map (order undefined by Go spec but sort for predictability). + ids := make([]string, 0, len(m.MockQueues)) + for id := range m.MockQueues { + ids = append(ids, id) + } + sort.Strings(ids) + return ids + +} +func (m *MockPriorityBandAccessor) Queue(flowID string) types.FlowQueueAccessor { + return m.MockQueues[flowID] +} + +func (m *MockPriorityBandAccessor) IterateQueues(callback func(q types.FlowQueueAccessor) bool) { + iterOrder := m.MockFlowIDsInOrder + if len(iterOrder) == 0 { // Fallback if order not specified for mock + iterOrder = m.FlowIDs() // This will give sorted keys + } + for _, flowID := range iterOrder { + if q, ok := m.MockQueues[flowID]; ok { + if !callback(q) { + return + } + } + } +} diff --git a/pkg/epp/flowcontroller/types/errors.go b/pkg/epp/flowcontroller/types/errors.go new file mode 100644 index 000000000..594770ba7 --- /dev/null +++ b/pkg/epp/flowcontroller/types/errors.go @@ -0,0 +1,135 @@ +/* +Copyright 2025 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 types + +import ( + "errors" +) + +// --- Standard Runtime Sentinel Errors --- + +// ErrRejected is a generic error indicating a request was rejected by the FlowController *before* being formally +// enqueued into a SafeQueue. +// It is typically paired with a specific QueueOutcome (e.g., QueueOutcomeRejectedCapacity, QueueOutcomeRejectedOther). +// Errors returned by FlowController.EnqueueAndWait() that signify pre-queue rejection will wrap this error. +var ErrRejected = errors.New("request rejected pre-queue") + +// ErrEvicted is a generic error indicating a request was removed from a FlowController-managed queue after being +// successfully enqueued, but for reasons other than successful dispatch (e.g., TTL expiry, preemption, context +// cancellation, shutdown). +// It is typically paired with a specific QueueOutcome. Errors returned by FlowController.EnqueueAndWait() that signify +// post-queue eviction will wrap this error. +var ErrEvicted = errors.New("request evicted from queue") + +// PreEnqueueRejectionErrors are errors that can occur before a request is formally added to a SafeQueue. +// When returned by FlowController.EnqueueAndWait(), these specific errors will typically be wrapped by ErrRejected. +var ( + // ErrNilRequest indicates that a nil types.FlowControlRequest was provided. + ErrNilRequest = errors.New("FlowControlRequest cannot be nil") + + // ErrFlowIDEmpty indicates that a flow ID was empty when one was required. + ErrFlowIDEmpty = errors.New("flow ID cannot be empty") + + // ErrQueueAtCapacity indicates that a request could not be enqueued because queue capacity limits were met and + // preemption (if applicable) failed to make space. + ErrQueueAtCapacity = errors.New("queue at capacity and preemption failed to make space") + + // ErrFlowNotRegistered indicates that the flow ID provided in a request is not registered or has no active instance + // in the FlowRegistry. + // (This error is also used by FlowRegistry methods directly). + ErrFlowNotRegistered = errors.New("flow not registered or no active instance") +) + +// PostEnqueueEvictionErrors are errors that occur when a request, already in a SafeQueue, is removed for reasons other +// than dispatch. +// When returned by FlowController.EnqueueAndWait(), these specific errors will typically be wrapped by ErrEvicted. +var ( + // ErrTTLExpired indicates a request was evicted from a queue because its effective Time-To-Live expired. + ErrTTLExpired = errors.New("request TTL expired") + + // ErrContextCancelled indicates a request was evicted from a queue because its associated context (from + // FlowControlRequest.Context()) was cancelled. + // This error will often wrap the specific context error (context.Canceled or context.DeadlineExceeded). + ErrContextCancelled = errors.New("request context cancelled") + + // ErrPreempted indicates a request was evicted from a queue because it was chosen as a victim by a preemption policy + // to make space for another request. + ErrPreempted = errors.New("request preempted") +) + +// FlowRegistryErrors relate to operations on the FlowRegistry, such as flow registration, updates, or lookups. These +// are typically returned directly by FlowRegistry methods. Some (like ErrFlowNotRegistered or ErrInvalidFlowPriority) +// might also be wrapped by ErrRejected if they cause FlowController.EnqueueAndWait() to fail. +var ( + // ErrInvalidFlowPriority indicates that a flow priority value provided during flow registration or lookup is not + // recognized or supported by the FlowRegistry's configuration. + ErrInvalidFlowPriority = errors.New("invalid or unconfigured priority level for flow") + + // ErrFlowInstanceNotFound indicates that a specific instance of a flow (e.g., a flow at a particular priority) was + // not found by a FlowRegistry lookup. + ErrFlowInstanceNotFound = errors.New("specific flow instance not found in registry") + + // ErrPriorityBandNotFound indicates that an operation targeted a priority band that is not configured in the + // FlowRegistry. + ErrPriorityBandNotFound = errors.New("priority band not configured") +) + +// SafeQueueErrors relate to operations directly on a SafeQueue implementation. +// These are typically returned by SafeQueue methods and might be handled or wrapped by the ManagedQueue or +// FlowController. +var ( + // ErrQueueEmpty indicates an attempt to operate on an empty SafeQueue in a way that requires items (e.g., + // SafeQueue.PeekHead()). + ErrQueueEmpty = errors.New("queue is empty") + + // ErrQueueItemNotFound indicates that a SafeQueue.Remove(handle) operation did not find an item matching the provided + // valid QueueItemHandle. + ErrQueueItemNotFound = errors.New("queue item not found for the given handle") + + // ErrNilQueueItem indicates that a nil types.QueueItemAccessor was passed to SafeQueue.Add(). + ErrNilQueueItem = errors.New("queue item cannot be nil") + + // ErrInvalidQueueItemHandle indicates that a QueueItemHandle provided to a SafeQueue operation + // (like SafeQueue.Remove()) is not valid for that queue or operation. + ErrInvalidQueueItemHandle = errors.New("invalid queue item handle") + + // ErrOperationNotSupported indicates that an operation (e.g., SafeQueue.PeekTail()) was called on a SafeQueue + // implementation that does not support it. + ErrOperationNotSupported = errors.New("operation not supported by this queue type") +) + +// PolicyErrors relate to issues encountered by or with policy plugins. +// These are typically returned by policy methods and handled or wrapped by the FlowController. +var ( + // ErrIncompatiblePriorityType may be returned by policy implementations (e.g., InterFlowDispatchPolicy) if they + // attempt to compare items from different queues whose configured ItemComparators have different or incompatible + // ScoreTypes, and the policy cannot reconcile them. + ErrIncompatiblePriorityType = errors.New("incompatible item comparator ScoreTypes for comparison by policy") + + // ErrPolicyQueueMismatch is primarily a setup or validation error if the FlowRegistry attempts to associate a policy + // with a SafeQueue whose capabilities do not meet the policy's RequiredQueueCapabilities(). + ErrPolicyQueueMismatch = errors.New("policy requirements incompatible with configured queue capabilities") +) + +// GeneralFlowControllerErrors are general runtime errors for the FlowController. +var ( + // ErrFlowControllerShutdown indicates that an operation could not complete or an item was evicted because the + // FlowController is shutting down or has stopped. + // When returned by FlowController.EnqueueAndWait(), this will be wrapped by ErrRejected (if rejection happens before + // internal queuing) or ErrEvicted (if eviction happens after internal queuing). + ErrFlowControllerShutdown = errors.New("FlowController is shutting down") +) diff --git a/pkg/epp/flowcontroller/types/flow.go b/pkg/epp/flowcontroller/types/flow.go new file mode 100644 index 000000000..477383c1d --- /dev/null +++ b/pkg/epp/flowcontroller/types/flow.go @@ -0,0 +1,173 @@ +/* +Copyright 2025 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 types defines the core interfaces, data structures, behavioral contracts, and standard error/outcome types +// for the FlowController system. +package types + +// FlowSpecification defines the properties of a logical flow relevant for flow control, primarily its identity and +// registered priority. +// Instances are typically managed by the FlowRegistry. + +type FlowSpecification interface { + // ID returns the unique name or identifier for this flow (e.g., model name, tenant ID). + // This corresponds to the value from FlowControlRequest.FlowID(). + ID() string + // Priority returns the numerical priority level currently associated with this flow within the FlowRegistry. + // Convention: Lower numerical values indicate higher priority. + Priority() uint +} + +// FlowRegistry defines the interface for managing the lifecycle of flows, their associated ManagedQueues, policies, and +// aggregated statistics. +// It acts as the central control plane for flow definitions and provides the FlowController with access to the +// necessary components for request processing. +// +// Conformance: +// - All methods defined in this interface MUST be goroutine-safe, as the FlowRegistry may be accessed concurrently by +// the FlowController's dispatch/enqueue loops and by external configuration mechanisms. +type FlowRegistry interface { + // RegisterOrUpdateFlow handles the registration of a new flow or the update of an existing flow's specification + // (e.g., a change in its priority). + // + // If a flow's priority changes, its current active ManagedQueue instance is marked as inactive to drain existing + // requests, and a new ManagedQueue instance is activated (or created) at the new priority level. Subsequent requests + // for the flow are directed to this new active instance. + // The old, inactive instance is cleaned up by the registry once its queue is empty (typically signaled internally by + // its ManagedQueue wrapper when it empties). + // + // Returns: + // - nil on successful registration or update. + // - An error wrapping types.ErrFlowIDEmpty if spec.ID() is empty. + // - An error wrapping types.ErrInvalidFlowPriority if spec.Priority() refers to a priority level not configured in + // the registry. + // - Other errors if internal creation/activation of policies or queues fails (e.g., due to plugin factory errors or + // capability mismatches), in which case the registration or update will not complete. + RegisterOrUpdateFlow(spec FlowSpecification) error + // UnregisterFlow marks a flow as inactive across all its instances. + // Its associated resources (ManagedQueues and underlying SafeQueues) will be cleaned up by the registry once they are + // empty. New requests for this flow will be rejected by the FlowController (as ActiveManagedQueue will fail). + // + // Returns: + // - nil on successful marking for unregistration. + // - An error wrapping types.ErrFlowIDEmpty if flowID is empty. + // - An error wrapping types.ErrFlowNotRegistered if the flowID is not found or was already fully + // unregistered/cleaned up. + UnregisterFlow(flowID string) error + // ActiveManagedQueue returns the currently active ManagedQueue for the given flowID. + // This is the queue to which new requests for this flow should be enqueued by the FlowController. The returned + // ManagedQueue provides concurrency-safe operations that are validated against the flow's lifecycle in the registry + // and integrate with statistics updates. + // + // Returns: + // - (ManagedQueue, nil) on success. + // - (nil, error wrapping types.ErrFlowNotRegistered) if no active instance for flowID is found (e.g., flow not + // registered or currently inactive/draining). + ActiveManagedQueue(flowID string) (ManagedQueue, error) + // ManagedQueue retrieves a specific flow instance's ManagedQueue, by flowID and priority. This is critical for + // accessing queues that are inactive or draining (e.g., after a flow's priority changes or it's unregistered) to + // allow the FlowController to continue dispatching their remaining items. + // + // Returns: + // - (ManagedQueue, nil) on success. + // - (nil, error wrapping types.ErrFlowInstanceNotFound) if no instance for the given flowID and priority exists. + ManagedQueue(flowID string, priority uint) (ManagedQueue, error) + // IntraFlowDispatchPolicy retrieves a specific flow instance's configured IntraFlowDispatchPolicy. The registry + // guarantees a policy is always returned (defaulting if necessary) if the flow instance itself exists. + // + // Returns: + // - (IntraFlowDispatchPolicy, nil) on success. + // - (nil, error wrapping types.ErrFlowInstanceNotFound) if no instance for the given flowID and priority exists. + IntraFlowDispatchPolicy(flowID string, priority uint) (IntraFlowDispatchPolicy, error) + // IntraFlowPreemptionPolicy retrieves a specific flow instance's configured IntraFlowPreemptionPolicy. The registry + // guarantees a policy is always returned (defaulting if necessary) if the flow instance itself exists. + // + // Returns: + // - (IntraFlowPreemptionPolicy, nil) on success. + // - (nil, error wrapping types.ErrFlowInstanceNotFound) if no instance for the given flowID and priority exists. + IntraFlowPreemptionPolicy(flowID string, priority uint) (IntraFlowPreemptionPolicy, error) + // InterFlowDispatchPolicy retrieves a priority band's configured InterFlowDispatchPolicy. The registry guarantees a + // policy is always returned (defaulting if necessary) if the priority band itself exists. + // + // Returns: + // - (InterFlowDispatchPolicy, nil) on success. + // - (nil, error wrapping types.ErrPriorityBandNotFound) if the specified priority level is not a configured band. + InterFlowDispatchPolicy(priority uint) (InterFlowDispatchPolicy, error) + // InterFlowPreemptionPolicy retrieves a priority band's configured InterFlowPreemptionPolicy. The registry guarantees + // a policy is always returned (defaulting if necessary) if the priority band itself exists. + // + // Returns: + // - (InterFlowPreemptionPolicy, nil) on success. + // - (nil, error wrapping types.ErrPriorityBandNotFound) if the specified priority level is not a configured band. + InterFlowPreemptionPolicy(priority uint) (InterFlowPreemptionPolicy, error) + // PriorityBandAccessor retrieves a PriorityBandAccessor for a given priority level, allowing inter-flow policies to + // inspect the state of that band. + // + // Returns: + // - (PriorityBandAccessor, nil) on success. + // - (nil, error wrapping types.ErrPriorityBandNotFound) if the specified priority level is not a configured band. + PriorityBandAccessor(priority uint) (PriorityBandAccessor, error) + // AllOrderedPriorityLevels returns all configured priority levels, sorted from highest to lowest priority. + // Convention: Lower numerical value means higher priority (e.g., 0 is highest). + // The returned slice is sorted in ascending numerical order. + AllOrderedPriorityLevels() []uint + // GetStats returns aggregated statistics for the FlowRegistry, including global and per-priority-band metrics for + // queue lengths and byte sizes. + // These statistics are updated atomically by ManagedQueue operations. + GetStats() FlowRegistryStats +} + +// FlowRegistryStats holds aggregated statistics for the entire FlowRegistry. +type FlowRegistryStats struct { + GlobalByteSize uint64 + GlobalLen uint64 + PerPriorityBandStats map[uint]PriorityBandStats // Keyed by priority level +} + +// PriorityBandStats holds aggregated statistics for a single priority band. +type PriorityBandStats struct { + PriorityLevel uint + PriorityName string + ByteSize uint64 // Total byte size of items in this band. + Len uint64 // Total number of items in this band. +} + +// PriorityBandAccessor provides a read-only view into a specific priority band within the FlowRegistry. It allows +// inter-flow policies to inspect the state of all flow queues within that band. +// +// Conformance: +// - All methods MUST be goroutine-safe for concurrent access. +type PriorityBandAccessor interface { + // Priority returns the numerical priority level of this band. + Priority() uint + // PriorityName returns the human-readable name of this priority band. + PriorityName() string + // CapacityBytes returns the configured maximum total byte size for this priority band. The FlowController uses this + // limit in its capacity checking logic. A value of 0 might indicate no specific byte limit for this band (beyond + // global limits or other constraints). + CapacityBytes() uint64 + // FlowIDs returns a slice of all flow IDs currently active or draining within this priority band. The order is not + // guaranteed unless specified by the implementation (e.g., for deterministic testing). + FlowIDs() []string + // Queue returns a FlowQueueAccessor for the specified flowID within this band. + // Conformance: + // - Returns nil if the flowID is not found in this band. + Queue(flowID string) FlowQueueAccessor + // IterateQueues executes the given callback for each FlowQueueAccessor in his priority band. Iteration stops if the + // callback returns false. + // The order of iteration is not guaranteed unless specified by the implementation. + IterateQueues(callback func(queue FlowQueueAccessor) (keepIterating bool)) +} diff --git a/pkg/epp/flowcontroller/types/outcomes.go b/pkg/epp/flowcontroller/types/outcomes.go new file mode 100644 index 000000000..7582a6aa0 --- /dev/null +++ b/pkg/epp/flowcontroller/types/outcomes.go @@ -0,0 +1,95 @@ +/* +Copyright 2025 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 types + +import "strconv" + +// QueueOutcome clarifies the high-level final outcome of a request's lifecycle within the FlowController. This enum is +// designed for concise reporting in return values from FlowController.EnqueueAndWait() and for use as a low-cardinality +// label in metrics. For fine-grained details on failures, the accompanying error should be inspected. +type QueueOutcome int + +const ( + // QueueOutcomeDispatched indicates the request was successfully processed by the FlowController and unblocked for the + // caller to proceed. + // The associated error from EnqueueAndWait will be nil. + QueueOutcomeDispatched QueueOutcome = iota + + // --- Pre-Enqueue Rejection Outcomes (request never entered a SafeQueue) --- + // For these outcomes, the error from EnqueueAndWait will wrap ErrRejected. + + // QueueOutcomeRejectedCapacity indicates rejection because queue capacity limits were met and preemption (if + // applicable) failed to make space. + // The associated error will wrap types.ErrQueueAtCapacity (and types.ErrRejected). + QueueOutcomeRejectedCapacity + + // QueueOutcomeRejectedOther indicates rejection for reasons other than capacity before the request was formally + // enqueued. + // Examples: invalid input (nil request, empty flowID), flow not registered, or FlowController shutdown before + // internal queuing. + // The specific underlying cause can be determined from the associated error (e.g., types.ErrNilRequest, + // types.ErrFlowNotRegistered, types.ErrFlowControllerShutdown, all wrapped by types.ErrRejected). + QueueOutcomeRejectedOther + + // --- Post-Enqueue Eviction Outcomes (request was in a SafeQueue but not dispatched) --- + // For these outcomes, the error from EnqueueAndWait will wrap ErrEvicted. + + // QueueOutcomeEvictedTTL indicates eviction from a queue because the request's effective Time-To-Live expired. + // The associated error will wrap types.ErrTTLExpired (and types.ErrEvicted). + QueueOutcomeEvictedTTL + + // QueueOutcomeEvictedContextCancelled indicates eviction from a queue because the request's own context (from + // FlowControlRequest.Context()) was cancelled. + // The associated error will wrap types.ErrContextCancelled (which may further wrap context.Canceled or + // context.DeadlineExceeded) and types.ErrEvicted. + QueueOutcomeEvictedContextCancelled + + // QueueOutcomeEvictedPreempted indicates eviction from a queue to make space for another request due to a preemption + // policy. + // The associated error will wrap types.ErrPreempted (and types.ErrEvicted). + QueueOutcomeEvictedPreempted + + // QueueOutcomeEvictedOther indicates eviction from a queue for reasons not covered by more specific eviction outcomes + // (e.g., FlowController shutdown while the item was queued, or an unexpected internal error during dispatch). + // The specific underlying cause can be determined from the associated error (e.g., types.ErrFlowControllerShutdown, + // wrapped by types.ErrEvicted). + QueueOutcomeEvictedOther +) + +// String returns a human-readable string representation of the QueueOutcome. +// It includes the underlying integer value for unknown outcomes to aid debugging. +func (o QueueOutcome) String() string { + switch o { + case QueueOutcomeDispatched: + return "Dispatched" + case QueueOutcomeRejectedCapacity: + return "RejectedCapacity" // Associated error wraps types.ErrQueueAtCapacity + case QueueOutcomeRejectedOther: + return "RejectedOther" + case QueueOutcomeEvictedTTL: + return "EvictedTTL" + case QueueOutcomeEvictedContextCancelled: + return "EvictedContextCancelled" + case QueueOutcomeEvictedPreempted: + return "EvictedPreempted" + case QueueOutcomeEvictedOther: + return "EvictedOther" + default: + // Return the integer value for unknown outcomes to aid in debugging. + return "UnknownOutcome(" + strconv.Itoa(int(o)) + ")" + } +} diff --git a/pkg/epp/flowcontroller/types/policy.go b/pkg/epp/flowcontroller/types/policy.go new file mode 100644 index 000000000..660364c28 --- /dev/null +++ b/pkg/epp/flowcontroller/types/policy.go @@ -0,0 +1,194 @@ +/* +Copyright 2025 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 types + +// ItemComparatorFunc defines the function signature for comparing two QueueItemAccessors to determine their relative +// dispatch priority. +// +// The function encapsulates the logic for "higher priority". It should return true if item 'a' is considered to have +// higher dispatch priority than item 'b', and false otherwise. The specific criteria for "higher priority" (e.g., +// earlier deadline, higher SLO urgency, lower enqueue time) are determined by the specific IntraFlowDispatchPolicy that +// provides this function via an ItemComparator. +// +// This function operates on arbitrary QueueItemAccessors, enabling comparisons not just within a single queue, but +// potentially across items from different flows if their associated ItemComparators have compatible ScoreTypes. +type ItemComparatorFunc func(a, b QueueItemAccessor) bool + +// ItemComparator defines the contract for an object that encapsulates both the logic (Func) and the semantic type +// (ScoreType) of item comparison for dispatch priority. +// It is provided by an IntraFlowDispatchPolicy (via its Comparator() method) and serves as the primary mechanism for +// defining item ordering within and potentially across flow queues. +// +// This approach makes item priority a policy-driven, relational concept rather than a static attribute of an item. It +// allows for dynamic priority evaluation if the providing policy is stateful, enabling sophisticated dispatch +// strategies (e.g., based on real-time SLO attainment or predicted completion times). +// +// Conformance: +// - Implementations returned by IntraFlowDispatchPolicy.Comparator() MUST NOT be nil. +// - The Func() method MUST return a non-nil ItemComparatorFunc. +// - The ScoreType() method MUST return a non-empty, meaningful string that describes the domain or unit of comparison +// (e.g., "nanoseconds_deadline_asc", "urgency_score_0_1_desc"). +type ItemComparator interface { + // Func returns the core comparison logic function. + // This function is the single source of truth for determining the relative priority between two items according to + // the policy that vends this comparator. + // A SafeQueue that declares CapabilityPriorityConfigurable will use this function for its internal ordering. + // Inter-flow policies might use this function (after checking ScoreType compatibility) to compare items from + // different queues. + Func() ItemComparatorFunc + // ScoreType describes the semantic meaning and domain of the comparison defined by Func(). For example, + // "enqueue_time_ns_asc" implies the Func compares enqueue timestamps, and lower values are higher priority. + // "slo_urgency_desc" might imply a calculated urgency score where higher values are higher priority. + // + // This ScoreType is crucial for: + // 1. Understanding: It makes the priority scheme human-understandable. + // 2. Comparability: Inter-flow policies MUST check for ScoreType compatibility before attempting to compare items + // from different queues using their respective ItemComparators. Direct comparison is only meaningful for + // identical ScoreTypes. Policies should not assume any cross-ScoreType normalization exists unless explicitly + // documented by such a future extension. + ScoreType() string +} + +// InterFlowDispatchPolicy selects which flow's queue (identified by its FlowQueueAccessor) to service next from a given +// priority band. +// Implementations of this interface define the fairness or dispatch ordering logic between different flows sharing the +// same priority level. +type InterFlowDispatchPolicy interface { + // SelectQueue inspects the queues within the provided priority band (using the PriorityBandAccessor) and returns the + // FlowQueueAccessor of the flow queue chosen for the next dispatch attempt. + // + // Returns: + // - (selectedQueue, nil): If a queue is successfully selected. + // - (nil, nil): If no queue is selected at this moment (e.g., all eligible queues in the band are empty, or the + // policy determines a pause is needed). + // - (nil, error): If an irrecoverable error occurs that prevents selection. + // Policies should generally be resilient to transient issues (like a queue becoming empty during inspection) and + // attempt to select from other available queues if possible, rather than returning an error for such cases. + // An error might be returned for issues like ErrIncompatiblePriorityType if the policy cannot compare scores from + // different queues in the band. + // + // Conformance: + // - Implementations MUST be goroutine-safe if they maintain internal state. + SelectQueue(band PriorityBandAccessor) (selectedQueue FlowQueueAccessor, err error) + // RequiredQueueCapabilities returns a slice of capabilities that SafeQueue implementations within the band this + // policy operates on MUST support for the policy to function correctly. The FlowRegistry will validate that new flows + // added to a band meet these requirements. Inter-flow policies should primarily require *structural* capabilities + // (e.g., CapabilityDoubleEnded for PeekTail) as behavioral ordering is typically understood via each queue's + // ItemComparator (sourced from its IntraFlowDispatchPolicy). + RequiredQueueCapabilities() []QueueCapability + // Name returns the unique string identifier for this policy implementation (e.g., "RoundRobin", + // "ShortestQueueFirst-Bytes"). + // Useful for debugging and introspection. + Name() string +} + +// IntraFlowDispatchPolicy selects a specific request (identified by its QueueItemAccessor) to dispatch next from a +// single given flow's queue. +// Implementations define the ordering of requests within a single flow. +type IntraFlowDispatchPolicy interface { + // SelectItem inspects the given flow's queue (using the FlowQueueAccessor). + // If it determines an item should be dispatched from this queue according to its policy, it returns the + // QueueItemAccessor for that item. + // Otherwise (e.g., queue is empty, or policy decides not to select an item now), it returns nil. + // + // For queues that inherently order items by dispatch preference (e.g., a priority queue where SafeQueue implements + // CapabilityPriorityConfigurable and is configured with this policy's ItemComparator), this method might simply call + // queue.PeekHead(). + // + // The FlowController will use the handle from the returned QueueItemAccessor (via item.Handle()) to instruct the + // ManagedQueue (and underlying SafeQueue) to remove the item for dispatch. + // + // Conformance: + // - Implementations MUST be goroutine-safe if they maintain internal state. + SelectItem(queue FlowQueueAccessor) (selectedItem QueueItemAccessor) + // Comparator returns the ItemComparator defining this policy's item ordering logic. + // This comparator encapsulates both the comparison function and its semantic type. + // It is the definitive source for how items within a flow governed by this policy should be prioritized for dispatch. + // + // Even policies for simple orderings like FCFS should provide a meaningful ItemComparator (e.g., based on enqueue + // time) to make their ordering principle explicit and potentially usable by other components like inter-flow + // policies. + // + // Conformance: + // - MUST NOT return nil. + // - The returned ItemComparator's Func() MUST NOT return nil. + // - The returned ItemComparator's ScoreType() MUST be non-empty and meaningful. + Comparator() ItemComparator + // RequiredQueueCapabilities returns a slice of capabilities that the SafeQueue used with this policy MUST support for + // the policy to function correctly. + // Example: A FIFO policy would require [CapabilityFIFO]. A policy providing an ItemComparator for a priority queue + // would require [CapabilityPriorityConfigurable]. + RequiredQueueCapabilities() []QueueCapability + // Name returns the unique string identifier for this policy implementation (e.g., "FIFO", + // "ShortestJobFirst-PredictedCost"). + // Useful for debugging and introspection. + Name() string +} + +// InterFlowPreemptionPolicy selects a victim flow's queue (identified by its FlowQueueAccessor) from a target priority +// band (which is of strictly lower priority than the request needing space) to be considered for preemption. +type InterFlowPreemptionPolicy interface { + // SelectVictimQueue inspects the queues within the victimBand. If a suitable queue to target for preemption is found + // according to the policy's criteria, its FlowQueueAccessor is returned. Otherwise (e.g., all queues are empty, or no + // queue meets preemption criteria), it returns nil. + // + // Returns: + // - (victimQueue, nil): If a victim queue is successfully selected. + // - (nil, nil): If no victim queue is selected from this band. + // - (nil, error): If an irrecoverable error occurs. + // + // Conformance: + // - Implementations MUST be goroutine-safe if they maintain internal state. + SelectVictimQueue(victimBand PriorityBandAccessor) (victimQueue FlowQueueAccessor, err error) + // RequiredQueueCapabilities returns a slice of capabilities that SafeQueue implementations within the band this + // policy operates on MUST support for the policy to function correctly. The FlowRegistry will validate that new flows + // added to a band meet these requirements. Inter-flow policies should primarily require *structural* capabilities + // (e.g., CapabilityDoubleEnded for PeekTail) as behavioral ordering is typically understood via each queue's + // ItemComparator (sourced from its IntraFlowDispatchPolicy). + RequiredQueueCapabilities() []QueueCapability + // Name returns the unique string identifier for this policy implementation (e.g., "LeastRecentlyDispatched", + // "LargestQueueFirst-Bytes"). + // Useful for debugging and introspection. + Name() string +} + +// IntraFlowPreemptionPolicy selects a single victim item (identified by its QueueItemAccessor) from within a specific +// flow's queue to be preempted. +type IntraFlowPreemptionPolicy interface { + // SelectVictim inspects the given queue (using the FlowQueueAccessor). + // If a suitable victim item is found within this queue according to the policy's criteria (e.g., oldest item, largest + // item, lowest internal priority item), its QueueItemAccessor is returned. Otherwise, it returns nil. + // + // The FlowController will use the handle from the returned QueueItemAccessor to instruct the ManagedQueue to remove + // the item. + // + // Returns: + // - (victimItem, nil): If a victim item is successfully selected. + // - (nil, nil): If no victim item is selected from this queue. + // - (nil, error): If an irrecoverable error occurs. + // + // Conformance: + // - Implementations MUST be goroutine-safe if they maintain internal state. + SelectVictim(queue FlowQueueAccessor) (victimItem QueueItemAccessor, err error) + // RequiredQueueCapabilities returns a slice of capabilities that the SafeQueue used with this policy MUST support. + // Example: An "EvictTail" policy might require [CapabilityDoubleEnded]. An "EvictLowestPriority" policy for a + // priority queue would need [CapabilityPriorityConfigurable, CapabilityDoubleEnded]. + RequiredQueueCapabilities() []QueueCapability + // Name returns the unique string identifier for this policy implementation (e.g., "EvictOldest", "EvictLargest"). + // Useful for debugging and introspection. + Name() string +} diff --git a/pkg/epp/flowcontroller/types/queue.go b/pkg/epp/flowcontroller/types/queue.go new file mode 100644 index 000000000..4213b6ae9 --- /dev/null +++ b/pkg/epp/flowcontroller/types/queue.go @@ -0,0 +1,227 @@ +/* +Copyright 2025 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 types + +import ( + "time" +) + +// QueueInspectionMethods defines common read-only and content inspection methods shared by SafeQueue and +// FlowQueueAccessor. +// It is used as an embedded interface to ensure DRY (Don't Repeat Yourself). +type QueueInspectionMethods interface { + // Len returns the current number of items in the queue. + Len() int + // ByteSize returns the current total byte size of all items in the queue (from QueueItemAccessor.ByteSize()). + ByteSize() uint64 + // Name returns a string identifier for the concrete queue implementation type (e.g., "ListQueue", "MinMaxHeap", + // "RedisSortedSet"). + // Useful for debugging and introspection. + Name() string + // Capabilities returns the set of functional capabilities this queue instance provides. + Capabilities() []QueueCapability + // PeekHead returns the QueueItemAccessor for the item at the "head" of the queue (according to the queue's ordering) + // without removing it. + // Conformance: + // - Returns (nil, ErrQueueEmpty) if the queue is empty. + PeekHead() (QueueItemAccessor, error) + // PeekTail returns the QueueItemAccessor for the item at the "tail" of the queue without removing it, if the queue + // supports this capability. + // Conformance: + // - Returns (nil, ErrQueueEmpty) if the queue is empty. + // - Returns (nil, ErrOperationNotSupported) if not supported by the implementation. + PeekTail() (QueueItemAccessor, error) +} + +// SafeQueue defines the contract for a core, self-contained queue implementation. +// Plugin implementers provide concrete types that satisfy this interface. +// These raw queues are then wrapped by a ManagedQueue by the FlowRegistry. +// +// Conformance: +// - All methods defined in this interface (including those embedded from QueueInspectionMethods and the +// write/mutating methods) MUST be goroutine-safe for concurrent access with respect to the queue's own internal +// data structures. +// - Methods that mutate the queue (Add, Remove) MUST return the queue's new length and total byte size after the +// operation. +// - If this queue was configured with an ItemComparator (because it reported CapabilityPriorityConfigurable), it +// should use the comparator's Func for ordering. +type SafeQueue interface { + QueueInspectionMethods // Embeds Len, ByteSize, Name, Capabilities, PeekHead, PeekTail. + // Add attempts to enqueue an item. + // Upon successful addition, it returns the queue's new length and total byte size. + // Conformance: + // - Must call item.SetHandle(createdHandle) with a new, unique QueueItemHandle. + // - Must store the item and accurately update internal state for Len and ByteSize. + // - If item is nil, must return (currentLen, currentByteSize, ErrNilQueueItem) where currentLen and currentByteSize + // are the queue's state before attempting to add. + Add(item QueueItemAccessor) (newLen uint64, newByteSize uint64, err error) + // Remove removes and returns the item identified by the given handle. + // Upon successful removal, it returns the removed item, and the queue's new length and total byte size. + // Conformance: + // - If handle is invalid (nil, wrong type, already invalidated), must return (nil, currentLen, currentByteSize, + // ErrInvalidQueueItemHandle) (currentLen and currentByteSize are the queue's state before attempting removal). + // - If handle is valid but item not found, must return (nil, currentLen, currentByteSize, types.ErrQueueItemNotFound) + // - Must update internal state (Len and ByteSize) and invalidate the provided handle upon successful removal. + Remove(handle QueueItemHandle) (removedItem QueueItemAccessor, newLen uint64, newByteSize uint64, err error) + // CleanupExpired iterates items, using isItemExpired to check each one. + // If an item is deemed expired, it's removed internally. Information about all removed items is returned. The + // underlying queue's length and byte size are updated internally by this operation. The ManagedQueue wrapper will + // subsequently call Len() and ByteSize() to get the final state for reconciliation. + // Conformance: + // - For each item removed, its associated QueueItemHandle MUST be invalidated. + // - Must accurately update internal state (Len, ByteSize). + CleanupExpired(currentTime time.Time, isItemExpired IsItemExpiredFunc) (removedItemsInfo []ExpiredItemInfo, err error) +} + +// ManagedQueue is the interface returned by the FlowRegistry for a flow's active queue. +// It wraps an underlying SafeQueue, adding lifecycle validation against the FlowRegistry and integrating atomic +// statistics updates. +// +// Conformance: +// - Implementations of this interface (provided by the FlowRegistry's wrapper) ensure that operations on the +// underlying SafeQueue are only performed if the flow instance is still valid within the registry for mutating +// operations (Add, Remove, CleanupExpired). If the instance is found to be invalid (e.g., cleaned up from the +// registry), these mutating operations will return an error (typically wrapping types.ErrFlowInstanceNotFound). +// - Read-only inspection methods (Len, ByteSize, Name, Capabilities, PeekHead, PeekTail) may return data from a +// "zombie" queue instance if its corresponding flow instance has been removed from the registry. Similarly, +// FlowSpec() and FlowQueueAccessor() will return values based on the state of the ManagedQueue when it was +// valid, even if the underlying FlowRegistry instance has since been cleaned up; users should be aware that this +// data might be stale. +// - Write operations (Add, Remove, CleanupExpired) are effectively serialized by the wrapper and made atomic with +// respect to FlowRegistry state and statistics. +// - All methods (including those from embedded SafeQueue) are goroutine-safe. +// - Mutating methods (Add, Remove) that return the queue's new length and byte size reflect the state *after* the +// operation and internal statistics reconciliation. +type ManagedQueue interface { + // Embeds all methods from SafeQueue + // The wrapper's implementation of these embedded methods will: + // 1. Validate flow instance with FlowRegistry (likely under registry RLock). + // 2. Call the corresponding method on the underlying SafeQueue instance, receiving newLen and newByteSize for + // mutating operations. + // 3. Atomically update its associated flowInstance's statistics and then the FlowRegistry's global/band statistics + // based on these values or calculated deltas. + // 4. Return the results (including newLen, newByteSize) from the SafeQueue call. + SafeQueue + // FlowQueueAccessor returns a read-only, flow-aware accessor for this managed queue. + // This is how policies inspect queue state. + // Conformance: + // - Must return a non-nil FlowQueueAccessor. + FlowQueueAccessor() FlowQueueAccessor + // FlowSpec returns the specification of the flow this managed queue is associated with. + // This is a convenience method for accessing the flow specification without needing to call + // FlowQueueAccessor().FlowSpec(). + // Conformance: + // - Must return a non-nil FlowSpecification. + FlowSpec() FlowSpecification +} + +// FlowQueueAccessor provides a read-only, flow-aware view of a queue's state and its items. +// It is intended for use by policy plugins to inspect queues. +// Instances are vended by a ManagedQueue. +// +// Conformance: +// - All methods defined in this interface (including those embedded from QueueInspectionMethods) MUST be +// goroutine-safe for concurrent access. +type FlowQueueAccessor interface { + QueueInspectionMethods // Embeds Len, ByteSize, Name, Capabilities, PeekHead, PeekTail. + // FlowSpec returns the specification of the flow this queue accessor is associated with, providing essential context + // (like FlowID) to policies. + // Conformance: + // - Must return a non-nil FlowSpecification. + FlowSpec() FlowSpecification + // Comparator returns the ItemComparator that defines the dispatch ordering for items within this queue, sourced from + // from the IntraFlowDispatchPolicy configured for this flow. + // Conformance: + // - Must return a non-nil ItemComparator. + Comparator() ItemComparator +} + +// QueueCapability defines a functional capability that a SafeQueue implementation can provide. +// These capabilities can be broadly categorized into: +// 1. Structural Capabilities: Describe the methods or interface contract a queue exposes (e.g., CapabilityDoubleEnded +// implies a working PeekTail method). Inter-flow policies primarily rely on these to ensure they can perform +// necessary inspection operations. +// 2. Behavioral Capabilities: Describe the internal operational logic or ordering principle of a queue (e.g., +// CapabilityFIFO, CapabilityPriorityConfigurable). These are primarily the concern of IntraFlowDispatchPolicy, +// which selects a desired behavior and requires the corresponding capability. The ItemComparator provided by an +// IntraFlowDispatchPolicy then serves as the standardized way for *any* policy (intra or inter-flow) to understand +// a queue's dispatch order, abstracting the underlying behavioral implementation. +// +// Policies use these capabilities to declare their operational requirements, allowing the FlowRegistry to ensure that a +// compatible SafeQueue is used with a given policy. +type QueueCapability string + +const ( + // CapabilityFIFO indicates that the queue operates in a First-In, First-Out manner. PeekHead() will return the oldest + // item. To remove it, its handle would be obtained and used with Remove(handle). + // Policies requiring strict FIFO ordering would specify this capability. + // This is primarily a *behavioral* capability. + CapabilityFIFO QueueCapability = "FIFO" + + // CapabilityPriorityConfigurable indicates that the queue can be configured with an ItemComparator (typically + // provided by an IntraFlowDispatchPolicy). Its PeekHead() will return the highest priority item according to this + // comparator. To remove it, its handle would be obtained and used with Remove(handle). This capability is essential + // for implementing priority-based dispatch within a flow. + // This is primarily a *behavioral* capability. + CapabilityPriorityConfigurable QueueCapability = "PriorityConfigurable" + + // CapabilityDoubleEnded indicates that the queue supports operations at both ends, specifically PeekTail(). + // This is useful for policies that might need to inspect or target items at the "end" or "lowest priority" part of + // the queue, such as certain preemption strategies. + // This is a *structural* capability. + CapabilityDoubleEnded QueueCapability = "DoubleEnded" + + // CapabilityDynamicPriority indicates that the queue can efficiently handle items whose dispatch priorities (as + // determined by the configured ItemComparator) may change while they are in the queue. Such queues (e.g., a priority + // heap) should be able to re-order or re-evaluate item positions as needed, or allow for efficient re-prioritization + // if signaled. This is crucial for policies that implement dynamic scoring based on evolving system state. + // This is primarily a *behavioral* capability, often coupled with CapabilityPriorityConfigurable. + CapabilityDynamicPriority QueueCapability = "DynamicPriority" +) + +// ExpiredItemInfo holds structured information about a single QueueItemAccessor that was removed from a SafeQueue +// during its CleanupExpired process because it was deemed expired by the IsItemExpiredFunc callback. +type ExpiredItemInfo struct { + // Item is the QueueItemAccessor for the item that was removed. + Item QueueItemAccessor + // Outcome indicates the specific reason (as a QueueOutcome) why the item was considered expired and subsequently + // removed by the queue during cleanup. + // Examples: QueueOutcomeEvictedTTL, QueueOutcomeEvictedContextCancelled. + Outcome QueueOutcome + // Error is the specific error associated with the expiry condition that led to the item's removal (e.g., + // types.ErrTTLExpired, context.Canceled). + Error error +} + +// IsItemExpiredFunc is a function type defining the callback signature used by SafeQueue.CleanupExpired(). The +// FlowController implements this function, encapsulating its logic for determining if a queued item should be +// considered expired (e.g., due to TTL violation or context cancellation). +// +// The SafeQueue implementation calls this function for items during its CleanupExpired routine. +// +// Parameters: +// - item: The QueueItemAccessor of the item being checked. +// - currentTime: The current time, provided by the caller of CleanupExpired (typically the FlowController's expiry +// cleanup loop) to ensure consistency across multiple checks in a single cleanup cycle. +// +// Returns: +// - isExpired (bool): True if the item is considered expired and should be removed from the queue. +// - outcomeForExpiry (QueueOutcome): The QueueOutcome to be associated with this specific expiry reason if isExpired +// is true. +// - errForExpiry (error): The specific error associated with this expiry reason if isExpired is true (e.g., +// types.ErrTTLExpired). +type IsItemExpiredFunc func(item QueueItemAccessor, currentTime time.Time) (isExpired bool, outcomeForExpiry QueueOutcome, errForExpiry error) diff --git a/pkg/epp/flowcontroller/types/request.go b/pkg/epp/flowcontroller/types/request.go new file mode 100644 index 000000000..deca8b933 --- /dev/null +++ b/pkg/epp/flowcontroller/types/request.go @@ -0,0 +1,113 @@ +/* +Copyright 2025 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 types + +import ( + "context" + "time" +) + +// FlowControlRequest defines the essential data the FlowController needs from an incoming request when it is first +// submitted for processing via FlowController.EnqueueAndWait(). +// The FlowController will wrap instances of this interface with its own internal structure (e.g., +// flowcontroller.flowItem, which implements QueueItemAccessor) to manage the request's lifecycle within the flow +// control system. +type FlowControlRequest interface { + // Context returns the request's context. The FlowController uses this for monitoring cancellation (e.g., if the + // client disconnects or a request-scoped timeout occurs), which can lead to the request being evicted from a queue. + Context() context.Context + // FlowID returns the unique identifier for the flow this request belongs to (e.g., model name, tenant ID). The + // FlowController uses this ID, in conjunction with the flow's registered priority, to look up the active ManagedQueue + // from the FlowRegistry. + FlowID() string + // ByteSize returns the request's size in bytes (e.g., prompt size). This is used by the FlowController and queue + // implementations for managing byte-based capacity limits and for statistics. + ByteSize() uint64 + // InitialEffectiveTTL returns the suggested initial Time-To-Live for this request within the FlowController's queues. + // The FlowController may use this value as a hint or override it based on its own configuration or per-flow policies. + // A value of 0 typically indicates no specific TTL preference from the request's perspective, in which case a + // FlowController-defined default TTL may apply. + InitialEffectiveTTL() time.Duration + // ID returns an optional, user-facing unique identifier for this specific request. + // This ID is primarily intended for logging, tracing, and observability across systems. It is distinct from the + // internal QueueItemHandle used by SafeQueue implementations. + ID() string +} + +// QueueItemAccessor provides a view of a request item as it is managed within the FlowController's queues. It is the +// primary interface through which SafeQueue implementations and policy plugins interact with the request data and its +// associated flow control metadata. +// +// The FlowController internally creates an object that implements this interface (e.g., flowcontroller.flowItem) by +// wrapping an incoming FlowControlRequest. +type QueueItemAccessor interface { + // EnqueueTime is the timestamp when the item was logically accepted by the FlowController for queuing (i.e., when + // FlowController.EnqueueAndWait was called and the item was passed to the internal enqueue channel). + EnqueueTime() time.Time + // ByteSize returns the byte size of the original request, cached from FlowControlRequest.ByteSize(). Used for + // capacity management and statistics. + ByteSize() uint64 + // FlowID returns the unique identifier of the flow this item belongs to, cached from FlowControlRequest.FlowID(). + FlowID() string + // EffectiveTTL is the actual Time-To-Live assigned to this item by the FlowController, taking into account the + // request's preference (FlowControlRequest.InitialEffectiveTTL()) and any FlowController or per-flow + // defaults/policies. + EffectiveTTL() time.Duration + // RequestID is the user-facing ID from the original request (FlowControlRequest.ID()), primarily for logging and + // tracing. + RequestID() string + // OriginalRequest returns the underlying FlowControlRequest that this accessor provides a view of. This allows + // policies or components that are aware of more specific FlowControlRequest implementations to perform type + // assertions and access richer, application-specific data if necessary. In short, this is useful escape hatch for + // rich request metadata passthrough. + OriginalRequest() FlowControlRequest + // Handle returns the QueueItemHandle associated with this item once it has been successfully added to a SafeQueue. + // Returns nil if no handle has been set yet (e.g., before the item is successfully processed by SafeQueue.Add()). + Handle() QueueItemHandle + // SetHandle associates a QueueItemHandle with this item. + // Conformance: + // - This method MUST be called by a SafeQueue implementation within its Add method, immediately after a new + // QueueItemHandle is created for the item being added. + // This ensures that the QueueItemAccessor (which is typically stored in the queue and passed to policies) always + // has a reference to its queue-specific handle. + // - This method is not intended for general use outside of SafeQueue implementations. + SetHandle(handle QueueItemHandle) +} + +// QueueItemHandle represents an opaque, queue-specific handle to an item that has been successfully added to a +// SafeQueue. +// It allows the FlowController or other authorized components to refer to a specific item for operations like targeted +// removal (SafeQueue.Remove()). The handle also provides a mechanism for the SafeQueue to invalidate it after the item +// is removed or the handle is otherwise deemed stale. +type QueueItemHandle interface { + // Handle returns the underlying, queue-specific raw handle (e.g., *list.Element). This is primarily for internal use + // by the SafeQueue implementation that created it. + // External users should treat this as opaque. + Handle() any + // Invalidate marks this handle instance as no longer valid for future operations on the SafeQueue from which it + // originated. + // This method is typically called by the SafeQueue implementation itself after the item associated with this handle + // has been successfully removed, or if the queue otherwise determines the handle is stale (e.g., during + // CleanupExpired for items it internally removes). + // Conformance: + // - Must be idempotent; subsequent calls after the first should have no effect. + Invalidate() + // IsInvalidated returns true if this handle instance has been marked as invalid (e.g., by a call to Invalidate()). + // If true, this handle should not be used for further operations on the SafeQueue. An attempt to use an invalidated + // handle with SafeQueue.Remove() MUST result in ErrInvalidQueueItemHandle. + IsInvalidated() bool +} diff --git a/pkg/epp/requestcontrol/director.go b/pkg/epp/requestcontrol/director.go index 78daf0d92..456a6ee71 100644 --- a/pkg/epp/requestcontrol/director.go +++ b/pkg/epp/requestcontrol/director.go @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +// Package requestcontrol defines the Director component responsible for orchestrating request processing after initial +// parsing. package requestcontrol import ( @@ -34,33 +36,45 @@ import ( requtil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/request" ) +// Scheduler defines the interface required by the Director for scheduling. type Scheduler interface { Schedule(ctx context.Context, b *schedulingtypes.LLMRequest) (result map[string]*schedulingtypes.Result, err error) OnResponse(ctx context.Context, resp *schedulingtypes.LLMResponse, targetPodName string) } +// SaturationDetector provides a signal indicating whether the backends are considered saturated. +type SaturationDetector interface { + IsSaturated(ctx context.Context) bool +} + +// Director orchestrates the request handling flow, including scheduling. type Director struct { - datastore datastore.Datastore - scheduler Scheduler + datastore datastore.Datastore + scheduler Scheduler + saturationDetector SaturationDetector } -func NewDirector(datastore datastore.Datastore, scheduler Scheduler) *Director { - return &Director{ - datastore: datastore, - scheduler: scheduler, - } +// NewDirector creates a new Director instance with all dependencies. +func NewDirector(datastore datastore.Datastore, scheduler Scheduler, saturationDetector SaturationDetector) *Director { + return &Director{datastore, scheduler, saturationDetector} } -// HandleRequest always returns the requestContext even in the error case, as the request context is used in error handling. +// HandleRequest orchestrates the request lifecycle: +// 1. Parses request details. +// 2. Calls PreDispatch for admission control. +// 3. Calls Dispatch (which calls Scheduler) if request is approved. +// 4. Calls PostDispatch to populate RequestContext with results. +// +// It always returns the requestContext even in the error case, as the request context is used in error handling. func (d *Director) HandleRequest(ctx context.Context, reqCtx *handlers.RequestContext) (*handlers.RequestContext, error) { logger := log.FromContext(ctx) - // Resolve target models. + // --- 1. Parse Request, Resolve Target Models, and Determine Parameters --- var ok bool requestBodyMap := reqCtx.Request.Body reqCtx.Model, ok = requestBodyMap["model"].(string) if !ok { - return reqCtx, errutil.Error{Code: errutil.BadRequest, Msg: "model not found in request"} + return reqCtx, errutil.Error{Code: errutil.BadRequest, Msg: "model not found in request body"} } prompt, err := requtil.ExtractPromptFromRequestBody(requestBodyMap) if err != nil { @@ -84,29 +98,72 @@ func (d *Director) HandleRequest(ctx context.Context, reqCtx *handlers.RequestCo reqCtx.Request.Body["model"] = reqCtx.ResolvedTargetModel // Update target model in the body. } + requestCriticality := v1alpha2.Standard + if modelObj.Spec.Criticality != nil { + requestCriticality = *modelObj.Spec.Criticality + } + + // Prepare LLMRequest (needed for both saturation detection and Scheduler) llmReq := &schedulingtypes.LLMRequest{ TargetModel: reqCtx.ResolvedTargetModel, RequestId: reqCtx.Request.Headers[requtil.RequestIdHeaderKey], - Critical: modelObj.Spec.Criticality != nil && *modelObj.Spec.Criticality == v1alpha2.Critical, + Critical: requestCriticality == v1alpha2.Critical, Prompt: prompt, Headers: reqCtx.Request.Headers, } - logger.V(logutil.DEBUG).Info("LLM request assembled", "request", llmReq) - results, err := d.Dispatch(ctx, llmReq) - if err != nil { - return reqCtx, err + logger = logger.WithValues( + "model", reqCtx.Model, + "resolvedTargetModel", llmReq.TargetModel, + "criticality", requestCriticality, + ) + ctx = log.IntoContext(ctx, logger) + logger.V(logutil.DEBUG).Info("LLM request assembled") + + // --- 2. Saturation Check --- + logger.V(logutil.DEBUG).Info("Calling PreDispatch") + preDispatchErr := d.PreDispatch(ctx, reqCtx, requestCriticality) + if preDispatchErr != nil { + return reqCtx, preDispatchErr } + // --- 3. Dispatch (Calls Scheduler) --- + logger.V(logutil.DEBUG).Info("Calling Dispatch") + results, dispatchErr := d.Dispatch(ctx, llmReq) + if dispatchErr != nil { + return reqCtx, dispatchErr + } + + // --- 4. PostDispatch (Populates RequestContext) --- // Insert target endpoint to instruct Envoy to route requests to the specified target pod. - // Attach the port number - reqCtx, err = d.PostDispatch(ctx, reqCtx, results) - if err != nil { - return reqCtx, err + // Attach the port number. + logger.V(logutil.DEBUG).Info("Calling PostDispatch") + reqCtx, postDispatchErr := d.PostDispatch(ctx, reqCtx, results) + if postDispatchErr != nil { + return reqCtx, postDispatchErr } return reqCtx, nil } +// PreDispatch handles admission control before dispatch. +func (d *Director) PreDispatch(ctx context.Context, reqCtx *handlers.RequestContext, reqCriticality v1alpha2.Criticality) error { + logger := log.FromContext(ctx) + + if reqCriticality == v1alpha2.Critical { + logger.V(logutil.DEBUG).Info("Critical request bypassing saturation check.") + return nil + } + + logger.V(logutil.DEBUG).Info("Performing saturation check for non-critical request.") + if d.saturationDetector.IsSaturated(ctx) { // Assuming non-nil Saturation Detector + return errutil.Error{ + Code: errutil.InferencePoolResourceExhausted, + Msg: "system saturated, non-critical request dropped", + } + } + return nil +} + // Dispatch runs one or many scheduling cycles. func (d *Director) Dispatch(ctx context.Context, llmReq *schedulingtypes.LLMRequest) (map[string]*schedulingtypes.Result, error) { var err error @@ -118,6 +175,7 @@ func (d *Director) Dispatch(ctx context.Context, llmReq *schedulingtypes.LLMRequ return res, nil // TODO handle multi cycle result after defining the PostDispatch extension point } +// PostDispatch populates the RequestContext based on scheduling results. func (d *Director) PostDispatch(ctx context.Context, reqCtx *handlers.RequestContext, results map[string]*schedulingtypes.Result) (*handlers.RequestContext, error) { logger := log.FromContext(ctx) // currently only get a single result. Will refactor to pluggably implement the PostSchedule diff --git a/pkg/epp/requestcontrol/director_test.go b/pkg/epp/requestcontrol/director_test.go index e4384a80b..a56115818 100644 --- a/pkg/epp/requestcontrol/director_test.go +++ b/pkg/epp/requestcontrol/director_test.go @@ -18,120 +18,189 @@ package requestcontrol import ( "context" - "strings" + "errors" "testing" "time" "github.com/google/go-cmp/cmp" - + "github.com/stretchr/testify/assert" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + k8stypes "k8s.io/apimachinery/pkg/types" clientgoscheme "k8s.io/client-go/kubernetes/scheme" "sigs.k8s.io/controller-runtime/pkg/client/fake" - "sigs.k8s.io/gateway-api-inference-extension/api/v1alpha2" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" + "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend" backendmetrics "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend/metrics" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/datastore" "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/handlers" - "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling" + schedulingtypes "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types" errutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/error" logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging" + requtil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/request" testutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/testing" ) -func TestHandleRequest(t *testing.T) { +// --- Mocks --- + +type mockSaturationDetector struct { + isSaturated bool +} + +func (m *mockSaturationDetector) IsSaturated(_ context.Context) bool { + return m.isSaturated +} + +type mockScheduler struct { + scheduleResults map[string]*schedulingtypes.Result + scheduleErr error + lastRespOnResponse *schedulingtypes.LLMResponse + lastTargetPodOnResponse string +} + +func (m *mockScheduler) Schedule( + ctx context.Context, + req *schedulingtypes.LLMRequest, +) (map[string]*schedulingtypes.Result, error) { + return m.scheduleResults, m.scheduleErr +} + +func (m *mockScheduler) OnResponse(ctx context.Context, resp *schedulingtypes.LLMResponse, targetPodName string) { + m.lastRespOnResponse = resp + m.lastTargetPodOnResponse = targetPodName +} + +func TestDirector_HandleRequest(t *testing.T) { ctx := logutil.NewTestLoggerIntoContext(context.Background()) - // Setup datastore - tsModel := "food-review" - modelWithTarget := "food-review-0" - model1 := testutil.MakeInferenceModel("model1"). + // --- Setup common objects --- + model := "food-review" + modelSheddable := "food-review-sheddable" + modelWithResolvedTarget := "food-review-resolve" + + // InferenceModel definitions + imFoodReview := testutil.MakeInferenceModel("imFoodReview"). CreationTimestamp(metav1.Unix(1000, 0)). - ModelName(tsModel).ObjRef() - model2 := testutil.MakeInferenceModel("model2"). + ModelName(model). + Criticality(v1alpha2.Critical). + ObjRef() + imFoodReviewSheddable := testutil.MakeInferenceModel("imFoodReviewSheddable"). CreationTimestamp(metav1.Unix(1000, 0)). - ModelName(modelWithTarget).ObjRef() + ModelName(modelSheddable). + Criticality(v1alpha2.Sheddable). + ObjRef() + imFoodReviewResolve := testutil.MakeInferenceModel("imFoodReviewResolve"). + CreationTimestamp(metav1.Unix(1000, 0)). + ModelName(modelWithResolvedTarget). + Criticality(v1alpha2.Standard). + TargetModel("resolved-target-model-A"). + ObjRef() + + // Datastore setup pmf := backendmetrics.NewPodMetricsFactory(&backendmetrics.FakePodMetricsClient{}, time.Second) ds := datastore.NewDatastore(t.Context(), pmf) - ds.ModelSetIfOlder(model1) - ds.ModelSetIfOlder(model2) + ds.ModelSetIfOlder(imFoodReview) + ds.ModelSetIfOlder(imFoodReviewResolve) + ds.ModelSetIfOlder(imFoodReviewSheddable) pool := &v1alpha2.InferencePool{ + ObjectMeta: metav1.ObjectMeta{Name: "test-pool", Namespace: "default"}, Spec: v1alpha2.InferencePoolSpec{ TargetPortNumber: int32(8000), Selector: map[v1alpha2.LabelKey]v1alpha2.LabelValue{ - "some-key": "some-val", + "app": "inference", }, }, } - pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod1"}, Status: corev1.PodStatus{PodIP: "address-1"}} + + // Pod setup + testPod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Namespace: "default", + Labels: map[string]string{"app": "inference"}, + }, + Status: corev1.PodStatus{ + PodIP: "192.168.1.100", + Phase: corev1.PodRunning, + Conditions: []corev1.PodCondition{{Type: corev1.PodReady, Status: corev1.ConditionTrue}}, + }, + } scheme := runtime.NewScheme() _ = clientgoscheme.AddToScheme(scheme) - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - Build() + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() if err := ds.PoolSet(ctx, fakeClient, pool); err != nil { - t.Error(err, "Error while setting inference pool") + t.Fatalf("Error while setting inference pool: %v", err) + } + ds.PodUpdateOrAddIfNotExist(testPod) + + defaultSuccessfulScheduleResults := map[string]*schedulingtypes.Result{ + "testProfile": { + TargetPod: &schedulingtypes.ScoredPod{ + Pod: &schedulingtypes.PodMetrics{ + Pod: &backend.Pod{ + Address: "192.168.1.100", + NamespacedName: k8stypes.NamespacedName{Name: "pod1", Namespace: "default"}, + }, + }, + }, + }, } - ds.PodUpdateOrAddIfNotExist(pod) tests := []struct { - name string - reqBodyMap map[string]interface{} - wantErrCode string - wantReqCtx *handlers.RequestContext - wantRespBody map[string]interface{} + name string + reqBodyMap map[string]interface{} + mockSaturationDetector *mockSaturationDetector + schedulerMockSetup func(m *mockScheduler) + wantErrCode string // Expected errutil code string + wantReqCtx *handlers.RequestContext // Fields to check in the returned RequestContext + wantMutatedBodyModel string // Expected model in reqCtx.Request.Body after PostDispatch }{ { - name: "successful completions request", + name: "successful completions request (critical, saturation ignored)", reqBodyMap: map[string]interface{}{ - "model": tsModel, - "prompt": "test prompt", + "model": model, + "prompt": "critical prompt", }, - wantReqCtx: &handlers.RequestContext{ - Model: tsModel, - ResolvedTargetModel: tsModel, - TargetPod: "/pod1", - TargetEndpoint: "address-1:8000", + mockSaturationDetector: &mockSaturationDetector{isSaturated: true}, + schedulerMockSetup: func(m *mockScheduler) { + m.scheduleResults = defaultSuccessfulScheduleResults }, - wantRespBody: map[string]interface{}{ - "model": tsModel, - "prompt": "test prompt", + wantReqCtx: &handlers.RequestContext{ + Model: model, + ResolvedTargetModel: model, + TargetPod: "default/pod1", + TargetEndpoint: "192.168.1.100:8000", }, + wantMutatedBodyModel: model, }, { - name: "successful chat completions request", + name: "successful chat completions request (critical, saturation ignored)", reqBodyMap: map[string]interface{}{ - "model": tsModel, + "model": model, "messages": []interface{}{ map[string]interface{}{ "role": "user", - "content": "test prompt", + "content": "critical prompt", }, }, }, - wantReqCtx: &handlers.RequestContext{ - Model: tsModel, - ResolvedTargetModel: tsModel, - TargetPod: "/pod1", - TargetEndpoint: "address-1:8000", + schedulerMockSetup: func(m *mockScheduler) { + m.scheduleResults = defaultSuccessfulScheduleResults }, - wantRespBody: map[string]interface{}{ - "model": tsModel, - "messages": []interface{}{ - map[string]interface{}{ - "role": "user", - "content": "test prompt", - }, - }, + wantReqCtx: &handlers.RequestContext{ + Model: model, + ResolvedTargetModel: model, + TargetPod: "default/pod1", + TargetEndpoint: "192.168.1.100:8000", }, + wantMutatedBodyModel: model, }, { - name: "successful chat completions request with multiple messages", + name: "successful chat completions request with multiple messages (critical, saturation ignored)", reqBodyMap: map[string]interface{}{ - "model": tsModel, + "model": model, "messages": []interface{}{ map[string]interface{}{ "role": "developer", @@ -143,58 +212,79 @@ func TestHandleRequest(t *testing.T) { }, }, }, - wantReqCtx: &handlers.RequestContext{ - Model: tsModel, - ResolvedTargetModel: tsModel, - TargetPod: "/pod1", - TargetEndpoint: "address-1:8000", + schedulerMockSetup: func(m *mockScheduler) { + m.scheduleResults = defaultSuccessfulScheduleResults }, - wantRespBody: map[string]interface{}{ - "model": tsModel, - "messages": []interface{}{ - map[string]interface{}{ - "role": "developer", - "content": "You are a helpful assistant.", - }, - map[string]interface{}{ - "role": "user", - "content": "Hello!", - }, - }, + wantReqCtx: &handlers.RequestContext{ + Model: model, + ResolvedTargetModel: model, + TargetPod: "default/pod1", + TargetEndpoint: "192.168.1.100:8000", }, + wantMutatedBodyModel: model, }, { - name: "successful completions request with target model", + name: "successful completions request (sheddable, not saturated)", reqBodyMap: map[string]interface{}{ - "model": modelWithTarget, - "prompt": "test prompt", + "model": modelSheddable, + "prompt": "sheddable prompt", }, - wantReqCtx: &handlers.RequestContext{ - Model: modelWithTarget, - ResolvedTargetModel: modelWithTarget, - TargetPod: "/pod1", - TargetEndpoint: "address-1:8000", + mockSaturationDetector: &mockSaturationDetector{isSaturated: false}, + schedulerMockSetup: func(m *mockScheduler) { + m.scheduleResults = defaultSuccessfulScheduleResults }, - wantRespBody: map[string]interface{}{ - "model": modelWithTarget, - "prompt": "test prompt", + wantReqCtx: &handlers.RequestContext{ + Model: modelSheddable, + ResolvedTargetModel: modelSheddable, + TargetPod: "default/pod1", + TargetEndpoint: "192.168.1.100:8000", }, + wantMutatedBodyModel: modelSheddable, }, { - name: "no model defined, expect err", - wantErrCode: errutil.BadRequest, + name: "successful request with target model resolution", + reqBodyMap: map[string]interface{}{ + "model": modelWithResolvedTarget, + "prompt": "prompt for target resolution", + }, + mockSaturationDetector: &mockSaturationDetector{isSaturated: false}, + schedulerMockSetup: func(m *mockScheduler) { + m.scheduleResults = defaultSuccessfulScheduleResults + }, + wantReqCtx: &handlers.RequestContext{ + Model: modelWithResolvedTarget, + ResolvedTargetModel: "resolved-target-model-A", + TargetPod: "default/pod1", + TargetEndpoint: "192.168.1.100:8000", + }, + wantMutatedBodyModel: "resolved-target-model-A", }, { - name: "prompt or messages not found, expect err", + + name: "request dropped (sheddable, saturated)", reqBodyMap: map[string]interface{}{ - "model": tsModel, + "model": modelSheddable, + "prompt": "sheddable prompt", }, + mockSaturationDetector: &mockSaturationDetector{isSaturated: true}, + wantErrCode: errutil.InferencePoolResourceExhausted, + }, + { + name: "model not found, expect err", + reqBodyMap: map[string]interface{}{"prompt": "p"}, + mockSaturationDetector: &mockSaturationDetector{isSaturated: false}, + wantErrCode: errutil.BadRequest, + }, + + { + name: "prompt or messages not found, expect err", + reqBodyMap: map[string]interface{}{"model": model}, wantErrCode: errutil.BadRequest, }, { name: "empty messages, expect err", reqBodyMap: map[string]interface{}{ - "model": tsModel, + "model": model, "messages": []interface{}{}, }, wantErrCode: errutil.BadRequest, @@ -205,7 +295,8 @@ func TestHandleRequest(t *testing.T) { "model": "non-existent-model", "prompt": "test prompt", }, - wantErrCode: errutil.BadConfiguration, + mockSaturationDetector: &mockSaturationDetector{isSaturated: false}, + wantErrCode: errutil.BadConfiguration, }, { name: "invalid target defined, expect err", @@ -213,47 +304,86 @@ func TestHandleRequest(t *testing.T) { "model": "food-review-1", "prompt": "test prompt", }, - wantErrCode: errutil.BadConfiguration, + mockSaturationDetector: &mockSaturationDetector{isSaturated: false}, + wantErrCode: errutil.BadConfiguration, + }, + { + name: "scheduler returns error", + reqBodyMap: map[string]interface{}{ + "model": model, + "prompt": "prompt that causes scheduler error", + }, + schedulerMockSetup: func(m *mockScheduler) { + m.scheduleErr = errors.New("simulated scheduler failure") + }, + wantErrCode: errutil.InferencePoolResourceExhausted, + }, + { + name: "scheduler returns nil result and nil error", + reqBodyMap: map[string]interface{}{ + "model": model, + "prompt": "prompt for nil,nil scheduler return", + }, + schedulerMockSetup: func(m *mockScheduler) { + m.scheduleResults = nil + m.scheduleErr = nil + }, + wantErrCode: errutil.Internal, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - server := NewDirector(ds, scheduling.NewScheduler(ds)) + mockSched := &mockScheduler{} + if test.schedulerMockSetup != nil { + test.schedulerMockSetup(mockSched) + } + + var sd SaturationDetector + if test.mockSaturationDetector != nil { + sd = test.mockSaturationDetector + } + director := NewDirector(ds, mockSched, sd) + reqCtx := &handlers.RequestContext{ Request: &handlers.Request{ - Body: test.reqBodyMap, + // Create a copy of the map for each test run to avoid mutation issues. + Body: make(map[string]interface{}), + Headers: map[string]string{ + requtil.RequestIdHeaderKey: "test-req-id-" + test.name, // Ensure a default request ID + }, }, } - reqCtx, err := server.HandleRequest(ctx, reqCtx) + // Deep copy the body map. + for k, v := range test.reqBodyMap { + reqCtx.Request.Body[k] = v + } + + returnedReqCtx, err := director.HandleRequest(ctx, reqCtx) if test.wantErrCode != "" { - if err == nil { - t.Fatalf("HandleRequestBody should have returned an error containing '%s', but got nil", test.wantErrCode) - } - if !strings.Contains(err.Error(), test.wantErrCode) { - t.Fatalf("HandleRequestBody returned error '%v', which does not contain expected substring '%s'", err, test.wantErrCode) + assert.Error(t, err, "HandleRequest() should have returned an error") + var e errutil.Error + if assert.ErrorAs(t, err, &e, "Error should be of type errutil.Error") { + assert.Equal(t, test.wantErrCode, e.Code, "Error code mismatch") } return } - if err != nil { - t.Fatalf("HandleRequestBody returned unexpected error: %v", err) - } + assert.NoError(t, err, "HandleRequest() returned unexpected error") if test.wantReqCtx != nil { - if diff := cmp.Diff(test.wantReqCtx.Model, reqCtx.Model); diff != "" { - t.Errorf("HandleRequestBody returned unexpected reqCtx.Model, diff(-want, +got): %v", diff) - } - if diff := cmp.Diff(test.wantReqCtx.ResolvedTargetModel, reqCtx.ResolvedTargetModel); diff != "" { - t.Errorf("HandleRequestBody returned unexpected reqCtx.ResolvedTargetModel, diff(-want, +got): %v", diff) - } - if diff := cmp.Diff(test.wantReqCtx.TargetPod, reqCtx.TargetPod); diff != "" { - t.Errorf("HandleRequestBody returned unexpected reqCtx.TargetPod, diff(-want, +got): %v", diff) - } - if diff := cmp.Diff(test.wantReqCtx.TargetEndpoint, reqCtx.TargetEndpoint); diff != "" { - t.Errorf("HandleRequestBody returned unexpected reqCtx.TargetEndpoint, diff(-want, +got): %v", diff) - } + assert.Equal(t, test.wantReqCtx.Model, returnedReqCtx.Model, "reqCtx.Model mismatch") + assert.Equal(t, test.wantReqCtx.ResolvedTargetModel, returnedReqCtx.ResolvedTargetModel, + "reqCtx.ResolvedTargetModel mismatch") + assert.Equal(t, test.wantReqCtx.TargetPod, returnedReqCtx.TargetPod, "reqCtx.TargetPod mismatch") + assert.Equal(t, test.wantReqCtx.TargetEndpoint, returnedReqCtx.TargetEndpoint, "reqCtx.TargetEndpoint mismatch") + } + + if test.wantMutatedBodyModel != "" { + assert.NotNil(t, returnedReqCtx.Request.Body, "Expected mutated body, but reqCtx.Request.Body is nil") + assert.Equal(t, test.wantMutatedBodyModel, returnedReqCtx.Request.Body["model"], + "Mutated reqCtx.Request.Body model mismatch") } }) } @@ -261,87 +391,59 @@ func TestHandleRequest(t *testing.T) { func TestRandomWeightedDraw(t *testing.T) { logger := logutil.NewTestLogger() + // Note: These tests verify deterministic outcomes for a fixed seed (420). + // They do not test the statistical properties of the random draw. tests := []struct { name string model *v1alpha2.InferenceModel want string }{ { - name: "'random' distribution", + name: "deterministic draw: 50/50 weights, seed 420", model: &v1alpha2.InferenceModel{ Spec: v1alpha2.InferenceModelSpec{ TargetModels: []v1alpha2.TargetModel{ - { - Name: "canary", - Weight: pointer(50), - }, - { - Name: "v1", - Weight: pointer(50), - }, + {Name: "canary", Weight: pointer(50)}, + {Name: "v1", Weight: pointer(50)}, }, }, }, want: "canary", }, { - name: "'random' distribution", + name: "deterministic draw: 25/55/50 weights, seed 420", model: &v1alpha2.InferenceModel{ Spec: v1alpha2.InferenceModelSpec{ TargetModels: []v1alpha2.TargetModel{ - { - Name: "canary", - Weight: pointer(25), - }, - { - Name: "v1.1", - Weight: pointer(55), - }, - { - Name: "v1", - Weight: pointer(50), - }, + {Name: "canary", Weight: pointer(25)}, + {Name: "v1.1", Weight: pointer(55)}, + {Name: "v1", Weight: pointer(50)}, }, }, }, want: "v1", }, { - name: "'random' distribution", + name: "deterministic draw: 20/20/10 weights, seed 420", model: &v1alpha2.InferenceModel{ Spec: v1alpha2.InferenceModelSpec{ TargetModels: []v1alpha2.TargetModel{ - { - Name: "canary", - Weight: pointer(20), - }, - { - Name: "v1.1", - Weight: pointer(20), - }, - { - Name: "v1", - Weight: pointer(10), - }, + {Name: "canary", Weight: pointer(20)}, + {Name: "v1.1", Weight: pointer(20)}, + {Name: "v1", Weight: pointer(10)}, }, }, }, want: "v1.1", }, { - name: "weighted distribution with weight unset", + name: "deterministic draw: nil weights (uniform), seed 420", model: &v1alpha2.InferenceModel{ Spec: v1alpha2.InferenceModelSpec{ TargetModels: []v1alpha2.TargetModel{ - { - Name: "canary", - }, - { - Name: "v1.1", - }, - { - Name: "v1", - }, + {Name: "canary"}, + {Name: "v1.1"}, + {Name: "v1"}, }, }, }, @@ -351,13 +453,8 @@ func TestRandomWeightedDraw(t *testing.T) { var seedVal int64 = 420 for _, test := range tests { t.Run(test.name, func(t *testing.T) { - for range 10000 { - model := RandomWeightedDraw(logger, test.model, seedVal) - if model != test.want { - t.Errorf("Model returned: %v != %v", model, test.want) - break - } - } + model := RandomWeightedDraw(logger, test.model, seedVal) + assert.Equal(t, test.want, model, "RandomWeightedDraw() with seed %d should produce expected model", seedVal) }) } } @@ -393,7 +490,7 @@ func TestGetRandomPod(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - pmf := metrics.NewPodMetricsFactory(&metrics.FakePodMetricsClient{}, time.Millisecond) + pmf := backendmetrics.NewPodMetricsFactory(&backendmetrics.FakePodMetricsClient{}, time.Millisecond) ds := datastore.NewDatastore(t.Context(), pmf) for _, pod := range test.storePods { ds.PodUpdateOrAddIfNotExist(pod) @@ -414,3 +511,37 @@ func TestGetRandomPod(t *testing.T) { func pointer(v int32) *int32 { return &v } + +func TestDirector_HandleResponse(t *testing.T) { + ctx := logutil.NewTestLoggerIntoContext(context.Background()) + ds := datastore.NewDatastore(t.Context(), nil) + mockSched := &mockScheduler{} + director := NewDirector(ds, mockSched, nil) + + reqCtx := &handlers.RequestContext{ + Request: &handlers.Request{ + Headers: map[string]string{ + requtil.RequestIdHeaderKey: "test-req-id-for-response", + }, + }, + Response: &handlers.Response{ // Simulate some response headers + Headers: map[string]string{"X-Test-Response-Header": "TestValue"}, + }, + TargetPod: "namespace1/test-pod-name", + } + + _, err := director.HandleResponse(ctx, reqCtx) + if err != nil { + t.Fatalf("HandleResponse() returned unexpected error: %v", err) + } + + if diff := cmp.Diff("test-req-id-for-response", mockSched.lastRespOnResponse.RequestId); diff != "" { + t.Errorf("Scheduler.OnResponse RequestId mismatch (-want +got):\n%s", diff) + } + if diff := cmp.Diff(reqCtx.Response.Headers, mockSched.lastRespOnResponse.Headers); diff != "" { + t.Errorf("Scheduler.OnResponse Headers mismatch (-want +got):\n%s", diff) + } + if diff := cmp.Diff("namespace1/test-pod-name", mockSched.lastTargetPodOnResponse); diff != "" { + t.Errorf("Scheduler.OnResponse TargetPodName mismatch (-want +got):\n%s", diff) + } +} diff --git a/pkg/epp/server/runserver.go b/pkg/epp/server/runserver.go index 69f4805dd..3dd1d58bd 100644 --- a/pkg/epp/server/runserver.go +++ b/pkg/epp/server/runserver.go @@ -49,8 +49,9 @@ type ExtProcServerRunner struct { CertPath string RefreshPrometheusMetricsInterval time.Duration Scheduler requestcontrol.Scheduler + SaturationDetector requestcontrol.SaturationDetector - // This should only be used in tests. We won't need this once we don't inject metrics in the tests. + // This should only be used in tests. We won't need this once we do not inject metrics in the tests. // TODO:(https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/432) Cleanup TestPodMetricsClient *backendmetrics.FakePodMetricsClient } @@ -67,6 +68,8 @@ const ( DefaultSecureServing = true // default for --secureServing ) +// NewDefaultExtProcServerRunner creates a runner with default values. +// Note: Dependencies like Datastore, Scheduler, SD need to be set separately. func NewDefaultExtProcServerRunner() *ExtProcServerRunner { return &ExtProcServerRunner{ GrpcPort: DefaultGrpcPort, @@ -75,7 +78,7 @@ func NewDefaultExtProcServerRunner() *ExtProcServerRunner { PoolNamespacedName: types.NamespacedName{Name: DefaultPoolName, Namespace: DefaultPoolNamespace}, SecureServing: DefaultSecureServing, RefreshPrometheusMetricsInterval: DefaultRefreshPrometheusMetricsInterval, - // Datastore can be assigned later. + // Dependencies can be assigned later. } } @@ -137,7 +140,14 @@ func (r *ExtProcServerRunner) AsRunnable(logger logr.Logger) manager.Runnable { } else { srv = grpc.NewServer() } - extProcServer := handlers.NewStreamingServer(r.DestinationEndpointHintMetadataNamespace, r.DestinationEndpointHintKey, r.Datastore, requestcontrol.NewDirector(r.Datastore, r.Scheduler)) + + director := requestcontrol.NewDirector(r.Datastore, r.Scheduler, r.SaturationDetector) + extProcServer := handlers.NewStreamingServer( + r.DestinationEndpointHintMetadataNamespace, + r.DestinationEndpointHintKey, + r.Datastore, + director, + ) extProcPb.RegisterExternalProcessorServer( srv, extProcServer, diff --git a/pkg/epp/util/env/env.go b/pkg/epp/util/env/env.go index 7ea7c3c0c..a584f95e7 100644 --- a/pkg/epp/util/env/env.go +++ b/pkg/epp/util/env/env.go @@ -41,6 +41,12 @@ func GetEnvInt(key string, defaultVal int, logger logr.Logger) int { return getEnvWithParser(key, defaultVal, strconv.Atoi, logger) } +// GetEnvUint64 gets a uint64 from an environment variable with a default value. +func GetEnvUint64(key string, defaultVal uint64, logger logr.Logger) uint64 { + parser := func(s string) (uint64, error) { return strconv.ParseUint(s, 10, 64) } + return getEnvWithParser(key, defaultVal, parser, logger) +} + // GetEnvDuration gets a time.Duration from an environment variable with a default value. func GetEnvDuration(key string, defaultVal time.Duration, logger logr.Logger) time.Duration { return getEnvWithParser(key, defaultVal, time.ParseDuration, logger) @@ -51,3 +57,8 @@ func GetEnvString(key string, defaultVal string, logger logr.Logger) string { parser := func(s string) (string, error) { return s, nil } return getEnvWithParser(key, defaultVal, parser, logger) } + +// GetEnvBool gets a boolean from an environment variable with a default value. +func GetEnvBool(key string, defaultVal bool, logger logr.Logger) bool { + return getEnvWithParser(key, defaultVal, strconv.ParseBool, logger) +}