Repository: kubernetes-sigs/gateway-api-inference-extension

Issue:
Optionally allow Ready Status to be affiliated with Leader Election
To support an active-passive deployment in environments that cant use Envoy's OOTB [failover](https://gateway.envoyproxy.io/docs/tasks/traffic/failover/) solution. A short-term hack is proposed:

- With multiple EPP replicas, use leader election to determine a leader, set the leaders Ready condition to `True`
- The other replicas remain NotReady indefinitely
- Should the leader go unhealthy, a newly elected Leader is marked as healthy

This issue would cover both the investigation and implementation of this behavior (which should be enabled via flag or, more preferrably, environment variable as this is hopefully a temporary fix)

Hints from issue comments:
@yangligt2: I will look into this issue.@kfswain: Thanks @yangligt2!
@ahg-g: Is this done?

Solution patch:
diff --git a/apix/v1alpha2/inferenceobjective_types.go b/apix/v1alpha2/inferenceobjective_types.go
index 6ed7c2c..07f2c4d 100644
--- a/apix/v1alpha2/inferenceobjective_types.go
+++ b/apix/v1alpha2/inferenceobjective_types.go
@@ -27,7 +27,7 @@ import (
 // +kubebuilder:storageversion
 // +kubebuilder:printcolumn:name="Model Name",type=string,JSONPath=`.spec.modelName`
 // +kubebuilder:printcolumn:name="Inference Pool",type=string,JSONPath=`.spec.poolRef.name`
-// +kubebuilder:printcolumn:name="Criticality",type=string,JSONPath=`.spec.criticality`
+// +kubebuilder:printcolumn:name="Priority",type=string,JSONPath=`.spec.priority`
 // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
 // +genclient
 type InferenceObjective struct {
@@ -64,20 +64,20 @@ type InferenceObjectiveList struct {
 // condition, one will be selected at random.
 type InferenceObjectiveSpec struct {
 
-	// Criticality defines how important it is to serve the request compared to other requests in the same pool.
-	// Criticality is an integer value that defines the priority of the request.
+	// Priority defines how important it is to serve the request compared to other requests in the same pool.
+	// Priority is an integer value that defines the priority of the request.
 	// The higher the value, the more critical the request is; negative values _are_ allowed.
 	// No default value is set for this field, allowing for future additions of new fields that may 'one of' with this field.
 	// However, implementations that consume this field (such as the Endpoint Picker) will treat an unset value as '0'.
-	// Criticality is used in flow control, primarily in the event of resource scarcity(reqeusts need to be queued).
-	// All requests will be queued, and flow control will _always_ allow requests of higher criticality to be served first.
-	// Fairness is only enforced and tracked between requests of the same criticality.
+	// Priority is used in flow control, primarily in the event of resource scarcity(reqeusts need to be queued).
+	// All requests will be queued, and flow control will _always_ allow requests of higher priority to be served first.
+	// Fairness is only enforced and tracked between requests of the same priority.
 	//
-	// Example: requests with Criticality 10 will always be served before
-	// requests with Criticality of 0(the value used if Criticality is unset or no InfereneceObjective is specified).
-	// Similarly requests with a Criticality of -10 will always be served after requests with Criticality of 0.
+	// Example: requests with Priority 10 will always be served before
+	// requests with Priority of 0 (the value used if Priority is unset or no InfereneceObjective is specified).
+	// Similarly requests with a Priority of -10 will always be served after requests with Priority of 0.
 	// +optional
-	Criticality *int `json:"criticality,omitempty"`
+	Priority *int `json:"priority,omitempty"`
 
 	// PoolRef is a reference to the inference pool, the pool must exist in the same namespace.
 	//
diff --git a/apix/v1alpha2/zz_generated.deepcopy.go b/apix/v1alpha2/zz_generated.deepcopy.go
index 19436e7..42195d1 100644
--- a/apix/v1alpha2/zz_generated.deepcopy.go
+++ b/apix/v1alpha2/zz_generated.deepcopy.go
@@ -174,8 +174,8 @@ func (in *InferenceObjectiveList) DeepCopyObject() runtime.Object {
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *InferenceObjectiveSpec) DeepCopyInto(out *InferenceObjectiveSpec) {
 	*out = *in
-	if in.Criticality != nil {
-		in, out := &in.Criticality, &out.Criticality
+	if in.Priority != nil {
+		in, out := &in.Priority, &out.Priority
 		*out = new(int)
 		**out = **in
 	}
diff --git a/client-go/applyconfiguration/apix/v1alpha2/inferenceobjectivespec.go b/client-go/applyconfiguration/apix/v1alpha2/inferenceobjectivespec.go
index d168670..6fbcf33 100644
--- a/client-go/applyconfiguration/apix/v1alpha2/inferenceobjectivespec.go
+++ b/client-go/applyconfiguration/apix/v1alpha2/inferenceobjectivespec.go
@@ -21,8 +21,8 @@ package v1alpha2
 // InferenceObjectiveSpecApplyConfiguration represents a declarative configuration of the InferenceObjectiveSpec type for use
 // with apply.
 type InferenceObjectiveSpecApplyConfiguration struct {
-	Criticality *int                                   `json:"criticality,omitempty"`
-	PoolRef     *PoolObjectReferenceApplyConfiguration `json:"poolRef,omitempty"`
+	Priority *int                                   `json:"priority,omitempty"`
+	PoolRef  *PoolObjectReferenceApplyConfiguration `json:"poolRef,omitempty"`
 }
 
 // InferenceObjectiveSpecApplyConfiguration constructs a declarative configuration of the InferenceObjectiveSpec type for use with
@@ -31,11 +31,11 @@ func InferenceObjectiveSpec() *InferenceObjectiveSpecApplyConfiguration {
 	return &InferenceObjectiveSpecApplyConfiguration{}
 }
 
-// WithCriticality sets the Criticality field in the declarative configuration to the given value
+// WithPriority sets the Priority field in the declarative configuration to the given value
 // and returns the receiver, so that objects can be built by chaining "With" function invocations.
-// If called multiple times, the Criticality field is set to the value of the last call.
-func (b *InferenceObjectiveSpecApplyConfiguration) WithCriticality(value int) *InferenceObjectiveSpecApplyConfiguration {
-	b.Criticality = &value
+// If called multiple times, the Priority field is set to the value of the last call.
+func (b *InferenceObjectiveSpecApplyConfiguration) WithPriority(value int) *InferenceObjectiveSpecApplyConfiguration {
+	b.Priority = &value
 	return b
 }
 
diff --git a/cmd/epp/runner/health.go b/cmd/epp/runner/health.go
index cc5da8d..6e63e17 100644
--- a/cmd/epp/runner/health.go
+++ b/cmd/epp/runner/health.go
@@ -18,6 +18,8 @@ package runner
 
 import (
 	"context"
+	"fmt"
+	"sync/atomic"
 
 	extProcPb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3"
 	"github.com/go-logr/logr"
@@ -30,37 +32,83 @@ import (
 )
 
 type healthServer struct {
-	logger    logr.Logger
-	datastore datastore.Datastore
+	logger                logr.Logger
+	datastore             datastore.Datastore
+	isLeader              *atomic.Bool
+	leaderElectionEnabled bool
 }
 
+const (
+	LivenessCheckService  = "liveness"
+	ReadinessCheckService = "readiness"
+)
+
 func (s *healthServer) Check(ctx context.Context, in *healthPb.HealthCheckRequest) (*healthPb.HealthCheckResponse, error) {
-	// TODO: we're accepting ANY service name for now as a temporary hack in alignment with
-	// upstream issues. See https://github.com/kubernetes-sigs/gateway-api-inference-extension/pull/788
-	// if in.Service != extProcPb.ExternalProcessor_ServiceDesc.ServiceName {
-	// 	s.logger.V(logutil.DEFAULT).Info("gRPC health check requested unknown service", "available-services", []string{extProcPb.ExternalProcessor_ServiceDesc.ServiceName}, "requested-service", in.Service)
-	// 	return &healthPb.HealthCheckResponse{Status: healthPb.HealthCheckResponse_SERVICE_UNKNOWN}, nil
-	// }
-
-	if !s.datastore.PoolHasSynced() {
-		s.logger.V(logutil.DEFAULT).Info("gRPC health check not serving", "service", in.Service)
+	isLive := s.datastore.PoolHasSynced()
+
+	// If leader election is disabled, use current logic: all checks are based on whether the pool has synced.
+	if !s.leaderElectionEnabled {
+		if !isLive {
+			s.logger.V(logutil.DEFAULT).Info("gRPC health check not serving (leader election disabled)", "service", in.Service)
+			return &healthPb.HealthCheckResponse{Status: healthPb.HealthCheckResponse_NOT_SERVING}, nil
+		}
+		s.logger.V(logutil.TRACE).Info("gRPC health check serving (leader election disabled)", "service", in.Service)
+		return &healthPb.HealthCheckResponse{Status: healthPb.HealthCheckResponse_SERVING}, nil
+	}
+
+	// When leader election is enabled, differentiate between liveness and readiness.
+	// The service name in the request determines which check to perform.
+	var checkName string
+	var isPassing bool
+
+	switch in.Service {
+	case ReadinessCheckService:
+		checkName = "readiness"
+		isPassing = isLive && s.isLeader.Load()
+	case LivenessCheckService, "": // Default to liveness check if service is empty
+		checkName = "liveness"
+		// Any pod that is running and can respond to this gRPC check is considered "live".
+		// The datastore sync status should not affect liveness, only readiness.
+		// This is to prevent the non-leader node from continuous restarts
+		isPassing = true
+	case extProcPb.ExternalProcessor_ServiceDesc.ServiceName:
+		// The main service is considered ready only on the leader.
+		checkName = "ext_proc"
+		isPassing = isLive && s.isLeader.Load()
+	default:
+		s.logger.V(logutil.DEFAULT).Info("gRPC health check requested unknown service", "available-services", []string{LivenessCheckService, ReadinessCheckService, extProcPb.ExternalProcessor_ServiceDesc.ServiceName}, "requested-service", in.Service)
+		return &healthPb.HealthCheckResponse{Status: healthPb.HealthCheckResponse_SERVICE_UNKNOWN}, nil
+	}
+
+	if !isPassing {
+		s.logger.V(logutil.DEFAULT).Info(fmt.Sprintf("gRPC %s check not serving", checkName), "service", in.Service, "isLive", isLive, "isLeader", s.isLeader.Load())
 		return &healthPb.HealthCheckResponse{Status: healthPb.HealthCheckResponse_NOT_SERVING}, nil
 	}
-	s.logger.V(logutil.TRACE).Info("gRPC health check serving", "service", in.Service)
+
+	s.logger.V(logutil.TRACE).Info(fmt.Sprintf("gRPC %s check serving", checkName), "service", in.Service)
 	return &healthPb.HealthCheckResponse{Status: healthPb.HealthCheckResponse_SERVING}, nil
 }
 
 func (s *healthServer) List(ctx context.Context, _ *healthPb.HealthListRequest) (*healthPb.HealthListResponse, error) {
-	// currently only the ext_proc service is provided
-	serviceHealthResponse, err := s.Check(ctx, &healthPb.HealthCheckRequest{Service: extProcPb.ExternalProcessor_ServiceDesc.ServiceName})
-	if err != nil {
-		return nil, err
+	statuses := make(map[string]*healthPb.HealthCheckResponse)
+
+	services := []string{extProcPb.ExternalProcessor_ServiceDesc.ServiceName}
+	if s.leaderElectionEnabled {
+		services = append(services, LivenessCheckService, ReadinessCheckService)
+	}
+
+	for _, service := range services {
+		resp, err := s.Check(ctx, &healthPb.HealthCheckRequest{Service: service})
+		if err != nil {
+			// Check can return an error for unknown services, but here we are iterating known services.
+			// If another error occurs, we should probably return it.
+			return nil, err
+		}
+		statuses[service] = resp
 	}
 
 	return &healthPb.HealthListResponse{
-		Statuses: map[string]*healthPb.HealthCheckResponse{
-			extProcPb.ExternalProcessor_ServiceDesc.ServiceName: serviceHealthResponse,
-		},
+		Statuses: statuses,
 	}, nil
 }
 
diff --git a/cmd/epp/runner/runner.go b/cmd/epp/runner/runner.go
index c450588..c614b21 100644
--- a/cmd/epp/runner/runner.go
+++ b/cmd/epp/runner/runner.go
@@ -25,6 +25,7 @@ import (
 	"net/http"
 	"net/http/pprof"
 	"os"
+	"sync/atomic"
 
 	"github.com/go-logr/logr"
 	"github.com/prometheus/client_golang/prometheus"
@@ -151,6 +152,10 @@ var (
 	modelServerMetricsPath                    = flag.String("model-server-metrics-path", "/metrics", "Path to scrape metrics from pods")
 	modelServerMetricsScheme                  = flag.String("model-server-metrics-scheme", "http", "Scheme to scrape metrics from pods")
 	modelServerMetricsHttpsInsecureSkipVerify = flag.Bool("model-server-metrics-https-insecure-skip-verify", true, "When using 'https' scheme for 'model-server-metrics-scheme', configure 'InsecureSkipVerify' (default to true)")
+	haEnableLeaderElection                    = flag.Bool(
+		"ha-enable-leader-election",
+		false,
+		"Enables leader election for high availability. When enabled, readiness probes will only pass on the leader.")
 
 	setupLog = ctrl.Log.WithName("setup")
 )
@@ -190,8 +195,9 @@ func bindEnvToFlags() {
 		"POOL_NAME":                                       "pool-name",
 		"POOL_NAMESPACE":                                  "pool-namespace",
 		// durations & bools work too; flag.Set expects the *string* form
-		"REFRESH_METRICS_INTERVAL": "refresh-metrics-interval",
-		"SECURE_SERVING":           "secure-serving",
+		"REFRESH_METRICS_INTERVAL":  "refresh-metrics-interval",
+		"SECURE_SERVING":            "secure-serving",
+		"HA_ENABLE_LEADER_ELECTION": "ha-enable-leader-election",
 	} {
 		if v := os.Getenv(env); v != "" {
 			// ignore error; Parse() will catch invalid values later
@@ -299,12 +305,28 @@ func (r *Runner) Run(ctx context.Context) error {
 		NamespacedName: poolNamespacedName,
 		GroupKind:      poolGroupKind,
 	}
-	mgr, err := runserver.NewDefaultManager(poolGKNN, cfg, metricsServerOptions)
+
+	isLeader := &atomic.Bool{}
+	isLeader.Store(false)
+
+	mgr, err := runserver.NewDefaultManager(poolGKNN, cfg, metricsServerOptions, *haEnableLeaderElection)
 	if err != nil {
 		setupLog.Error(err, "Failed to create controller manager")
 		return err
 	}
 
+	if *haEnableLeaderElection {
+		setupLog.Info("Leader election enabled")
+		go func() {
+			<-mgr.Elected()
+			isLeader.Store(true)
+			setupLog.Info("This instance is now the leader!")
+		}()
+	} else {
+		// If leader election is disabled, all instances are "leaders" for readiness purposes.
+		isLeader.Store(true)
+	}
+
 	if *enablePprof {
 		setupLog.Info("Enabling pprof handlers")
 		err = setupPprofHandlers(mgr)
@@ -356,7 +378,7 @@ func (r *Runner) Run(ctx context.Context) error {
 
 	// --- Add Runnables to Manager ---
 	// Register health server.
-	if err := registerHealthServer(mgr, ctrl.Log.WithName("health"), datastore, *grpcHealthPort); err != nil {
+	if err := registerHealthServer(mgr, ctrl.Log.WithName("health"), datastore, *grpcHealthPort, isLeader, *haEnableLeaderElection); err != nil {
 		return err
 	}
 
@@ -452,11 +474,13 @@ func registerExtProcServer(mgr manager.Manager, runner *runserver.ExtProcServerR
 }
 
 // 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 {
+func registerHealthServer(mgr manager.Manager, logger logr.Logger, ds datastore.Datastore, port int, isLeader *atomic.Bool, leaderElectionEnabled bool) error {
 	srv := grpc.NewServer()
 	healthPb.RegisterHealthServer(srv, &healthServer{
-		logger:    logger,
-		datastore: ds,
+		logger:                logger,
+		datastore:             ds,
+		isLeader:              isLeader,
+		leaderElectionEnabled: leaderElectionEnabled,
 	})
 	if err := mgr.Add(
 		runnable.NoLeaderElection(runnable.GRPCServer("health", srv, port))); err != nil {
diff --git a/config/charts/inferencepool/README.md b/config/charts/inferencepool/README.md
index b8a8d0a..edc01d2 100644
--- a/config/charts/inferencepool/README.md
+++ b/config/charts/inferencepool/README.md
@@ -79,6 +79,24 @@ $ helm install triton-llama3-8b-instruct \
   oci://us-central1-docker.pkg.dev/k8s-staging-images/gateway-api-inference-extension/charts/inferencepool --version v0
 ```
 
+### Install with High Availability (HA)
+
+To deploy the EndpointPicker in a high-availability (HA) active-passive configuration, you can enable leader election. When enabled, the EPP deployment will have multiple replicas, but only one "leader" replica will be active and ready to process traffic at any given time. If the leader pod fails, another pod will be elected as the new leader, ensuring service continuity.
+
+To enable HA, set `inferenceExtension.enableLeaderElection` to `true` and increase the number of replicas in your `values.yaml` file:
+
+```yaml
+inferenceExtension:
+  replicas: 3
+  enableLeaderElection: true
+```
+
+Then apply it with:
+
+```txt
+helm install vllm-llama3-8b-instruct ./config/charts/inferencepool -f values.yaml \
+```
+
 ## Uninstall
 
 Run the following command to uninstall the chart:
@@ -107,6 +125,8 @@ The following table list the configurable parameters of the chart.
 | `inferenceExtension.extraServicePorts`      | List of additional service ports to expose. Defaults to `[]`.                                                         |
 | `inferenceExtension.logVerbosity`           | Logging verbosity level for the endpoint picker. Defaults to `"3"`.                                                   |
 | `provider.name`                             | Name of the Inference Gateway implementation being used. Possible values: `gke`. Defaults to `none`.                   |
+| `inferenceExtension.enableLeaderElection`   | Enable leader election for high availability. When enabled, only one EPP pod (the leader) will be ready to serve traffic. It is recommended to set `inferenceExtension.replicas` to a value greater than 1 when this is set to `true`. Defaults to `false`. |
+
 
 ## Notes
 
diff --git a/config/charts/inferencepool/templates/epp-deployment.yaml b/config/charts/inferencepool/templates/epp-deployment.yaml
index 23957a3..fc15449 100644
--- a/config/charts/inferencepool/templates/epp-deployment.yaml
+++ b/config/charts/inferencepool/templates/epp-deployment.yaml
@@ -44,6 +44,9 @@ spec:
         - "--model-server-metrics-path={{ .Values.inferenceExtension.modelServerMetricsPath }}"
         - "--model-server-metrics-scheme={{ .Values.inferenceExtension.modelServerMetricsScheme }}"
         - "--model-server-metrics-https-insecure-skip-verify={{ .Values.inferenceExtension.modelServerMetricsHttpsInsecureSkipVerify }}"
+        {{- if .Values.inferenceExtension.enableLeaderElection }}
+        - "--ha-enable-leader-election"
+        {{- end }}
         {{- if eq (.Values.inferencePool.modelServerType | default "vllm") "triton-tensorrt-llm" }}
         - --total-queued-requests-metric
         - "nv_trt_llm_request_metrics{request_type=waiting}"
@@ -63,15 +66,27 @@ spec:
         {{- toYaml . | nindent 8 }}
         {{- end }}
         livenessProbe:
+          {{- if .Values.inferenceExtension.enableLeaderElection }}
+          grpc:
+            port: 9003
+            service: liveness
+          {{- else }}
           grpc:
             port: 9003
             service: inference-extension
+          {{- end }}
           initialDelaySeconds: 5
           periodSeconds: 10
         readinessProbe:
+          {{- if .Values.inferenceExtension.enableLeaderElection }}
+          grpc:
+            port: 9003
+            service: readiness
+          {{- else }}
           grpc:
             port: 9003
             service: inference-extension
+          {{- end }}
           initialDelaySeconds: 5
           periodSeconds: 10
         {{- with .Values.inferenceExtension.env }}
diff --git a/config/charts/inferencepool/templates/leader-election-rbac.yaml b/config/charts/inferencepool/templates/leader-election-rbac.yaml
new file mode 100644
index 0000000..923bdd6
--- /dev/null
+++ b/config/charts/inferencepool/templates/leader-election-rbac.yaml
@@ -0,0 +1,30 @@
+{{- if .Values.inferenceExtension.enableLeaderElection }}
+---
+kind: Role
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: {{ include "gateway-api-inference-extension.name" . }}-leader-election
+  namespace: {{ .Release.Namespace }}
+  labels:
+    {{- include "gateway-api-inference-extension.labels" . | nindent 4 }}
+rules:
+- apiGroups: [ "coordination.k8s.io" ]
+  resources: [ "leases" ]
+  verbs: [ "get", "list", "watch", "create", "update", "patch", "delete" ]
+- apiGroups: [ "" ]
+  resources: [ "events" ]
+  verbs: [ "create", "patch" ]
+---
+kind: RoleBinding
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: {{ include "gateway-api-inference-extension.name" . }}-leader-election-binding
+  namespace: {{ .Release.Namespace }}
+subjects:
+- kind: ServiceAccount
+  name: {{ include "gateway-api-inference-extension.name" . }}
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: Role
+  name: {{ include "gateway-api-inference-extension.name" . }}-leader-election
+{{- end }}
diff --git a/config/charts/inferencepool/values.yaml b/config/charts/inferencepool/values.yaml
index 2c295c1..95eb077 100644
--- a/config/charts/inferencepool/values.yaml
+++ b/config/charts/inferencepool/values.yaml
@@ -34,6 +34,9 @@ inferenceExtension:
   extraContainerPorts: []
   # Define additional service ports
   extraServicePorts: []
+  # Enable leader election for high availability. When enabled, it is recommended to set replicas > 1.
+  # Only the leader pod will be ready to serve traffic.
+  enableLeaderElection: false
 
 inferencePool:
   targetPortNumber: 8000
diff --git a/config/crd/bases/inference.networking.x-k8s.io_inferenceobjectives.yaml b/config/crd/bases/inference.networking.x-k8s.io_inferenceobjectives.yaml
index b340ea9..7f0968f 100644
--- a/config/crd/bases/inference.networking.x-k8s.io_inferenceobjectives.yaml
+++ b/config/crd/bases/inference.networking.x-k8s.io_inferenceobjectives.yaml
@@ -21,8 +21,8 @@ spec:
     - jsonPath: .spec.poolRef.name
       name: Inference Pool
       type: string
-    - jsonPath: .spec.criticality
-      name: Criticality
+    - jsonPath: .spec.priority
+      name: Priority
       type: string
     - jsonPath: .metadata.creationTimestamp
       name: Age
@@ -68,21 +68,6 @@ spec:
               creation timestamp, will be selected to remain valid. In the event of a race
               condition, one will be selected at random.
             properties:
-              criticality:
-                description: |-
-                  Criticality defines how important it is to serve the request compared to other requests in the same pool.
-                  Criticality is an integer value that defines the priority of the request.
-                  The higher the value, the more critical the request is; negative values _are_ allowed.
-                  No default value is set for this field, allowing for future additions of new fields that may 'one of' with this field.
-                  However, implementations that consume this field (such as the Endpoint Picker) will treat an unset value as '0'.
-                  Criticality is used in flow control, primarily in the event of resource scarcity(reqeusts need to be queued).
-                  All requests will be queued, and flow control will _always_ allow requests of higher criticality to be served first.
-                  Fairness is only enforced and tracked between requests of the same criticality.
-
-                  Example: requests with Criticality 10 will always be served before
-                  requests with Criticality of 0(the value used if Criticality is unset or no InfereneceObjective is specified).
-                  Similarly requests with a Criticality of -10 will always be served after requests with Criticality of 0.
-                type: integer
               poolRef:
                 description: PoolRef is a reference to the inference pool, the pool
                   must exist in the same namespace.
@@ -108,6 +93,21 @@ spec:
                 required:
                 - name
                 type: object
+              priority:
+                description: |-
+                  Priority defines how important it is to serve the request compared to other requests in the same pool.
+                  Priority is an integer value that defines the priority of the request.
+                  The higher the value, the more critical the request is; negative values _are_ allowed.
+                  No default value is set for this field, allowing for future additions of new fields that may 'one of' with this field.
+                  However, implementations that consume this field (such as the Endpoint Picker) will treat an unset value as '0'.
+                  Priority is used in flow control, primarily in the event of resource scarcity(reqeusts need to be queued).
+                  All requests will be queued, and flow control will _always_ allow requests of higher priority to be served first.
+                  Fairness is only enforced and tracked between requests of the same priority.
+
+                  Example: requests with Priority 10 will always be served before
+                  requests with Priority of 0 (the value used if Priority is unset or no InfereneceObjective is specified).
+                  Similarly requests with a Priority of -10 will always be served after requests with Priority of 0.
+                type: integer
             required:
             - poolRef
             type: object
diff --git a/config/manifests/inferenceobjective.yaml b/config/manifests/inferenceobjective.yaml
index b2346ed..3b7ba91 100644
--- a/config/manifests/inferenceobjective.yaml
+++ b/config/manifests/inferenceobjective.yaml
@@ -3,7 +3,7 @@ kind: InferenceObjective
 metadata:
   name: food-review
 spec:
-  criticality: 1
+  priority: 1
   poolRef:
     group: inference.networking.k8s.io
     name: vllm-llama3-8b-instruct
@@ -13,7 +13,7 @@ kind: InferenceObjective
 metadata:
   name: base-model
 spec:
-  criticality: 2
+  priority: 2
   poolRef:
     group: inference.networking.k8s.io
     name: vllm-llama3-8b-instruct
@@ -23,7 +23,7 @@ kind: InferenceObjective
 metadata:
   name: base-model-cpu
 spec:
-  criticality: 2
+  priority: 2
   poolRef:
     group: inference.networking.k8s.io
     name: vllm-llama3-8b-instruct
diff --git a/config/manifests/regression-testing/inferenceobjective.yaml b/config/manifests/regression-testing/inferenceobjective.yaml
index 70e5b50..eede557 100644
--- a/config/manifests/regression-testing/inferenceobjective.yaml
+++ b/config/manifests/regression-testing/inferenceobjective.yaml
@@ -3,7 +3,7 @@ kind: InferenceObjective
 metadata:
   name: adapter-0
 spec:
-  criticality: 2
+  priority: 2
   poolRef:
     group: inference.networking.k8s.io
     name: vllm-llama3-8b-instruct
@@ -15,7 +15,7 @@ kind: InferenceObjective
 metadata:
   name: adapter-1
 spec:
-  criticality: 2
+  priority: 2
   poolRef:
     group: inference.networking.k8s.io
     name: vllm-llama3-8b-instruct
@@ -27,7 +27,7 @@ kind: InferenceObjective
 metadata:
   name: adapter-2
 spec:
-  criticality: 2
+  priority: 2
   poolRef:
     group: inference.networking.k8s.io
     name: vllm-llama3-8b-instruct
@@ -39,7 +39,7 @@ kind: InferenceObjective
 metadata:
   name: adapter-3
 spec:
-  criticality: 2
+  priority: 2
   poolRef:
     group: inference.networking.k8s.io
     name: vllm-llama3-8b-instruct
@@ -51,7 +51,7 @@ kind: InferenceObjective
 metadata:
   name: adapter-4
 spec:
-  criticality: 2
+  priority: 2
   poolRef:
     group: inference.networking.k8s.io
     name: vllm-llama3-8b-instruct
@@ -63,7 +63,7 @@ kind: InferenceObjective
 metadata:
   name: adapter-5
 spec:
-  criticality: 2
+  priority: 2
   poolRef:
     group: inference.networking.k8s.io
     name: vllm-llama3-8b-instruct
@@ -75,7 +75,7 @@ kind: InferenceObjective
 metadata:
   name: adapter-6
 spec:
-  criticality: 2
+  priority: 2
   poolRef:
     group: inference.networking.k8s.io
     name: vllm-llama3-8b-instruct
@@ -87,7 +87,7 @@ kind: InferenceObjective
 metadata:
   name: adapter-7
 spec:
-  criticality: 2
+  priority: 2
   poolRef:
     group: inference.networking.k8s.io
     name: vllm-llama3-8b-instruct
@@ -99,7 +99,7 @@ kind: InferenceObjective
 metadata:
   name: adapter-8
 spec:
-  criticality: 2
+  priority: 2
   poolRef:
     group: inference.networking.k8s.io
     name: vllm-llama3-8b-instruct
@@ -111,7 +111,7 @@ kind: InferenceObjective
 metadata:
   name: adapter-9
 spec:
-  criticality: 2
+  priority: 2
   poolRef:
     group: inference.networking.k8s.io
     name: vllm-llama3-8b-instruct
@@ -123,7 +123,7 @@ kind: InferenceObjective
 metadata:
   name: adapter-10
 spec:
-  criticality: 2
+  priority: 2
   poolRef:
     group: inference.networking.k8s.io
     name: vllm-llama3-8b-instruct
@@ -135,7 +135,7 @@ kind: InferenceObjective
 metadata:
   name: adapter-11
 spec:
-  criticality: 2
+  priority: 2
   poolRef:
     group: inference.networking.k8s.io
     name: vllm-llama3-8b-instruct
@@ -147,7 +147,7 @@ kind: InferenceObjective
 metadata:
   name: adapter-12
 spec:
-  criticality: 2
+  priority: 2
   poolRef:
     group: inference.networking.k8s.io
     name: vllm-llama3-8b-instruct
@@ -160,7 +160,7 @@ kind: InferenceObjective
 metadata:
   name: adapter-13
 spec:
-  criticality: 2
+  priority: 2
   poolRef:
     group: inference.networking.k8s.io
     name: vllm-llama3-8b-instruct
@@ -173,7 +173,7 @@ kind: InferenceObjective
 metadata:
   name: adapter-14
 spec:
-  criticality: 2
+  priority: 2
   poolRef:
     group: inference.networking.k8s.io
     name: vllm-llama3-8b-instruct
@@ -186,7 +186,7 @@ kind: InferenceObjective
 metadata:
   name: base-model
 spec:
-  criticality: 2
+  priority: 2
   poolRef:
     group: inference.networking.k8s.io
-    name: vllm-llama3-8b-instruct
\ No newline at end of file
+    name: vllm-llama3-8b-instruct
diff --git a/conformance/resources/resourcename.go b/conformance/resources/resourcename.go
index 4c0c7b4..6f23840 100644
--- a/conformance/resources/resourcename.go
+++ b/conformance/resources/resourcename.go
@@ -34,6 +34,8 @@ const (
 
 	ModelServerPodReplicas    = 3
 	EndPointPickerPodReplicas = 1
+
+	InferenceObjName = "conformance-fake-model-server"
 )
 
 var (
diff --git a/go.mod b/go.mod
index 181e11b..619d8df 100644
--- a/go.mod
+++ b/go.mod
@@ -21,7 +21,7 @@ require (
 	go.uber.org/zap v1.27.0
 	golang.org/x/sync v0.16.0
 	google.golang.org/grpc v1.74.2
-	google.golang.org/protobuf v1.36.6
+	google.golang.org/protobuf v1.36.7
 	k8s.io/api v0.33.3
 	k8s.io/apiextensions-apiserver v0.33.3
 	k8s.io/apimachinery v0.33.3
diff --git a/go.sum b/go.sum
index 0a323e0..9ea103f 100644
--- a/go.sum
+++ b/go.sum
@@ -340,8 +340,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:
 google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
 google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
 google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
-google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
-google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
+google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
+google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
diff --git a/pkg/bbr/handlers/request.go b/pkg/bbr/handlers/request.go
index 98cd245..e7baec6 100644
--- a/pkg/bbr/handlers/request.go
+++ b/pkg/bbr/handlers/request.go
@@ -19,7 +19,6 @@ package handlers
 import (
 	"context"
 	"encoding/json"
-	"fmt"
 
 	basepb "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
 	eppb "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3"
@@ -31,18 +30,22 @@ import (
 
 const modelHeader = "X-Gateway-Model-Name"
 
+type RequestBody struct {
+	Model string `json:"model"`
+}
+
 // HandleRequestBody handles request bodies.
 func (s *Server) HandleRequestBody(ctx context.Context, requestBodyBytes []byte) ([]*eppb.ProcessingResponse, error) {
 	logger := log.FromContext(ctx)
 	var ret []*eppb.ProcessingResponse
 
-	var requestBody map[string]any
+	var requestBody RequestBody
 	if err := json.Unmarshal(requestBodyBytes, &requestBody); err != nil {
+		metrics.RecordModelNotParsedCounter()
 		return nil, err
 	}
 
-	modelVal, ok := requestBody["model"]
-	if !ok {
+	if requestBody.Model == "" {
 		metrics.RecordModelNotInBodyCounter()
 		logger.V(logutil.DEFAULT).Info("Request body does not contain model parameter")
 		if s.streaming {
@@ -63,13 +66,6 @@ func (s *Server) HandleRequestBody(ctx context.Context, requestBodyBytes []byte)
 		return ret, nil
 	}
 
-	modelStr, ok := modelVal.(string)
-	if !ok {
-		metrics.RecordModelNotParsedCounter()
-		logger.V(logutil.DEFAULT).Info("Model parameter value is not a string")
-		return nil, fmt.Errorf("the model parameter value %v is not a string", modelVal)
-	}
-
 	metrics.RecordSuccessCounter()
 
 	if s.streaming {
@@ -83,7 +79,7 @@ func (s *Server) HandleRequestBody(ctx context.Context, requestBodyBytes []byte)
 								{
 									Header: &basepb.HeaderValue{
 										Key:      modelHeader,
-										RawValue: []byte(modelStr),
+										RawValue: []byte(requestBody.Model),
 									},
 								},
 							},
@@ -108,7 +104,7 @@ func (s *Server) HandleRequestBody(ctx context.Context, requestBodyBytes []byte)
 								{
 									Header: &basepb.HeaderValue{
 										Key:      modelHeader,
-										RawValue: []byte(modelStr),
+										RawValue: []byte(requestBody.Model),
 									},
 								},
 							},
diff --git a/pkg/epp/plugins/plugin_state.go b/pkg/epp/plugins/plugin_state.go
index afd0f87..2da3557 100644
--- a/pkg/epp/plugins/plugin_state.go
+++ b/pkg/epp/plugins/plugin_state.go
@@ -109,17 +109,23 @@ func (s *PluginState) cleanup(ctx context.Context) {
 			log.FromContext(ctx).V(logutil.DEFAULT).Info("Shutting down plugin state cleanup")
 			return
 		case <-ticker.C:
-			s.requestToLastAccessTime.Range(func(k, v any) bool {
-				requestID := k.(string)
-				lastAccessTime := v.(time.Time)
-				if time.Since(lastAccessTime) > stalenessThreshold {
-					s.Delete(requestID) // cleanup stale requests (this is safe in sync.Map)
-				}
-				return true
-			})
+			s.cleanStaleRequests()
 		}
 	}
+}
 
+// cleanStaleRequests iterates through all requests and removes those that haven't been
+// accessed for longer than stalenessThreshold. This operation is safe to run concurrently
+// with other operations on the PluginState.
+func (s *PluginState) cleanStaleRequests() {
+	s.requestToLastAccessTime.Range(func(k, v any) bool {
+		requestID := k.(string)
+		lastAccessTime := v.(time.Time)
+		if time.Since(lastAccessTime) > stalenessThreshold {
+			s.Delete(requestID) // cleanup stale requests (this is safe in sync.Map)
+		}
+		return true
+	})
 }
 
 // ReadPluginStateKey retrieves data with the given key from PluginState and asserts it to type T.
diff --git a/pkg/epp/requestcontrol/director.go b/pkg/epp/requestcontrol/director.go
index e3b96f3..518da60 100644
--- a/pkg/epp/requestcontrol/director.go
+++ b/pkg/epp/requestcontrol/director.go
@@ -70,10 +70,10 @@ type Director struct {
 	saturationDetector  SaturationDetector
 	preRequestPlugins   []PreRequest
 	postResponsePlugins []PostResponse
-	// we just need a pointer to an int variable since criticality is a pointer in InferenceObjective
+	// we just need a pointer to an int variable since priority is a pointer in InferenceObjective
 	// no need to set this in the constructor, since the value we want is the default int val
 	// and value types cannot be nil
-	defaultCriticality int
+	defaultPriority int
 }
 
 // HandleRequest orchestrates the request lifecycle:
@@ -109,12 +109,12 @@ func (d *Director) HandleRequest(ctx context.Context, reqCtx *handlers.RequestCo
 		logger.V(logutil.VERBOSE).Info("No associated InferenceObjective found, using default", "objectiveKey", reqCtx.ObjectiveKey)
 		infObjective = &v1alpha2.InferenceObjective{
 			Spec: v1alpha2.InferenceObjectiveSpec{
-				Criticality: &d.defaultCriticality,
+				Priority: &d.defaultPriority,
 			},
 		}
-	} else if infObjective.Spec.Criticality == nil {
+	} else if infObjective.Spec.Priority == nil {
 		// Default to 0 if not specified.
-		infObjective.Spec.Criticality = &d.defaultCriticality
+		infObjective.Spec.Priority = &d.defaultPriority
 	}
 
 	// Prepare LLMRequest (needed for both saturation detection and Scheduler)
@@ -125,13 +125,13 @@ func (d *Director) HandleRequest(ctx context.Context, reqCtx *handlers.RequestCo
 		Headers:     reqCtx.Request.Headers,
 	}
 
-	logger = logger.WithValues("objectiveKey", reqCtx.ObjectiveKey, "incomingModelName", reqCtx.IncomingModelName, "targetModelName", reqCtx.TargetModelName, "criticality", infObjective.Spec.Criticality)
+	logger = logger.WithValues("objectiveKey", reqCtx.ObjectiveKey, "incomingModelName", reqCtx.IncomingModelName, "targetModelName", reqCtx.TargetModelName, "priority", infObjective.Spec.Priority)
 
 	ctx = log.IntoContext(ctx, logger)
 	logger.V(logutil.DEBUG).Info("LLM request assembled")
 
 	// --- 2. Admission Control check --
-	if err := d.admitRequest(ctx, *infObjective.Spec.Criticality, reqCtx.FairnessID); err != nil {
+	if err := d.admitRequest(ctx, *infObjective.Spec.Priority, reqCtx.FairnessID); err != nil {
 		return reqCtx, err
 	}
 
@@ -157,17 +157,17 @@ func (d *Director) HandleRequest(ctx context.Context, reqCtx *handlers.RequestCo
 }
 
 // admitRequest handles admission control to decide whether or not to accept the request
-// based on the request criticality and system saturation state.
-func (d *Director) admitRequest(ctx context.Context, requestCriticality int, fairnessID string) error {
+// based on the request priority and system saturation state.
+func (d *Director) admitRequest(ctx context.Context, requestPriority int, fairnessID string) error {
 	logger := log.FromContext(ctx)
 
-	logger.V(logutil.TRACE).Info("Entering Flow Control", "criticality", requestCriticality, "fairnessID", fairnessID)
+	logger.V(logutil.TRACE).Info("Entering Flow Control", "priority", requestPriority, "fairnessID", fairnessID)
 
 	// This will be removed in favor of a more robust implementation (Flow Control) in the very near future.
 	// For now we will keep similar behavior to the previous implementation.
 	// TODO: Make this a configurable value.
 	// Tracking issue https://github.com/kubernetes-sigs/gateway-api-inference-extension/issues/1347
-	if requestCriticality >= 2 {
+	if requestPriority >= 2 {
 		logger.V(logutil.DEBUG).Info("Critical request bypassing saturation check.")
 		return nil
 	}
diff --git a/pkg/epp/server/controller_manager.go b/pkg/epp/server/controller_manager.go
index 05038f7..47e4f12 100644
--- a/pkg/epp/server/controller_manager.go
+++ b/pkg/epp/server/controller_manager.go
@@ -85,12 +85,23 @@ func defaultManagerOptions(gknn common.GKNN, metricsServerOptions metricsserver.
 }
 
 // NewDefaultManager creates a new controller manager with default configuration.
-func NewDefaultManager(gknn common.GKNN, restConfig *rest.Config, metricsServerOptions metricsserver.Options) (ctrl.Manager, error) {
+func NewDefaultManager(gknn common.GKNN, restConfig *rest.Config, metricsServerOptions metricsserver.Options, leaderElectionEnabled bool) (ctrl.Manager, error) {
 	opt, err := defaultManagerOptions(gknn, metricsServerOptions)
 	if err != nil {
 		return nil, fmt.Errorf("failed to create controller manager options: %v", err)
 	}
+
+	if leaderElectionEnabled {
+		opt.LeaderElection = true
+		opt.LeaderElectionResourceLock = "leases"
+		// The lease name needs to be unique per EPP deployment.
+		opt.LeaderElectionID = fmt.Sprintf("epp-%s-%s.gateway-api-inference-extension.sigs.k8s.io", gknn.Namespace, gknn.Name)
+		opt.LeaderElectionNamespace = gknn.Namespace
+		opt.LeaderElectionReleaseOnCancel = true
+	}
+
 	manager, err := ctrl.NewManager(restConfig, opt)
+
 	if err != nil {
 		return nil, fmt.Errorf("failed to create controller manager: %v", err)
 	}



Test patch:
diff --git a/conformance/tests/epp_unavailable_fail_open.go b/conformance/tests/epp_unavailable_fail_open.go
index 95d17ad..8af5972 100644
--- a/conformance/tests/epp_unavailable_fail_open.go
+++ b/conformance/tests/epp_unavailable_fail_open.go
@@ -30,7 +30,7 @@ import (
 	k8sutils "sigs.k8s.io/gateway-api-inference-extension/conformance/utils/kubernetes"
 	trafficutils "sigs.k8s.io/gateway-api-inference-extension/conformance/utils/traffic"
 	"sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metadata"
-	testfilter "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework/plugins/test/filter"
+	"sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework/plugins/test"
 )
 
 func init() {
@@ -54,7 +54,6 @@ var EppUnAvailableFailOpen = suite.ConformanceTest{
                 "model": "conformance-fake-model",
                 "prompt": "Write as if you were a critic: San Francisco"
             }`
-			inferenceObjName = "conformance-fake-model-server"
 		)
 
 		httpRouteNN := types.NamespacedName{Name: "httproute-for-failopen-pool-gw", Namespace: resources.AppBackendNamespace}
@@ -80,8 +79,8 @@ var EppUnAvailableFailOpen = suite.ConformanceTest{
 					Host: hostname,
 					Path: path,
 					Headers: map[string]string{
-						testfilter.HeaderTestEppEndPointSelectionKey: targetPodIP,
-						metadata.ObjectiveKey:                        inferenceObjName,
+						test.HeaderTestEppEndPointSelectionKey: targetPodIP,
+						metadata.ObjectiveKey:                  resources.InferenceObjName,
 					},
 					Method:    http.MethodPost,
 					Body:      requestBody,
@@ -108,8 +107,8 @@ var EppUnAvailableFailOpen = suite.ConformanceTest{
 					Host: hostname,
 					Path: path,
 					Headers: map[string]string{
-						testfilter.HeaderTestEppEndPointSelectionKey: targetPodIP,
-						metadata.ObjectiveKey:                        inferenceObjName,
+						test.HeaderTestEppEndPointSelectionKey: targetPodIP,
+						metadata.ObjectiveKey:                  resources.InferenceObjName,
 					},
 					Method:    http.MethodPost,
 					Body:      requestBody,
diff --git a/conformance/tests/gateway_following_epp_routing.go b/conformance/tests/gateway_following_epp_routing.go
index 196e196..30a6dde 100644
--- a/conformance/tests/gateway_following_epp_routing.go
+++ b/conformance/tests/gateway_following_epp_routing.go
@@ -34,7 +34,7 @@ import (
 	k8sutils "sigs.k8s.io/gateway-api-inference-extension/conformance/utils/kubernetes"
 	"sigs.k8s.io/gateway-api-inference-extension/conformance/utils/traffic"
 	"sigs.k8s.io/gateway-api-inference-extension/pkg/epp/metadata"
-	testfilter "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework/plugins/test/filter"
+	"sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework/plugins/test"
 )
 
 func init() {
@@ -55,7 +55,6 @@ var GatewayFollowingEPPRouting = suite.ConformanceTest{
 			hostname            = "primary.example.com"
 			path                = "/primary-gateway-test"
 			appPodBackendPrefix = "primary-inference-model-server"
-			inferenceObjName    = "conformance-fake-model-server"
 		)
 
 		httpRouteNN := types.NamespacedName{Name: "httproute-for-primary-gw", Namespace: resources.AppBackendNamespace}
@@ -97,8 +96,8 @@ var GatewayFollowingEPPRouting = suite.ConformanceTest{
 					Host: hostname,
 					Path: path,
 					Headers: map[string]string{
-						testfilter.HeaderTestEppEndPointSelectionKey: podIPs[i],
-						metadata.ObjectiveKey:                        inferenceObjName,
+						test.HeaderTestEppEndPointSelectionKey: podIPs[i],
+						metadata.ObjectiveKey:                  resources.InferenceObjName,
 					},
 					Method:    http.MethodPost,
 					Body:      requestBody,
@@ -134,11 +133,11 @@ var GatewayFollowingEPPRouting = suite.ConformanceTest{
 			t.Run(tc.name, func(t *testing.T) {
 				eppHeaderValue := strings.Join(tc.podIPsToBeReturnedByEPP, ",")
 				headers := map[string]string{
-					testfilter.HeaderTestEppEndPointSelectionKey: eppHeaderValue,
-					metadata.ObjectiveKey:                        inferenceObjName,
+					test.HeaderTestEppEndPointSelectionKey: eppHeaderValue,
+					metadata.ObjectiveKey:                  resources.InferenceObjName,
 				}
 
-				t.Logf("Sending request to %s with EPP header '%s: %s'", gwAddr, testfilter.HeaderTestEppEndPointSelectionKey, eppHeaderValue)
+				t.Logf("Sending request to %s with EPP header '%s: %s'", gwAddr, test.HeaderTestEppEndPointSelectionKey, eppHeaderValue)
 				t.Logf("Expecting traffic to be routed to pod: %v", tc.expectAllRequestsRoutedWithinPodNames)
 
 				assertTrafficOnlyReachesToExpectedPods(t, s, gwAddr, gwhttp.ExpectedResponse{
diff --git a/pkg/epp/controller/inferenceobjective_reconciler_test.go b/pkg/epp/controller/inferenceobjective_reconciler_test.go
index 7a439ca..de43d6e 100644
--- a/pkg/epp/controller/inferenceobjective_reconciler_test.go
+++ b/pkg/epp/controller/inferenceobjective_reconciler_test.go
@@ -43,19 +43,19 @@ var (
 	pool          = utiltest.MakeInferencePool("test-pool1").Namespace("ns1").ObjRef()
 	infObjective1 = utiltest.MakeInferenceObjective("model1").
 			Namespace(pool.Namespace).
-			Criticality(1).
+			Priority(1).
 			CreationTimestamp(metav1.Unix(1000, 0)).
 			PoolName(pool.Name).
 			PoolGroup("inference.networking.k8s.io").ObjRef()
 	infObjective1Pool2 = utiltest.MakeInferenceObjective(infObjective1.Name).
 				Namespace(infObjective1.Namespace).
-				Criticality(*infObjective1.Spec.Criticality).
+				Priority(*infObjective1.Spec.Priority).
 				CreationTimestamp(metav1.Unix(1001, 0)).
 				PoolName("test-pool2").
 				PoolGroup("inference.networking.k8s.io").ObjRef()
 	infObjective1Critical = utiltest.MakeInferenceObjective(infObjective1.Name).
 				Namespace(infObjective1.Namespace).
-				Criticality(2).
+				Priority(2).
 				CreationTimestamp(metav1.Unix(1003, 0)).
 				PoolName(pool.Name).
 				PoolGroup("inference.networking.k8s.io").ObjRef()
@@ -67,7 +67,7 @@ var (
 				PoolGroup("inference.networking.k8s.io").ObjRef()
 	infObjective1DiffGroup = utiltest.MakeInferenceObjective(infObjective1.Name).
 				Namespace(pool.Namespace).
-				Criticality(1).
+				Priority(1).
 				CreationTimestamp(metav1.Unix(1005, 0)).
 				PoolName(pool.Name).
 				PoolGroup("inference.networking.x-k8s.io").ObjRef()
@@ -112,7 +112,7 @@ func TestInferenceObjectiveReconciler(t *testing.T) {
 			wantObjectives:     []*v1alpha2.InferenceObjective{},
 		},
 		{
-			name:               "Objective changed criticality",
+			name:               "Objective changed priority",
 			objectivessInStore: []*v1alpha2.InferenceObjective{infObjective1},
 			objective:          infObjective1Critical,
 			wantObjectives:     []*v1alpha2.InferenceObjective{infObjective1Critical},
diff --git a/pkg/epp/datastore/datastore_test.go b/pkg/epp/datastore/datastore_test.go
index 1ef7a7a..77fe7c7 100644
--- a/pkg/epp/datastore/datastore_test.go
+++ b/pkg/epp/datastore/datastore_test.go
@@ -114,7 +114,7 @@ func TestObjective(t *testing.T) {
 	model2ts := testutil.MakeInferenceObjective("model2").ObjRef()
 	// Same model name as model1ts, newer timestamp
 	model1tsCritical := testutil.MakeInferenceObjective("model1").
-		Criticality(2).ObjRef()
+		Priority(2).ObjRef()
 	// Same object name as model2ts, different model name.
 	model2chat := testutil.MakeInferenceObjective(model2ts.Name).ObjRef()
 
@@ -135,7 +135,7 @@ func TestObjective(t *testing.T) {
 			wantOpResult: true,
 		},
 		{
-			name:           "Set model1 with the same modelName, but with diff criticality, should update.",
+			name:           "Set model1 with the same modelName, but with diff priority, should update.",
 			existingModels: []*v1alpha2.InferenceObjective{model1ts},
 			op: func(ds Datastore) bool {
 				ds.ObjectiveSet(model1tsCritical)
diff --git a/pkg/epp/plugins/plugin_state_test.go b/pkg/epp/plugins/plugin_state_test.go
new file mode 100644
index 0000000..46c0de8
--- /dev/null
+++ b/pkg/epp/plugins/plugin_state_test.go
@@ -0,0 +1,128 @@
+/*
+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 plugins
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+
+	logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/epp/util/logging"
+)
+
+// pluginTestData implements the StateData interface for testing purposes.
+// It provides a simple string value that can be stored and retrieved.
+type pluginTestData struct {
+	value string
+}
+
+// Clone implements the StateData interface, creating a deep copy of the data.
+func (d *pluginTestData) Clone() StateData {
+	if d == nil {
+		return nil
+	}
+	return &pluginTestData{value: d.value}
+}
+
+// TestPluginState_ReadWrite verifies the basic operations of PluginState:
+// - Writing data for a request
+// - Reading the data back
+// - Deleting the data and confirming it's removed
+func TestPluginState_ReadWrite(t *testing.T) {
+	ctx := logutil.NewTestLoggerIntoContext(context.Background())
+
+	state := NewPluginState(ctx)
+
+	req1 := "req1"
+	key := StateKey("key")
+	data1 := "bar1"
+	req2 := "req2"
+	data2 := "bar2"
+
+	// Write data to the state storage
+	state.Write(req1, key, &pluginTestData{value: data1})
+	state.Write(req2, key, &pluginTestData{value: data2})
+
+	// Read back the req1 data and verify its content
+	readData, err := state.Read(req1, key)
+	assert.NoError(t, err)
+	td, ok := readData.(*pluginTestData)
+	assert.True(t, ok, "should be able to cast to pluginTestData")
+	assert.Equal(t, data1, td.value)
+
+	// Delete the req2 data and verify it's removed
+	state.Delete(req2)
+	readData, err = state.Read(req2, key)
+	assert.Equal(t, ErrNotFound, err)
+	assert.Nil(t, readData, "expected no data after delete")
+
+	// Read back the req1 data and verify its content after the req2 deleted
+	readData, err = state.Read(req1, key)
+	assert.NoError(t, err)
+	td, ok = readData.(*pluginTestData)
+	assert.True(t, ok, "should be able to cast to pluginTestData")
+	assert.Equal(t, data1, td.value)
+}
+
+// TestReadPluginStateKey tests the generic helper function ReadPluginStateKey which provides
+// type-safe access to stored data. It verifies:
+// - Successful type assertion and data retrieval
+// - Error handling for non-existent keys
+func TestReadPluginStateKey(t *testing.T) {
+	ctx := logutil.NewTestLoggerIntoContext(context.Background())
+	state := NewPluginState(ctx)
+
+	requestID := "req-1"
+	key := StateKey("foo")
+	data := &pluginTestData{value: "bar"}
+
+	state.Write(requestID, key, data)
+
+	// Read
+	val, err := ReadPluginStateKey[*pluginTestData](state, requestID, key)
+	assert.NoError(t, err)
+	assert.Equal(t, "bar", val.value)
+
+	// Not Found
+	_, err = ReadPluginStateKey[*pluginTestData](state, "not-exist", key)
+	assert.Equal(t, ErrNotFound, err)
+}
+
+// TestPluginState_Cleanup verifies the automatic cleanup of stale data.
+// It tests that data which hasn't been accessed for longer than stalenessThreshold
+// is properly removed from the storage.
+func TestPluginState_Cleanup(t *testing.T) {
+	ctx := logutil.NewTestLoggerIntoContext(context.Background())
+
+	state := NewPluginState(ctx)
+
+	requestID := "req-stale"
+	key := StateKey("foo")
+	data := &pluginTestData{value: "bar"}
+
+	state.Write(requestID, key, data)
+
+	// Manually set last access time to far in the past
+	state.requestToLastAccessTime.Store(requestID, time.Now().Add(-2*stalenessThreshold))
+	// Manually CleanUp
+	state.cleanStaleRequests()
+
+	_, err := state.Read(requestID, key)
+	assert.Equal(t, ErrNotFound, err)
+}
diff --git a/pkg/epp/requestcontrol/director_test.go b/pkg/epp/requestcontrol/director_test.go
index dd9c378..7c1ef8d 100644
--- a/pkg/epp/requestcontrol/director_test.go
+++ b/pkg/epp/requestcontrol/director_test.go
@@ -80,15 +80,15 @@ func TestDirector_HandleRequest(t *testing.T) {
 	// InferenceObjective definitions
 	ioFoodReview := testutil.MakeInferenceObjective("ioFoodReview").
 		CreationTimestamp(metav1.Unix(1000, 0)).
-		Criticality(2).
+		Priority(2).
 		ObjRef()
 	ioFoodReviewSheddable := testutil.MakeInferenceObjective("imFoodReviewSheddable").
 		CreationTimestamp(metav1.Unix(1000, 0)).
-		Criticality(0).
+		Priority(0).
 		ObjRef()
 	ioFoodReviewResolve := testutil.MakeInferenceObjective("imFoodReviewResolve").
 		CreationTimestamp(metav1.Unix(1000, 0)).
-		Criticality(1).
+		Priority(1).
 		ObjRef()
 
 	// Datastore setup
diff --git a/pkg/epp/scheduling/framework/plugins/test/consts.go b/pkg/epp/scheduling/framework/plugins/test/consts.go
new file mode 100644
index 0000000..81c0579
--- /dev/null
+++ b/pkg/epp/scheduling/framework/plugins/test/consts.go
@@ -0,0 +1,25 @@
+/*
+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 test
+
+const (
+	// HeaderTestEppEndPointSelectionKey is the header used for testing purposes to make EPP behavior controllable.
+	// The header value should be a comma-separated list of endpoint IP addresses.
+	// E.g., "test-epp-endpoint-selection": "10.0.0.7,10.0.0.8"
+	// The returned order is the same as the order provided in the header.
+	HeaderTestEppEndPointSelectionKey = "test-epp-endpoint-selection"
+)
diff --git a/pkg/epp/scheduling/framework/plugins/test/filter/filter_test.go b/pkg/epp/scheduling/framework/plugins/test/filter/filter_test.go
index b7434be..d5615bc 100644
--- a/pkg/epp/scheduling/framework/plugins/test/filter/filter_test.go
+++ b/pkg/epp/scheduling/framework/plugins/test/filter/filter_test.go
@@ -23,6 +23,7 @@ import (
 	"github.com/google/go-cmp/cmp"
 
 	"sigs.k8s.io/gateway-api-inference-extension/pkg/epp/backend"
+	"sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework/plugins/test"
 	"sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types"
 )
 
@@ -47,7 +48,7 @@ func TestFilter(t *testing.T) {
 		},
 		{
 			name: "TestHeaderBasedFilter, header endpoint set in request but no match",
-			req:  &types.LLMRequest{Headers: map[string]string{HeaderTestEppEndPointSelectionKey: "test-endpoint"}},
+			req:  &types.LLMRequest{Headers: map[string]string{test.HeaderTestEppEndPointSelectionKey: "test-endpoint"}},
 			input: []types.Pod{
 				&types.PodMetrics{
 					Pod: &backend.Pod{
@@ -59,7 +60,7 @@ func TestFilter(t *testing.T) {
 		},
 		{
 			name: "TestHeaderBasedFilter, header endpoint set",
-			req:  &types.LLMRequest{Headers: map[string]string{HeaderTestEppEndPointSelectionKey: "test-endpoint"}},
+			req:  &types.LLMRequest{Headers: map[string]string{test.HeaderTestEppEndPointSelectionKey: "test-endpoint"}},
 			input: []types.Pod{
 				&types.PodMetrics{
 					Pod: &backend.Pod{
@@ -77,7 +78,7 @@ func TestFilter(t *testing.T) {
 		},
 		{
 			name: "TestHeaderBasedFilter, multiple header endpoints set and multiple matches",
-			req:  &types.LLMRequest{Headers: map[string]string{HeaderTestEppEndPointSelectionKey: "test-endpoint3,test-endpoint2"}},
+			req:  &types.LLMRequest{Headers: map[string]string{test.HeaderTestEppEndPointSelectionKey: "test-endpoint3,test-endpoint2"}},
 			input: []types.Pod{
 				&types.PodMetrics{
 					Pod: &backend.Pod{
diff --git a/pkg/epp/scheduling/framework/plugins/test/filter/request_header_based_filter.go b/pkg/epp/scheduling/framework/plugins/test/filter/request_header_based_filter.go
index 113ec7b..bf36d77 100644
--- a/pkg/epp/scheduling/framework/plugins/test/filter/request_header_based_filter.go
+++ b/pkg/epp/scheduling/framework/plugins/test/filter/request_header_based_filter.go
@@ -23,15 +23,11 @@ import (
 
 	"sigs.k8s.io/gateway-api-inference-extension/pkg/epp/plugins"
 	"sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework"
+	"sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/framework/plugins/test"
 	"sigs.k8s.io/gateway-api-inference-extension/pkg/epp/scheduling/types"
 )
 
 const (
-	// HeaderTestEppEndPointSelectionKey is the header used for testing purposes to make EPP behavior controllable.
-	// The header value should be a comma-separated list of endpoint IP addresses.
-	// E.g., "test-epp-endpoint-selection": "10.0.0.7,10.0.0.8"
-	// The returned order is the same as the order provided in the header.
-	HeaderTestEppEndPointSelectionKey = "test-epp-endpoint-selection"
 	// HeaderBasedTestingFilterType is the filter type that is used in plugins registry.
 	HeaderBasedTestingFilterType = "header-based-testing-filter"
 )
@@ -70,7 +66,7 @@ func (f *HeaderBasedTestingFilter) WithName(name string) *HeaderBasedTestingFilt
 
 // Filter selects pods that match the IP addresses specified in the request header.
 func (f *HeaderBasedTestingFilter) Filter(_ context.Context, _ *types.CycleState, request *types.LLMRequest, pods []types.Pod) []types.Pod {
-	headerValue, ok := request.Headers[HeaderTestEppEndPointSelectionKey]
+	headerValue, ok := request.Headers[test.HeaderTestEppEndPointSelectionKey]
 	if !ok || headerValue == "" {
 		return []types.Pod{}
 	}
diff --git a/pkg/epp/util/testing/wrappers.go b/pkg/epp/util/testing/wrappers.go
index 562828b..8afb423 100644
--- a/pkg/epp/util/testing/wrappers.go
+++ b/pkg/epp/util/testing/wrappers.go
@@ -146,8 +146,8 @@ func (m *InferenceObjectiveWrapper) PoolGroup(poolGroup string) *InferenceObject
 	return m
 }
 
-func (m *InferenceObjectiveWrapper) Criticality(criticality int) *InferenceObjectiveWrapper {
-	m.Spec.Criticality = &criticality
+func (m *InferenceObjectiveWrapper) Priority(priority int) *InferenceObjectiveWrapper {
+	m.Spec.Priority = &priority
 	return m
 }
 
diff --git a/test/e2e/epp/README.md b/test/e2e/epp/README.md
index c3e4aa1..5a83f06 100644
--- a/test/e2e/epp/README.md
+++ b/test/e2e/epp/README.md
@@ -45,6 +45,19 @@ Follow these steps to run the end-to-end tests:
      export E2E_MANIFEST_PATH=[config/manifests/vllm/gpu-deployment.yaml|config/manifests/vllm/cpu-deployment.yaml]
      ```
 
+   - **Enable leader election tests**: By default, the e2e test runs the EPP server as a single replica.
+     To test the high-availability (HA) mode with leader election (3 replicas), set the following environment variable:
+
+     ```sh
+     export E2E_LEADER_ELECTION_ENABLED=true
+     ```
+
+   - **Pause before cleanup**: To pause the test run before cleaning up resources, set the `E2E_PAUSE_ON_EXIT` environment variable.
+     This is useful for debugging the state of the cluster after the test has run.
+
+     - To pause indefinitely, set it to `true`: `export E2E_PAUSE_ON_EXIT=true`
+     - To pause for a specific duration, provide a duration string: `export E2E_PAUSE_ON_EXIT=10m`
+
 1. **Run the Tests**: Run the `test-e2e` target:
 
    ```sh
diff --git a/test/e2e/epp/e2e_suite_test.go b/test/e2e/epp/e2e_suite_test.go
index a40d586..e6e5b83 100644
--- a/test/e2e/epp/e2e_suite_test.go
+++ b/test/e2e/epp/e2e_suite_test.go
@@ -85,8 +85,10 @@ const (
 	xInferObjectiveManifest = "../../../config/crd/bases/inference.networking.x-k8s.io_inferenceobjectives.yaml"
 	// inferPoolManifest is the manifest for the inference pool CRD with 'inference.networking.k8s.io' group.
 	inferPoolManifest = "../../../config/crd/bases/inference.networking.k8s.io_inferencepools.yaml"
-	// inferExtManifest is the manifest for the inference extension test resources.
-	inferExtManifest = "../../testdata/inferencepool-e2e.yaml"
+	// inferExtManifestDefault is the manifest for the default inference extension test resources (single replica).
+	inferExtManifestDefault = "../../testdata/inferencepool-e2e.yaml"
+	// inferExtManifestLeaderElection is the manifest for the inference extension test resources with leader election enabled (3 replicas).
+	inferExtManifestLeaderElection = "../../testdata/inferencepool-leader-election-e2e.yaml"
 	// envoyManifest is the manifest for the envoy proxy test resources.
 	envoyManifest = "../../testdata/envoy.yaml"
 	// metricsRbacManifest is the manifest for the rbac resources for testing metrics.
@@ -95,15 +97,18 @@ const (
 	modelServerManifestFilepathEnvVar = "MANIFEST_PATH"
 )
 
+const e2eLeaderElectionEnabledEnvVar = "E2E_LEADER_ELECTION_ENABLED"
+
 var (
 	ctx = context.Background()
 	cli client.Client
 	// Required for exec'ing in curl pod
-	kubeCli  *kubernetes.Clientset
-	scheme   = runtime.NewScheme()
-	cfg      = config.GetConfigOrDie()
-	nsName   string
-	e2eImage string
+	kubeCli               *kubernetes.Clientset
+	scheme                = runtime.NewScheme()
+	cfg                   = config.GetConfigOrDie()
+	nsName                string
+	e2eImage              string
+	leaderElectionEnabled bool
 )
 
 func TestAPIs(t *testing.T) {
@@ -121,6 +126,11 @@ var _ = ginkgo.BeforeSuite(func() {
 	e2eImage = os.Getenv("E2E_IMAGE")
 	gomega.Expect(e2eImage).NotTo(gomega.BeEmpty(), "E2E_IMAGE environment variable is not set")
 
+	if os.Getenv(e2eLeaderElectionEnabledEnvVar) == "true" {
+		leaderElectionEnabled = true
+		ginkgo.By("Leader election test mode enabled via " + e2eLeaderElectionEnabledEnvVar)
+	}
+
 	ginkgo.By("Setting up the test suite")
 	setupSuite()
 
@@ -146,7 +156,12 @@ func setupInfra() {
 	}
 
 	createCRDs(cli, crds)
-	createInferExt(cli, inferExtManifest)
+
+	inferExtManifestPath := inferExtManifestDefault
+	if leaderElectionEnabled {
+		inferExtManifestPath = inferExtManifestLeaderElection
+	}
+	createInferExt(cli, inferExtManifestPath)
 	createClient(cli, clientManifest)
 	createEnvoy(cli, envoyManifest)
 	createMetricsRbac(cli, metricsRbacManifest)
@@ -156,6 +171,20 @@ func setupInfra() {
 }
 
 var _ = ginkgo.AfterSuite(func() {
+	// If E2E_PAUSE_ON_EXIT is set, pause the test run before cleanup.
+	// This is useful for debugging the state of the cluster after the test has run.
+	if pauseStr := os.Getenv("E2E_PAUSE_ON_EXIT"); pauseStr != "" {
+		ginkgo.By("Pausing before cleanup as requested by E2E_PAUSE_ON_EXIT=" + pauseStr)
+		pauseDuration, err := time.ParseDuration(pauseStr)
+		if err != nil {
+			// If it's not a valid duration (e.g., "true"), just wait indefinitely.
+			ginkgo.By("Invalid duration, pausing indefinitely. Press Ctrl+C to stop the test runner when you are done.")
+			select {} // Block forever
+		}
+		ginkgo.By(fmt.Sprintf("Pausing for %v...", pauseDuration))
+		time.Sleep(pauseDuration)
+	}
+
 	ginkgo.By("Performing global cleanup")
 	cleanupResources()
 })
@@ -423,8 +452,12 @@ func createInferExt(k8sClient client.Client, filePath string) {
 		return k8sClient.Get(ctx, types.NamespacedName{Namespace: nsName, Name: inferExtName}, deploy)
 	}, existsTimeout, interval)
 
-	// Wait for the deployment to be available.
-	testutils.DeploymentAvailable(ctx, k8sClient, deploy, modelReadyTimeout, interval)
+	if leaderElectionEnabled {
+		// With leader election enabled, only 1 replica will be "Ready" at any given time (the leader).
+		testutils.DeploymentReadyReplicas(ctx, k8sClient, deploy, 1, modelReadyTimeout, interval)
+	} else {
+		testutils.DeploymentAvailable(ctx, k8sClient, deploy, modelReadyTimeout, interval)
+	}
 
 	// Wait for the service to exist.
 	testutils.EventuallyExists(ctx, func() error {
diff --git a/test/e2e/epp/e2e_test.go b/test/e2e/epp/e2e_test.go
index bb181a1..e01240d 100644
--- a/test/e2e/epp/e2e_test.go
+++ b/test/e2e/epp/e2e_test.go
@@ -28,6 +28,7 @@ import (
 	"github.com/google/go-cmp/cmp/cmpopts"
 	"github.com/onsi/ginkgo/v2"
 	"github.com/onsi/gomega"
+	appsv1 "k8s.io/api/apps/v1"
 	corev1 "k8s.io/api/core/v1"
 	k8serrors "k8s.io/apimachinery/pkg/api/errors"
 	"k8s.io/apimachinery/pkg/types"
@@ -71,160 +72,92 @@ var _ = ginkgo.Describe("InferencePool", func() {
 
 	ginkgo.When("The Inference Extension is running", func() {
 		ginkgo.It("Should route traffic to target model servers", func() {
-			for _, t := range []struct {
-				api              string
-				promptOrMessages any
-			}{
-				{
-					api:              "/completions",
-					promptOrMessages: "Write as if you were a critic: San Francisco",
-				},
-				{
-					api: "/chat/completions",
-					promptOrMessages: []map[string]any{
-						{
-							"role":    "user",
-							"content": "Write as if you were a critic: San Francisco",
-						},
-					},
-				},
-				{
-					api: "/chat/completions",
-					promptOrMessages: []map[string]any{
-						{
-							"role":    "user",
-							"content": "Write as if you were a critic: San Francisco",
-						},
-						{"role": "assistant", "content": "Okay, let's see..."},
-						{"role": "user", "content": "Now summarize your thoughts."},
-					},
-				},
-			} {
-				ginkgo.By(fmt.Sprintf("Verifying connectivity through the inference extension with %s api and prompt/messages: %v", t.api, t.promptOrMessages))
-
-				// Ensure the expected responses include the InferenceObjective target model names.
-				var expected []string
-				expected = append(expected, targetModelName)
-				curlCmd := getCurlCommand(envoyName, nsName, envoyPort, modelName, curlTimeout, t.api, t.promptOrMessages, false)
-
-				actual := make(map[string]int)
-				gomega.Eventually(func() error {
-					resp, err := testutils.ExecCommandInPod(ctx, cfg, scheme, kubeCli, nsName, "curl", "curl", curlCmd)
-					if err != nil {
-						return err
-					}
-					if !strings.Contains(resp, "200 OK") {
-						return fmt.Errorf("did not get 200 OK: %s", resp)
-					}
-					for _, m := range expected {
-						if strings.Contains(resp, m) {
-							actual[m] = 0
-						}
-					}
-					var got []string
-					for m := range actual {
-						got = append(got, m)
-					}
-					// Compare ignoring order
-					if !cmp.Equal(got, expected, cmpopts.SortSlices(func(a, b string) bool { return a < b })) {
-						return fmt.Errorf("actual (%v) != expected (%v); resp=%q", got, expected, resp)
-					}
-					return nil
-				}, readyTimeout, curlInterval).Should(gomega.Succeed())
-			}
+			verifyTrafficRouting()
 		})
 
 		ginkgo.It("Should expose EPP metrics after generating traffic", func() {
-			// Define the metrics we expect to see
-			expectedMetrics := []string{
-				"inference_model_request_total",
-				"inference_model_request_error_total",
-				"inference_model_request_duration_seconds",
-				// TODO: normalized_time_per_output_token_seconds is not actually recorded yet
-				// "normalized_time_per_output_token_seconds",
-				"inference_model_request_sizes",
-				"inference_model_response_sizes",
-				"inference_model_input_tokens",
-				"inference_model_output_tokens",
-				"inference_pool_average_kv_cache_utilization",
-				"inference_pool_average_queue_size",
-				"inference_pool_per_pod_queue_size",
-				"inference_model_running_requests",
-				"inference_pool_ready_pods",
-				"inference_extension_info",
+			verifyMetrics()
+		})
+	})
+
+	ginkgo.When("Leader election is enabled", func() {
+		ginkgo.It("Should elect one leader and have other pods as not ready", func() {
+			if !leaderElectionEnabled {
+				ginkgo.Skip("Leader election is not enabled for this test run, skipping.")
 			}
 
-			// Generate traffic by sending requests through the inference extension
-			ginkgo.By("Generating traffic through the inference extension")
-			curlCmd := getCurlCommand(envoyName, nsName, envoyPort, modelName, curlTimeout, "/completions", "Write as if you were a critic: San Francisco", true)
+			ginkgo.By("Verifying that exactly one EPP pod is ready")
+			gomega.Eventually(func(g gomega.Gomega) {
+				podList := &corev1.PodList{}
+				err := cli.List(ctx, podList, client.InNamespace(nsName), client.MatchingLabels{"app": inferExtName})
+				g.Expect(err).NotTo(gomega.HaveOccurred())
 
-			// Run the curl command multiple times to generate some metrics data
-			for i := 0; i < 5; i++ {
-				_, err := testutils.ExecCommandInPod(ctx, cfg, scheme, kubeCli, nsName, "curl", "curl", curlCmd)
-				gomega.Expect(err).NotTo(gomega.HaveOccurred())
-			}
+				// The deployment should have 3 replicas for leader election.
+				g.Expect(podList.Items).To(gomega.HaveLen(3))
+
+				readyPods := 0
+				for _, pod := range podList.Items {
+					for _, cond := range pod.Status.Conditions {
+						if cond.Type == corev1.PodReady && cond.Status == corev1.ConditionTrue {
+							readyPods++
+						}
+					}
+				}
+				g.Expect(readyPods).To(gomega.Equal(1), "Expected exactly one pod to be ready")
+			}, readyTimeout, interval).Should(gomega.Succeed())
+		})
 
-			// modify the curl command to generate some error metrics
-			curlCmd[len(curlCmd)-1] = "invalid input"
-			for i := 0; i < 5; i++ {
-				_, err := testutils.ExecCommandInPod(ctx, cfg, scheme, kubeCli, nsName, "curl", "curl", curlCmd)
-				gomega.Expect(err).NotTo(gomega.HaveOccurred())
+		ginkgo.It("Should successfully failover and serve traffic after the leader pod is deleted", func() {
+			if !leaderElectionEnabled {
+				ginkgo.Skip("Leader election is not enabled for this test run, skipping.")
 			}
 
-			// Now scrape metrics from the EPP endpoint via the curl pod
-			ginkgo.By("Scraping metrics from the EPP endpoint")
+			ginkgo.By("STEP 1: Verifying initial leader is working correctly before failover")
+			verifyTrafficRouting()
+			verifyMetrics()
+
+			ginkgo.By("STEP 2: Finding and deleting the current leader pod")
+			oldLeaderPod := findReadyPod()
+			ginkgo.By("Found initial leader pod: " + oldLeaderPod.Name)
 
-			// Get Pod IP instead of Service
-			podList := &corev1.PodList{}
-			err := cli.List(ctx, podList, client.InNamespace(nsName), client.MatchingLabels{"app": inferExtName})
-			gomega.Expect(err).NotTo(gomega.HaveOccurred())
-			gomega.Expect(podList.Items).NotTo(gomega.BeEmpty())
-			podIP := podList.Items[0].Status.PodIP
-			gomega.Expect(podIP).NotTo(gomega.BeEmpty())
+			ginkgo.By(fmt.Sprintf("Deleting leader pod %s to trigger failover", oldLeaderPod.Name))
+			gomega.Expect(cli.Delete(ctx, oldLeaderPod)).To(gomega.Succeed())
 
-			// Get the authorization token for reading metrics
-			token := ""
+			ginkgo.By("STEP 3: Waiting for a new leader to be elected")
+			// The deployment controller will create a new pod. We need to wait for the total number of pods
+			// to be back to 3, and for one of the other pods to become the new leader.
+			deploy := &appsv1.Deployment{}
 			gomega.Eventually(func() error {
-				token, err = getMetricsReaderToken(cli)
-				if err != nil {
-					return err
-				}
-				if token == "" {
-					return errors.New("token not found")
-				}
-				return nil
+				return cli.Get(ctx, types.NamespacedName{Namespace: nsName, Name: inferExtName}, deploy)
 			}, existsTimeout, interval).Should(gomega.Succeed())
 
-			// Construct the metric scraping curl command using Pod IP
-			metricScrapeCmd := []string{
-				"curl",
-				"-i",
-				"--max-time",
-				strconv.Itoa((int)(curlTimeout.Seconds())),
-				"-H",
-				"Authorization: Bearer " + token,
-				fmt.Sprintf("http://%s:%d/metrics", podIP, 9090),
-			}
+			// Wait for one replica to become ready again.
+			testutils.DeploymentReadyReplicas(ctx, cli, deploy, 1, readyTimeout, interval)
 
-			ginkgo.By("Verifying that all expected metrics are present.")
-			gomega.Eventually(func() error {
-				// Execute the metrics scrape command inside the curl pod
-				resp, err := testutils.ExecCommandInPod(ctx, cfg, scheme, kubeCli, nsName, "curl", "curl", metricScrapeCmd)
-				if err != nil {
-					return err
-				}
-				// Verify that we got a 200 OK responsecurl
-				if !strings.Contains(resp, "200 OK") {
-					return fmt.Errorf("did not get 200 OK: %s", resp)
-				}
-				// Check if all expected metrics are present in the metrics output
-				for _, metric := range expectedMetrics {
-					if !strings.Contains(resp, metric) {
-						return fmt.Errorf("expected metric %s not found in metrics output", metric)
-					}
-				}
-				return nil
-			}, readyTimeout, curlInterval).Should(gomega.Succeed())
+			// Also wait for the total number of replicas to be back to 3.
+			gomega.Eventually(func(g gomega.Gomega) {
+				d := &appsv1.Deployment{}
+				err := cli.Get(ctx, types.NamespacedName{Namespace: nsName, Name: inferExtName}, d)
+				g.Expect(err).NotTo(gomega.HaveOccurred())
+				g.Expect(d.Status.Replicas).To(gomega.Equal(int32(3)), "Deployment should have 3 replicas")
+			}, readyTimeout, interval).Should(gomega.Succeed())
+
+			ginkgo.By("STEP 4: Verifying a new, different leader is elected")
+			var newLeaderPod *corev1.Pod
+			gomega.Eventually(func(g gomega.Gomega) {
+				// Find the current ready pod.
+				newLeaderPod = findReadyPod()
+
+				// Ensure the new leader is not the same as the one we just deleted.
+				// This guards against a race condition where we might find the old leader
+				// before its status is updated to NotReady.
+				g.Expect(newLeaderPod.Name).NotTo(gomega.Equal(oldLeaderPod.Name), "The new leader should not be the same as the old deleted leader")
+			}, readyTimeout, interval).Should(gomega.Succeed())
+			ginkgo.By("Found new leader pod: " + newLeaderPod.Name)
+
+			ginkgo.By("STEP 5: Verifying the new leader is working correctly after failover")
+			verifyTrafficRouting()
+			verifyMetrics()
 		})
 	})
 })
@@ -232,11 +165,153 @@ var _ = ginkgo.Describe("InferencePool", func() {
 // newInferenceObjective creates an InferenceObjective in the given namespace for testutils.
 func newInferenceObjective(ns string) *v1alpha2.InferenceObjective {
 	return testutils.MakeModelWrapper(types.NamespacedName{Name: "inferenceobjective-sample", Namespace: ns}).
-		SetCriticality(2).
+		SetPriority(2).
 		SetPoolRef(modelServerName).
 		Obj()
 }
 
+// verifyTrafficRouting contains the logic for the "Should route traffic to target model servers" test.
+func verifyTrafficRouting() {
+	ginkgo.By("Verifying traffic routing")
+	for _, t := range []struct {
+		api              string
+		promptOrMessages any
+	}{
+		{
+			api:              "/completions",
+			promptOrMessages: "Write as if you were a critic: San Francisco",
+		},
+		{
+			api: "/chat/completions",
+			promptOrMessages: []map[string]any{
+				{
+					"role":    "user",
+					"content": "Write as if you were a critic: San Francisco",
+				},
+			},
+		},
+		{
+			api: "/chat/completions",
+			promptOrMessages: []map[string]any{
+				{
+					"role":    "user",
+					"content": "Write as if you were a critic: San Francisco",
+				},
+				{"role": "assistant", "content": "Okay, let's see..."},
+				{"role": "user", "content": "Now summarize your thoughts."},
+			},
+		},
+	} {
+		ginkgo.By(fmt.Sprintf("Verifying connectivity through the inference extension with %s api and prompt/messages: %v", t.api, t.promptOrMessages))
+
+		// Ensure the expected responses include the InferenceObjective target model names.
+		var expected []string
+		expected = append(expected, targetModelName)
+		curlCmd := getCurlCommand(envoyName, nsName, envoyPort, modelName, curlTimeout, t.api, t.promptOrMessages, false)
+
+		actual := make(map[string]int)
+		gomega.Eventually(func() error {
+			resp, err := testutils.ExecCommandInPod(ctx, cfg, scheme, kubeCli, nsName, "curl", "curl", curlCmd)
+			if err != nil {
+				return err
+			}
+			if !strings.Contains(resp, "200 OK") {
+				return fmt.Errorf("did not get 200 OK: %s", resp)
+			}
+			for _, m := range expected {
+				if strings.Contains(resp, m) {
+					actual[m] = 0
+				}
+			}
+			var got []string
+			for m := range actual {
+				got = append(got, m)
+			}
+			// Compare ignoring order
+			if !cmp.Equal(got, expected, cmpopts.SortSlices(func(a, b string) bool { return a < b })) {
+				return fmt.Errorf("actual (%v) != expected (%v); resp=%q", got, expected, resp)
+			}
+			return nil
+		}, readyTimeout, curlInterval).Should(gomega.Succeed())
+	}
+}
+
+// verifyMetrics contains the logic for the "Should expose EPP metrics after generating traffic" test.
+func verifyMetrics() {
+	ginkgo.By("Verifying metrics exposure")
+	// Define the metrics we expect to see
+	expectedMetrics := []string{
+		"inference_model_request_total",
+		"inference_model_request_error_total",
+		"inference_model_request_duration_seconds",
+		// TODO: normalized_time_per_output_token_seconds is not actually recorded yet
+		// "normalized_time_per_output_token_seconds",
+		"inference_model_request_sizes",
+		"inference_model_response_sizes",
+		"inference_model_input_tokens",
+		"inference_model_output_tokens",
+		"inference_pool_average_kv_cache_utilization",
+		"inference_pool_average_queue_size",
+		"inference_pool_per_pod_queue_size",
+		"inference_model_running_requests",
+		"inference_pool_ready_pods",
+		"inference_extension_info",
+	}
+
+	// Generate traffic by sending requests through the inference extension
+	ginkgo.By("Generating traffic through the inference extension")
+	curlCmd := getCurlCommand(envoyName, nsName, envoyPort, modelName, curlTimeout, "/completions", "Write as if you were a critic: San Francisco", true)
+
+	// Run the curl command multiple times to generate some metrics data
+	for i := 0; i < 5; i++ {
+		_, err := testutils.ExecCommandInPod(ctx, cfg, scheme, kubeCli, nsName, "curl", "curl", curlCmd)
+		gomega.Expect(err).NotTo(gomega.HaveOccurred())
+	}
+
+	// modify the curl command to generate some error metrics
+	curlCmd[len(curlCmd)-1] = "invalid input"
+	for i := 0; i < 5; i++ {
+		_, err := testutils.ExecCommandInPod(ctx, cfg, scheme, kubeCli, nsName, "curl", "curl", curlCmd)
+		gomega.Expect(err).NotTo(gomega.HaveOccurred())
+	}
+
+	// Now scrape metrics from the EPP endpoint via the curl pod
+	ginkgo.By("Scraping metrics from the EPP endpoint")
+	podIP := findReadyPod().Status.PodIP
+
+	// Get the authorization token for reading metrics
+	token := ""
+	gomega.Eventually(func(g gomega.Gomega) {
+		t, err := getMetricsReaderToken(cli)
+		g.Expect(err).NotTo(gomega.HaveOccurred())
+		g.Expect(t).NotTo(gomega.BeEmpty())
+		token = t
+	}, existsTimeout, interval).Should(gomega.Succeed())
+
+	// Construct the metric scraping curl command using Pod IP
+	metricScrapeCmd := getMetricsScrapeCommand(podIP, token)
+
+	ginkgo.By("Verifying that all expected metrics are present.")
+	gomega.Eventually(func() error {
+		// Execute the metrics scrape command inside the curl pod
+		resp, err := testutils.ExecCommandInPod(ctx, cfg, scheme, kubeCli, nsName, "curl", "curl", metricScrapeCmd)
+		if err != nil {
+			return err
+		}
+		// Verify that we got a 200 OK responsecurl
+		if !strings.Contains(resp, "200 OK") {
+			return fmt.Errorf("did not get 200 OK: %s", resp)
+		}
+		// Check if all expected metrics are present in the metrics output
+		for _, metric := range expectedMetrics {
+			if !strings.Contains(resp, metric) {
+				return fmt.Errorf("expected metric %s not found in metrics output", metric)
+			}
+		}
+		return nil
+	}, readyTimeout, curlInterval).Should(gomega.Succeed())
+}
+
 func getMetricsReaderToken(k8sClient client.Client) (string, error) {
 	secret := &corev1.Secret{}
 	err := k8sClient.Get(ctx, types.NamespacedName{Namespace: nsName, Name: metricsReaderSecretName}, secret)
@@ -246,6 +321,43 @@ func getMetricsReaderToken(k8sClient client.Client) (string, error) {
 	return string(secret.Data["token"]), nil
 }
 
+// findReadyPod finds the first EPP pod that has a "Ready" status condition.
+// It's used to target the leader pod in an HA setup.
+func findReadyPod() *corev1.Pod {
+	var readyPod *corev1.Pod
+	gomega.Eventually(func(g gomega.Gomega) {
+		podList := &corev1.PodList{}
+		err := cli.List(ctx, podList, client.InNamespace(nsName), client.MatchingLabels{"app": inferExtName})
+		g.Expect(err).NotTo(gomega.HaveOccurred())
+
+		foundReadyPod := false
+		for i := range podList.Items {
+			pod := &podList.Items[i]
+			for _, cond := range pod.Status.Conditions {
+				if cond.Type == corev1.PodReady && cond.Status == corev1.ConditionTrue {
+					g.Expect(pod.Status.PodIP).NotTo(gomega.BeEmpty(), "Ready pod must have an IP")
+					readyPod = pod
+					foundReadyPod = true
+					break // break inner loop
+				}
+			}
+			if foundReadyPod {
+				break // break outer loop
+			}
+		}
+		g.Expect(foundReadyPod).To(gomega.BeTrue(), "No ready EPP pod found")
+	}, readyTimeout, interval).Should(gomega.Succeed())
+	return readyPod
+}
+
+// getMetricsScrapeCommand returns the command to scrape the /metrics endpoint.
+func getMetricsScrapeCommand(podIP, token string) []string {
+	return []string{
+		"curl", "-i", "--max-time", strconv.Itoa((int)(curlTimeout.Seconds())),
+		"-H", "Authorization: Bearer " + token, fmt.Sprintf("http://%s:%d/metrics", podIP, 9090),
+	}
+}
+
 // getCurlCommand returns the command, as a slice of strings, for curl'ing
 // the test model server at the given name, namespace, port, and model name.
 func getCurlCommand(name, ns, port, model string, timeout time.Duration, api string, promptOrMessages any, streaming bool) []string {
diff --git a/test/testdata/inferencepool-leader-election-e2e.yaml b/test/testdata/inferencepool-leader-election-e2e.yaml
new file mode 100644
index 0000000..413fa55
--- /dev/null
+++ b/test/testdata/inferencepool-leader-election-e2e.yaml
@@ -0,0 +1,206 @@
+apiVersion: inference.networking.k8s.io/v1
+kind: InferencePool
+metadata:
+  labels:
+  name: vllm-llama3-8b-instruct
+spec:
+  targetPortNumber: 8000
+  selector:
+    app: vllm-llama3-8b-instruct
+  extensionRef:
+    name: vllm-llama3-8b-instruct-epp
+    namespace: $E2E_NS
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: vllm-llama3-8b-instruct-epp
+  namespace: $E2E_NS
+spec:
+  selector:
+    app: vllm-llama3-8b-instruct-epp
+  ports:
+    - protocol: TCP
+      port: 9002
+      targetPort: 9002
+      appProtocol: http2
+  type: ClusterIP
+---
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  name: vllm-llama3-8b-instruct-epp
+  namespace: $E2E_NS
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: vllm-llama3-8b-instruct-epp
+  namespace: $E2E_NS
+  labels:
+    app: vllm-llama3-8b-instruct-epp
+spec:
+  replicas: 3
+  selector:
+    matchLabels:
+      app: vllm-llama3-8b-instruct-epp
+  template:
+    metadata:
+      labels:
+        app: vllm-llama3-8b-instruct-epp
+    spec:
+      serviceAccountName: vllm-llama3-8b-instruct-epp
+      # Conservatively, this timeout should mirror the longest grace period of the pods within the pool
+      terminationGracePeriodSeconds: 130
+      containers:
+      - name: epp
+        image: $E2E_IMAGE
+        imagePullPolicy: IfNotPresent
+        args:
+        - --pool-name
+        - "vllm-llama3-8b-instruct"
+        - --pool-namespace
+        - "$E2E_NS"
+        - --v
+        - "4"
+        - --zap-encoder
+        - "json"
+        - --grpc-port
+        - "9002"
+        - --grpc-health-port
+        - "9003"
+        - --ha-enable-leader-election
+        - "--config-file"
+        - "/config/default-plugins.yaml"
+        ports:
+        - containerPort: 9002
+        - containerPort: 9003
+        - name: metrics
+          containerPort: 9090
+        livenessProbe:
+          grpc:
+            port: 9003
+            service: liveness
+          initialDelaySeconds: 5
+          periodSeconds: 10
+        readinessProbe:
+          grpc:
+            port: 9003
+            service: readiness
+          initialDelaySeconds: 5
+          periodSeconds: 10
+        volumeMounts:
+        - name: plugins-config-volume
+          mountPath: "/config"
+      volumes:
+      - name: plugins-config-volume
+        configMap:
+          name: plugins-config
+---
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: plugins-config
+  namespace: $E2E_NS
+data:
+  default-plugins.yaml: |
+    apiVersion: inference.networking.x-k8s.io/v1alpha1
+    kind: EndpointPickerConfig
+    plugins:
+    - type: queue-scorer
+    - type: kv-cache-utilization-scorer
+    - type: prefix-cache-scorer
+    schedulingProfiles:
+    - name: default
+      plugins:
+      - pluginRef: queue-scorer
+      - pluginRef: kv-cache-utilization-scorer
+      - pluginRef: prefix-cache-scorer
+---
+kind: Role
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: pod-read
+  namespace: $E2E_NS
+rules:
+- apiGroups: [ "inference.networking.x-k8s.io" ]
+  resources: [ "inferenceobjectives", "inferencepools" ]
+  verbs: [ "get", "watch", "list" ]
+- apiGroups: [ "inference.networking.k8s.io" ]
+  resources: [ "inferencepools" ]
+  verbs: [ "get", "watch", "list" ]
+- apiGroups: [ "" ]
+  resources: [ "pods" ]
+  verbs: [ "get", "watch", "list" ]
+---
+kind: Role
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: epp-leader-election
+  namespace: $E2E_NS
+rules:
+- apiGroups: [ "coordination.k8s.io" ]
+  resources: [ "leases" ]
+  verbs: [ "get", "list", "watch", "create", "update", "patch", "delete" ]
+- apiGroups: [ "" ]
+  resources: [ "events" ]
+  verbs: [ "create", "patch" ]
+---
+kind: RoleBinding
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: epp-leader-election-binding
+  namespace: $E2E_NS
+subjects:
+- kind: ServiceAccount
+  name: vllm-llama3-8b-instruct-epp
+  namespace: $E2E_NS
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: Role
+  name: epp-leader-election
+---
+kind: RoleBinding
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: pod-read-binding
+  namespace: $E2E_NS
+subjects:
+- kind: ServiceAccount
+  name: vllm-llama3-8b-instruct-epp
+  namespace: $E2E_NS
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: Role
+  name: pod-read
+---
+kind: ClusterRole
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: auth-reviewer
+rules:
+- apiGroups:
+  - authentication.k8s.io
+  resources:
+  - tokenreviews
+  verbs:
+  - create
+- apiGroups:
+  - authorization.k8s.io
+  resources:
+  - subjectaccessreviews
+  verbs:
+  - create
+---
+kind: ClusterRoleBinding
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: auth-reviewer-binding
+subjects:
+- kind: ServiceAccount
+  name: vllm-llama3-8b-instruct-epp
+  namespace: $E2E_NS
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: ClusterRole
+  name: auth-reviewer
diff --git a/test/testdata/inferencepool-with-model-hermetic.yaml b/test/testdata/inferencepool-with-model-hermetic.yaml
index 9f6c699..ca10f6e 100644
--- a/test/testdata/inferencepool-with-model-hermetic.yaml
+++ b/test/testdata/inferencepool-with-model-hermetic.yaml
@@ -16,7 +16,7 @@ metadata:
   name: sql-lora
   namespace: default
 spec:
-  criticality: 2
+  priority: 2
   poolRef:
     name: vllm-llama3-8b-instruct-pool
   targetModels:
@@ -41,7 +41,7 @@ metadata:
   name: my-model
   namespace: default
 spec:
-  criticality: 2
+  priority: 2
   poolRef:
     name: vllm-llama3-8b-instruct-pool
   targetModels:
@@ -54,6 +54,6 @@ metadata:
   name: direct-model
   namespace: default
 spec:
-  criticality: 2
+  priority: 2
   poolRef:
     name: vllm-llama3-8b-instruct-pool
diff --git a/test/utils/utils.go b/test/utils/utils.go
index c3e2759..8cc19b7 100644
--- a/test/utils/utils.go
+++ b/test/utils/utils.go
@@ -204,6 +204,18 @@ func DeploymentAvailable(ctx context.Context, cli client.Client, deploy *appsv1.
 	gomega.Eventually(checkDeploymentStatus, timeout, interval).WithArguments(ctx, cli, deploy, conditions).Should(gomega.BeTrue())
 }
 
+// DeploymentReadyReplicas checks if the given Deployment has at least `count` ready replicas before the given timeout.
+func DeploymentReadyReplicas(ctx context.Context, cli client.Client, deploy *appsv1.Deployment, count int, timeout, interval time.Duration) {
+	ginkgo.By(fmt.Sprintf("Checking if deployment %s/%s has at least %d ready replica(s)", deploy.Namespace, deploy.Name, count))
+	gomega.Eventually(func(g gomega.Gomega) {
+		var fetchedDeploy appsv1.Deployment
+		err := cli.Get(ctx, types.NamespacedName{Namespace: deploy.Namespace, Name: deploy.Name}, &fetchedDeploy)
+		g.Expect(err).NotTo(gomega.HaveOccurred())
+		g.Expect(fetchedDeploy.Status.ReadyReplicas).To(gomega.BeNumerically(">=", count),
+			fmt.Sprintf("Deployment only has %d ready replicas, want at least %d", fetchedDeploy.Status.ReadyReplicas, count))
+	}, timeout, interval).Should(gomega.Succeed())
+}
+
 // checkDeploymentStatus checks if the given Deployment status matches the expected conditions.
 func checkDeploymentStatus(ctx context.Context, cli client.Client, deploy *appsv1.Deployment, conditions []appsv1.DeploymentCondition) (bool, error) {
 	var fetchedDeploy appsv1.Deployment
diff --git a/test/utils/wrappers.go b/test/utils/wrappers.go
index 2d5271a..f1c0098 100644
--- a/test/utils/wrappers.go
+++ b/test/utils/wrappers.go
@@ -44,9 +44,9 @@ func MakeModelWrapper(namespacedName types.NamespacedName) *InferenceObjectiveWr
 	}
 }
 
-// SetCriticality sets the value of the InferenceObjective.spec.criticality.
-func (m *InferenceObjectiveWrapper) SetCriticality(level int) *InferenceObjectiveWrapper {
-	m.Spec.Criticality = &level
+// SetPriority sets the value of the InferenceObjective.spec.priority.
+func (m *InferenceObjectiveWrapper) SetPriority(level int) *InferenceObjectiveWrapper {
+	m.Spec.Priority = &level
 	return m
 }