Repository: envoyproxy/gateway

Issue:
Update docs for Zone Aware Routing
*Description*:
>Describe the issue.
There's now multiple ways to configure Zone Aware Routing and should update the docs

xRef #6597 and #6025

[optional *Relevant Links*:]
>Any extra documentation required to understand the issue.


Hints from issue comments:

Solution patch:
diff --git a/internal/gatewayapi/runner/runner.go b/internal/gatewayapi/runner/runner.go
index 2eccf8832..37e7519a6 100644
--- a/internal/gatewayapi/runner/runner.go
+++ b/internal/gatewayapi/runner/runner.go
@@ -194,11 +194,11 @@ func (r *Runner) subscribeAndTranslate(sub <-chan watchable.Snapshot[string, *re
 						r.Logger.Error(err, "unable to validate infra ir, skipped sending it")
 						errChan <- err
 					} else {
-						message.HandleStore(message.Metadata{
+						r.InfraIR.Store(key, val)
+						message.PublishMetric(message.Metadata{
 							Runner:  r.Name(),
 							Message: message.InfraIRMessageName,
-						},
-							key, val, &r.InfraIR.Map)
+						})
 						newIRKeys = append(newIRKeys, key)
 					}
 				}
@@ -209,67 +209,67 @@ func (r *Runner) subscribeAndTranslate(sub <-chan watchable.Snapshot[string, *re
 						r.Logger.Error(err, "unable to validate xds ir, skipped sending it")
 						errChan <- err
 					} else {
-						message.HandleStore(message.Metadata{
+						r.XdsIR.Store(key, val)
+						message.PublishMetric(message.Metadata{
 							Runner:  r.Name(),
 							Message: message.XDSIRMessageName,
-						},
-							key, val, &r.XdsIR.Map)
+						})
 					}
 				}
 
 				// Update Status
 				for _, gateway := range result.Gateways {
 					key := utils.NamespacedName(gateway)
-					message.HandleStore(message.Metadata{
+					r.ProviderResources.GatewayStatuses.Store(key, &gateway.Status)
+					message.PublishMetric(message.Metadata{
 						Runner:  r.Name(),
 						Message: message.GatewayStatusMessageName,
-					},
-						key, &gateway.Status, &r.ProviderResources.GatewayStatuses)
+					})
 					delete(statusesToDelete.GatewayStatusKeys, key)
 				}
 				for _, httpRoute := range result.HTTPRoutes {
 					key := utils.NamespacedName(httpRoute)
-					message.HandleStore(message.Metadata{
+					r.ProviderResources.HTTPRouteStatuses.Store(key, &httpRoute.Status)
+					message.PublishMetric(message.Metadata{
 						Runner:  r.Name(),
 						Message: message.HTTPRouteStatusMessageName,
-					},
-						key, &httpRoute.Status, &r.ProviderResources.HTTPRouteStatuses)
+					})
 					delete(statusesToDelete.HTTPRouteStatusKeys, key)
 				}
 				for _, grpcRoute := range result.GRPCRoutes {
 					key := utils.NamespacedName(grpcRoute)
-					message.HandleStore(message.Metadata{
+					r.ProviderResources.GRPCRouteStatuses.Store(key, &grpcRoute.Status)
+					message.PublishMetric(message.Metadata{
 						Runner:  r.Name(),
 						Message: message.GRPCRouteStatusMessageName,
-					},
-						key, &grpcRoute.Status, &r.ProviderResources.GRPCRouteStatuses)
+					})
 					delete(statusesToDelete.GRPCRouteStatusKeys, key)
 				}
 				for _, tlsRoute := range result.TLSRoutes {
 					key := utils.NamespacedName(tlsRoute)
-					message.HandleStore(message.Metadata{
+					r.ProviderResources.TLSRouteStatuses.Store(key, &tlsRoute.Status)
+					message.PublishMetric(message.Metadata{
 						Runner:  r.Name(),
 						Message: message.TLSRouteStatusMessageName,
-					},
-						key, &tlsRoute.Status, &r.ProviderResources.TLSRouteStatuses)
+					})
 					delete(statusesToDelete.TLSRouteStatusKeys, key)
 				}
 				for _, tcpRoute := range result.TCPRoutes {
 					key := utils.NamespacedName(tcpRoute)
-					message.HandleStore(message.Metadata{
+					r.ProviderResources.TCPRouteStatuses.Store(key, &tcpRoute.Status)
+					message.PublishMetric(message.Metadata{
 						Runner:  r.Name(),
 						Message: message.TCPRouteStatusMessageName,
-					},
-						key, &tcpRoute.Status, &r.ProviderResources.TCPRouteStatuses)
+					})
 					delete(statusesToDelete.TCPRouteStatusKeys, key)
 				}
 				for _, udpRoute := range result.UDPRoutes {
 					key := utils.NamespacedName(udpRoute)
-					message.HandleStore(message.Metadata{
+					r.ProviderResources.UDPRouteStatuses.Store(key, &udpRoute.Status)
+					message.PublishMetric(message.Metadata{
 						Runner:  r.Name(),
 						Message: message.UDPRouteStatusMessageName,
-					},
-						key, &udpRoute.Status, &r.ProviderResources.UDPRouteStatuses)
+					})
 					delete(statusesToDelete.UDPRouteStatusKeys, key)
 				}
 
@@ -280,11 +280,11 @@ func (r *Runner) subscribeAndTranslate(sub <-chan watchable.Snapshot[string, *re
 				for _, backendTLSPolicy := range result.BackendTLSPolicies {
 					key := utils.NamespacedName(backendTLSPolicy)
 					if !(reflect.ValueOf(backendTLSPolicy.Status).IsZero()) {
-						message.HandleStore(message.Metadata{
+						r.ProviderResources.BackendTLSPolicyStatuses.Store(key, &backendTLSPolicy.Status)
+						message.PublishMetric(message.Metadata{
 							Runner:  r.Name(),
 							Message: message.BackendTLSPolicyStatusMessageName,
-						},
-							key, &backendTLSPolicy.Status, &r.ProviderResources.BackendTLSPolicyStatuses)
+						})
 					}
 					delete(statusesToDelete.BackendTLSPolicyStatusKeys, key)
 				}
@@ -292,55 +292,55 @@ func (r *Runner) subscribeAndTranslate(sub <-chan watchable.Snapshot[string, *re
 				for _, clientTrafficPolicy := range result.ClientTrafficPolicies {
 					key := utils.NamespacedName(clientTrafficPolicy)
 					if !(reflect.ValueOf(clientTrafficPolicy.Status).IsZero()) {
-						message.HandleStore(message.Metadata{
+						r.ProviderResources.ClientTrafficPolicyStatuses.Store(key, &clientTrafficPolicy.Status)
+						message.PublishMetric(message.Metadata{
 							Runner:  r.Name(),
 							Message: message.ClientTrafficPolicyStatusMessageName,
-						},
-							key, &clientTrafficPolicy.Status, &r.ProviderResources.ClientTrafficPolicyStatuses)
+						})
 					}
 					delete(statusesToDelete.ClientTrafficPolicyStatusKeys, key)
 				}
 				for _, backendTrafficPolicy := range result.BackendTrafficPolicies {
 					key := utils.NamespacedName(backendTrafficPolicy)
 					if !(reflect.ValueOf(backendTrafficPolicy.Status).IsZero()) {
-						message.HandleStore(message.Metadata{
+						r.ProviderResources.BackendTrafficPolicyStatuses.Store(key, &backendTrafficPolicy.Status)
+						message.PublishMetric(message.Metadata{
 							Runner:  r.Name(),
 							Message: message.BackendTrafficPolicyStatusMessageName,
-						},
-							key, &backendTrafficPolicy.Status, &r.ProviderResources.BackendTrafficPolicyStatuses)
+						})
 					}
 					delete(statusesToDelete.BackendTrafficPolicyStatusKeys, key)
 				}
 				for _, securityPolicy := range result.SecurityPolicies {
 					key := utils.NamespacedName(securityPolicy)
 					if !(reflect.ValueOf(securityPolicy.Status).IsZero()) {
-						message.HandleStore(message.Metadata{
+						r.ProviderResources.SecurityPolicyStatuses.Store(key, &securityPolicy.Status)
+						message.PublishMetric(message.Metadata{
 							Runner:  r.Name(),
 							Message: message.SecurityPolicyStatusMessageName,
-						},
-							key, &securityPolicy.Status, &r.ProviderResources.SecurityPolicyStatuses)
+						})
 					}
 					delete(statusesToDelete.SecurityPolicyStatusKeys, key)
 				}
 				for _, envoyExtensionPolicy := range result.EnvoyExtensionPolicies {
 					key := utils.NamespacedName(envoyExtensionPolicy)
 					if !(reflect.ValueOf(envoyExtensionPolicy.Status).IsZero()) {
-						message.HandleStore(message.Metadata{
+						r.ProviderResources.EnvoyExtensionPolicyStatuses.Store(key, &envoyExtensionPolicy.Status)
+						message.PublishMetric(message.Metadata{
 							Runner:  r.Name(),
 							Message: message.EnvoyExtensionPolicyStatusMessageName,
-						},
-							key, &envoyExtensionPolicy.Status, &r.ProviderResources.EnvoyExtensionPolicyStatuses)
+						})
 					}
 					delete(statusesToDelete.EnvoyExtensionPolicyStatusKeys, key)
 				}
 				for _, backend := range result.Backends {
 					key := utils.NamespacedName(backend)
 					if !(reflect.ValueOf(backend.Status).IsZero()) {
-						message.HandleStore(message.Metadata{
+						r.ProviderResources.BackendStatuses.Store(key, &backend.Status)
+						message.PublishMetric(message.Metadata{
 							Runner:  r.Name(),
 							Message: message.BackendStatusMessageName,
-						},
-							key, &backend.Status, &r.ProviderResources.BackendStatuses)
+						})
 					}
 					delete(statusesToDelete.BackendStatusKeys, key)
 				}
@@ -351,11 +351,11 @@ func (r *Runner) subscribeAndTranslate(sub <-chan watchable.Snapshot[string, *re
 					}
 					if !(reflect.ValueOf(extServerPolicy.Object["status"]).IsZero()) {
 						policyStatus := unstructuredToPolicyStatus(extServerPolicy.Object["status"].(map[string]any))
-						message.HandleStore(message.Metadata{
+						r.ProviderResources.ExtensionPolicyStatuses.Store(key, &policyStatus)
+						message.PublishMetric(message.Metadata{
 							Runner:  r.Name(),
 							Message: message.ExtensionServerPoliciesStatusMessageName,
-						},
-							key, &policyStatus, &r.ProviderResources.ExtensionPolicyStatuses)
+						})
 					}
 					delete(statusesToDelete.ExtensionServerPolicyStatusKeys, key)
 				}
diff --git a/internal/message/watchutil.go b/internal/message/watchutil.go
index 32a9f61d3..645bd7eb0 100644
--- a/internal/message/watchutil.go
+++ b/internal/message/watchutil.go
@@ -28,6 +28,10 @@ type Metadata struct {
 	Message MessageName
 }
 
+func PublishMetric(meta Metadata) {
+	watchablePublishTotal.WithSuccess(meta.LabelValues()...).Increment()
+}
+
 func (m Metadata) LabelValues() []metrics.LabelValue {
 	labels := make([]metrics.LabelValue, 0, 2)
 	if m.Runner != "" {
@@ -100,8 +104,3 @@ func HandleSubscription[K comparable, V any](
 		}
 	}
 }
-
-func HandleStore[K comparable, V any](meta Metadata, key K, value V, publish *watchable.Map[K, V]) {
-	publish.Store(key, value)
-	watchablePublishTotal.WithSuccess(meta.LabelValues()...).Increment()
-}
diff --git a/internal/provider/kubernetes/controller.go b/internal/provider/kubernetes/controller.go
index bf6c1295a..7db2b157c 100644
--- a/internal/provider/kubernetes/controller.go
+++ b/internal/provider/kubernetes/controller.go
@@ -300,11 +300,11 @@ func (r *gatewayAPIReconciler) Reconcile(ctx context.Context, _ reconcile.Reques
 					false,
 					string(gwapiv1.GatewayClassReasonInvalidParameters),
 					msg)
-				message.HandleStore(message.Metadata{
+				r.resources.GatewayClassStatuses.Store(utils.NamespacedName(gc), &gc.Status)
+				message.PublishMetric(message.Metadata{
 					Runner:  string(egv1a1.LogComponentProviderRunner),
 					Message: message.GatewayClassStatusMessageName,
-				},
-					utils.NamespacedName(gc), &gc.Status, &r.resources.GatewayClassStatuses)
+				})
 				failToProcessGCParamsRef = true
 			}
 		}
@@ -322,11 +322,11 @@ func (r *gatewayAPIReconciler) Reconcile(ctx context.Context, _ reconcile.Reques
 				false,
 				string(gwapiv1.GatewayClassReasonAccepted),
 				fmt.Sprintf("%s: %v", status.MsgGatewayClassInvalidParams, err))
-			message.HandleStore(message.Metadata{
+			r.resources.GatewayClassStatuses.Store(utils.NamespacedName(gc), &gc.Status)
+			message.PublishMetric(message.Metadata{
 				Runner:  string(egv1a1.LogComponentProviderRunner),
 				Message: message.GatewayClassStatusMessageName,
-			},
-				utils.NamespacedName(gc), &gc.Status, &r.resources.GatewayClassStatuses)
+			})
 			failToProcessGCParamsRef = true
 		}
 
@@ -512,11 +512,11 @@ func (r *gatewayAPIReconciler) Reconcile(ctx context.Context, _ reconcile.Reques
 	// The Store is triggered even when there are no Gateways associated to the
 	// GatewayClass. This would happen in case the last Gateway is removed and the
 	// Store will be required to trigger a cleanup of envoy infra resources.
-	message.HandleStore(message.Metadata{
+	r.resources.GatewayAPIResources.Store(string(r.classController), &gwcResources)
+	message.PublishMetric(message.Metadata{
 		Runner:  string(egv1a1.LogComponentProviderRunner),
 		Message: message.ProviderResourcesMessageName,
-	},
-		string(r.classController), &gwcResources, &r.resources.GatewayAPIResources)
+	})
 
 	r.log.Info("reconciled gateways successfully")
 	return reconcile.Result{}, nil
diff --git a/internal/xds/translator/runner/runner.go b/internal/xds/translator/runner/runner.go
index c3e33c196..7f9bb2b5d 100644
--- a/internal/xds/translator/runner/runner.go
+++ b/internal/xds/translator/runner/runner.go
@@ -131,11 +131,11 @@ func (r *Runner) subscribeAndTranslate(sub <-chan watchable.Snapshot[string, *ir
 
 				// Publish
 				if err == nil {
-					message.HandleStore(message.Metadata{
+					r.Xds.Store(key, result)
+					message.PublishMetric(message.Metadata{
 						Runner:  r.Name(),
 						Message: message.XDSMessageName,
-					},
-						key, result, &r.Xds.Map)
+					})
 				} else {
 					r.Logger.Error(err, "skipped publishing xds resources")
 				}



Test patch:
diff --git a/internal/message/watchutil_test.go b/internal/message/watchutil_test.go
index 109c023f4..6e6472d14 100644
--- a/internal/message/watchutil_test.go
+++ b/internal/message/watchutil_test.go
@@ -91,39 +91,6 @@ func TestHandleSubscriptionAlreadyInitialized(t *testing.T) {
 	assert.Equal(t, 1, deleteCalls)
 }
 
-func TestHandleStore(t *testing.T) {
-	var m watchable.Map[string, any]
-	message.HandleStore(message.Metadata{Runner: "demo", Message: "demo"}, "foo", "bar", &m)
-
-	endCtx, end := context.WithCancel(context.Background())
-	go func() {
-		<-endCtx.Done()
-		message.HandleStore(message.Metadata{Runner: "demo", Message: "demo"}, "baz", "qux", &m)
-		m.Delete("qux")                                                                          // no-op
-		message.HandleStore(message.Metadata{Runner: "demo", Message: "demo"}, "foo", "bar", &m) // no-op
-		m.Delete("baz")
-		time.Sleep(100 * time.Millisecond)
-		m.Close()
-	}()
-
-	var storeCalls int
-	var deleteCalls int
-	message.HandleSubscription[string, any](
-		message.Metadata{Runner: "demo", Message: "demo"},
-		m.Subscribe(context.Background()),
-		func(update message.Update[string, any], errChans chan error) {
-			end()
-			if update.Delete {
-				deleteCalls++
-			} else {
-				storeCalls++
-			}
-		},
-	)
-	assert.Equal(t, 2, storeCalls)
-	assert.Equal(t, 1, deleteCalls)
-}
-
 func TestXdsIRUpdates(t *testing.T) {
 	tests := []struct {
 		desc    string
diff --git a/site/content/en/latest/tasks/traffic/zone-aware-routing.md b/site/content/en/latest/tasks/traffic/zone-aware-routing.md
index 260b30740..a07c70ded 100644
--- a/site/content/en/latest/tasks/traffic/zone-aware-routing.md
+++ b/site/content/en/latest/tasks/traffic/zone-aware-routing.md
@@ -2,10 +2,15 @@
 title: "Zone Aware Routing"
 ---
 
-EnvoyGateway makes use of [Envoy Zone Aware Routing][Envoy Zone Aware Routing] to support Kubernetes [Traffic Distribution][Traffic Distribution] 
-and [Topology Aware Routing][Topology Aware Routing] which are useful for keeping network traffic in the originating zone.
+EnvoyGateway makes use of [Envoy Zone Aware Routing][Envoy Zone Aware Routing] to keep network traffic in the originating zone.
 Preferring same-zone traffic between Pods in your cluster can help with reliability, performance (network latency and throughput), or cost.
 
+Zone-aware routing may be enabled in one of two ways:
+1. Configuring a [BackendTrafficPolicy][BackendTrafficPolicy] with the `loadbalancer.zoneAware` field
+2. Configuring a backendRef Kubernetes `Service` with  [Traffic Distribution][Traffic Distribution] or [Topology Aware Routing][Topology Aware Routing]
+
+When both a backendRef and a [BackendTrafficPolicy][BackendTrafficPolicy] include a configuration for zone awareness, the [BackendTrafficPolicy][BackendTrafficPolicy] takes precedence.
+
 ## Prerequisites
 * The Kubernetes cluster's nodes must indicate topology information via the `topology.kubernetes.io/zone` [well-known label][Kubernetes well-known metadata].
 * There must be at least two valid topology zones for scheduling.
@@ -13,7 +18,9 @@ Preferring same-zone traffic between Pods in your cluster can help with reliabil
 
 ## Configuration
 
-### Kubernetes Service
+Choose one of the following configuration options.
+
+### Option 1: Kubernetes Service
 Create the example Kubernetes Service with either topology aware routing or traffic distribution enabled.
 
 #### Topology Aware Routing
@@ -110,12 +117,64 @@ spec:
 {{% /tab %}}
 {{< /tabpane >}}
 
+### Option 2: BackendTrafficPolicy
+Zone aware routing can also be enabled directly with a [BackendTrafficPolicy][BackendTrafficPolicy].
+The example below configures similar behavior to Kubernetes Traffic Distribution and forces all traffic to the local zone via the `force` field instead
+of Envoy's default behavior which _prefers_ routing locally as much as possible while still achieving overall equal request distribution across all endpoints.
+
+{{< tabpane text=true >}}
+{{% tab header="Apply from stdin" %}}
+```shell
+cat <<EOF | kubectl apply -f -
+apiVersion: gateway.envoyproxy.io/v1alpha1
+kind: BackendTrafficPolicy
+metadata:
+  name: zone-aware-routing
+spec:
+  targetRefs:
+  - group: gateway.networking.k8s.io
+    kind: HTTPRoute
+    name: zone-aware-routing
+  loadBalancer:
+    type: RoundRobin
+    zoneAware:
+      preferLocal: # Enables Envoy to prefer local zone endpoints while maintaining overall traffic balance across zones
+        minEndpointsThreshold: 1 # Zone-aware routing is disabled if total number of endpoints is less than this threshold
+        force: # Forces all traffic to stay within the local zone, regardless of upstream endpoint distribution between zones
+          minEndpointsInZoneThreshold: 1  # If fewer local endpoints exist than this threshold, fallback to standard zone-aware routing behavior
+EOF
+```
+{{% /tab %}}
+{{% tab header="Apply from file" %}}
+Save and apply the following resource to your cluster:
+```yaml
+---
+apiVersion: gateway.envoyproxy.io/v1alpha1
+kind: BackendTrafficPolicy
+metadata:
+  name: zone-aware-routing
+spec:
+  targetRefs:
+  - group: gateway.networking.k8s.io
+    kind: HTTPRoute
+    name: zone-aware-routing
+  loadBalancer:
+    type: RoundRobin
+    zoneAware:
+      preferLocal: # Enables Envoy to prefer local zone endpoints while maintaining overall traffic balance across zones
+        minEndpointsThreshold: 1 # Zone-aware routing is disabled if total number of endpoints is less than this threshold
+        force: # Forces all traffic to stay within the local zone, regardless of upstream endpoint distribution between zones
+          minEndpointsInZoneThreshold: 1  # If fewer local endpoints exist than this threshold, fallback to standard zone-aware routing behavior
+```
+{{% /tab %}}
+{{< /tabpane >}}
+
 
 ### Example deployments and HTTPRoute
 Next apply the example manifests to create two Deployments and an HTTPRoute. For the test configuration one Deployment
 (zone-aware-routing-backend-local) includes affinity for EnvoyProxy Pods to ensure its Pods are scheduled in the same
-zone and the second Deployment (zone-aware-routing-backend-nonlocal) uses anti-affinity to ensure its Pods _don't_ 
-schedule to the same zone in order to demonstrate functionality. 
+zone and the second Deployment (zone-aware-routing-backend-nonlocal) uses anti-affinity to ensure its Pods _don't_
+schedule to the same zone in order to demonstrate functionality.
 ```shell
 kubectl apply -f https://raw.githubusercontent.com/envoyproxy/gateway/latest/examples/kubernetes/zone-aware-routing.yaml -n default
 ```
@@ -129,7 +188,7 @@ zone-aware-routing-backend-nonlocal   3/3     3            3           9m1s
 
 ```
 
-An HTTPRoute resource is created for the `/zone-aware-routing` path prefix along with two example Deployment resources that 
+An HTTPRoute resource is created for the `/zone-aware-routing` path prefix along with two example Deployment resources that
 are respectively configured with pod affinity/anti-affinity targeting the Envoy Proxy Pods for testing.
 
 Verify the HTTPRoute configuration and status:
@@ -138,6 +197,12 @@ Verify the HTTPRoute configuration and status:
 kubectl get httproute/zone-aware-routing -o yaml
 ```
 
+If used during configuration, verify the [BackendTrafficPolicy][BackendTrafficPolicy]:
+
+```shell
+kubectl get backendtrafficpolicy/zone-aware-routing -o yaml
+```
+
 ## Testing
 
 Ensure the `GATEWAY_HOST` environment variable from the [Quickstart](../../quickstart) is set. If not, follow the
@@ -204,9 +269,11 @@ kubectl delete service/zone-aware-routing-backend
 kubectl delete deployment/zone-aware-routing-backend-local
 kubectl delete deployment/zone-aware-routing-backend-nonlocal
 kubectl delete httproute/zone-aware-routing
+kubectl delete backendtrafficpolicy/zone-aware-routing
 ```
 
 [Traffic Distribution]: https://kubernetes.io/docs/concepts/services-networking/service/#traffic-distribution
 [Topology Aware Routing]: https://kubernetes.io/docs/concepts/services-networking/topology-aware-routing/
 [Kubernetes well-known metadata]: https://kubernetes.io/docs/reference/labels-annotations-taints/#topologykubernetesiozone
-[Envoy Zone Aware Routing]: https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/load_balancing/zone_aware
\ No newline at end of file
+[Envoy Zone Aware Routing]: https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/load_balancing/zone_aware
+[BackendTrafficPolicy]: ../../../api/extension_types#backendtrafficpolicy
\ No newline at end of file