Repository: istio/istio (2025-07-10T16:34:31Z)

Issue:
Gateway deployer runs deployment loop on each Gateway status update
Gateway status updates frequently due to attachedRoutes. This triggers the gateway deployer to run, which is not cheap. We should find a way to avoid the redundant writes


Discussion:
@howardjohn are you thinking short circuiting the Reconcile in [deploymentcontroller.go](https://github.com/istio/istio/blob/master/pilot/pkg/config/kube/gateway/deploymentcontroller.go#L393), or don't even be notified through the index somehow if the spec.status.listeners.attachedRoutes is updated?

It looks like short circuiting in Reconcile will skip the template generation, but there's still the overhead of a queue entry being made and processed.
the queue entry should be no big deal - the actual reconcile runs template and makes write API calls to the API server which are both expensive. 

But it's probably easier from implementation perspective to skip before the queue since we lose context by the time it gets to the queue
By the time Reconcile is invoked, the old spec for the gateway resource is not passed, so the context is lost, to your point.  Better to filter at the point the UpdateFunc is invoked, for the Gateway resource.  If the object type is a gateway resource, and the only things that's changed is the attachedRoutes, then log a debug message and skip adding it to the controller's queue.

I can give it a shot.
We might be able to skip any change to only status (as opposed to spec/metadata), I don't think we depend on anything there. If that's the case we can just diff the generation and labels+annotation I believe 
We can also look into the status for the two conditions, Accepted and Programmed.  There's the observedGeneration there.  If the conditions "status" is "True", and the observedGeneration in both conditions matches the generation, we can skip.
@howardjohn PR is published with approach of filtering before putting in queue.



Patch:
diff --git a/pilot/pkg/config/kube/gateway/deploymentcontroller.go b/pilot/pkg/config/kube/gateway/deploymentcontroller.go
index bf841b0c0c8a..5f2e41ba36d3 100644
--- a/pilot/pkg/config/kube/gateway/deploymentcontroller.go
+++ b/pilot/pkg/config/kube/gateway/deploymentcontroller.go
@@ -330,7 +330,35 @@ func NewDeploymentController(
 		}
 	}))
 
-	gateways.AddEventHandler(controllers.ObjectHandler(dc.queue.AddObject))
+	// we check if the generation has changed on a gateway, or if the annotations or labels have been updated
+	// reconciliation is expensive, so status updates for attachedRoutes can be skipped.
+
+	gateways.AddEventHandler(
+		controllers.FromEventHandler(func(o controllers.Event) {
+			switch o.Event {
+			case controllers.EventAdd:
+				dc.queue.AddObject(o.New)
+			case controllers.EventUpdate:
+				if o.New.GetGeneration() != o.Old.GetGeneration() {
+					dc.queue.AddObject(o.New)
+					break
+				}
+				if !reflect.DeepEqual(o.New.GetLabels(), o.Old.GetLabels()) {
+					dc.queue.AddObject(o.New)
+					break
+				}
+				if !reflect.DeepEqual(o.New.GetAnnotations(), o.Old.GetAnnotations()) {
+					dc.queue.AddObject(o.New)
+					break
+				}
+				log.Debugf("skip unchanged gateway %s", o.New.GetName())
+			case controllers.EventDelete:
+				dc.queue.AddObject(o.Old)
+			default:
+				log.Errorf("unhandled event for gateway object %v", o)
+			}
+		}))
+
 	gatewayClasses.AddEventHandler(controllers.ObjectHandler(func(o controllers.Object) {
 		for _, g := range dc.gateways.List(metav1.NamespaceAll, klabels.Everything()) {
 			if string(g.Spec.GatewayClassName) == o.GetName() {


Test patch:
diff --git a/pilot/pkg/config/kube/gateway/deploymentcontroller_test.go b/pilot/pkg/config/kube/gateway/deploymentcontroller_test.go
index 2ebafbcf3fa3..bfc4295cd1cd 100644
--- a/pilot/pkg/config/kube/gateway/deploymentcontroller_test.go
+++ b/pilot/pkg/config/kube/gateway/deploymentcontroller_test.go
@@ -28,6 +28,7 @@ import (
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/apimachinery/pkg/types"
 	kubeVersion "k8s.io/apimachinery/pkg/version"
 	fakediscovery "k8s.io/client-go/discovery/fake"
 	k8s "sigs.k8s.io/gateway-api/apis/v1"
@@ -728,6 +729,300 @@ func TestVersionManagement(t *testing.T) {
 	assert.Equal(t, reconciles.Load(), wantReconcile)
 }
 
+func TestHandlerEnqueueFunction(t *testing.T) {
+	log.SetOutputLevel(istiolog.DebugLevel)
+	defaultNamespace := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "default"}}
+	defaultGatewayClass := &k8sbeta.GatewayClass{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: features.GatewayAPIDefaultGatewayClass,
+		},
+		Spec: k8s.GatewayClassSpec{
+			ControllerName: k8s.GatewayController(features.ManagedGatewayController),
+		},
+	}
+
+	defaultObjects := []runtime.Object{defaultNamespace, defaultGatewayClass}
+	discoveryNamespaceFilter := buildFilter(defaultNamespace.GetName())
+
+	tests := []struct {
+		name       string
+		event      controllers.Event
+		reconciles int32
+		objects    []runtime.Object
+	}{
+		{
+			name: "add event",
+			event: controllers.Event{
+				Event: controllers.EventAdd,
+				New: &k8sbeta.Gateway{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:        "new-add",
+						Namespace:   defaultNamespace.GetName(),
+						Labels:      map[string]string{"should": "see"},
+						Annotations: map[string]string{"should": "see"},
+					},
+					Spec: k8s.GatewaySpec{
+						GatewayClassName: k8s.ObjectName(features.GatewayAPIDefaultGatewayClass),
+					},
+				},
+			},
+			reconciles: int32(1),
+			objects:    defaultObjects,
+		},
+		{
+			name: "delete event",
+			event: controllers.Event{
+				Event: controllers.EventDelete,
+				Old: &k8sbeta.Gateway{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:        "old-delete",
+						Namespace:   defaultNamespace.GetName(),
+						Labels:      map[string]string{"should": "see"},
+						Annotations: map[string]string{"should": "see"},
+					},
+					Spec: k8s.GatewaySpec{
+						GatewayClassName: k8s.ObjectName(features.GatewayAPIDefaultGatewayClass),
+					},
+				},
+			},
+			reconciles: int32(1),
+			objects:    defaultObjects,
+		},
+		{
+			name: "update event change annotation",
+			event: controllers.Event{
+				Event: controllers.EventUpdate,
+				New: &k8sbeta.Gateway{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:        "update-1",
+						Namespace:   defaultNamespace.GetName(),
+						Labels:      map[string]string{"should": "see"},
+						Annotations: map[string]string{"should": "see-new"},
+					},
+					Spec: k8s.GatewaySpec{
+						GatewayClassName: k8s.ObjectName(features.GatewayAPIDefaultGatewayClass),
+					},
+					Status: k8s.GatewayStatus{
+						Listeners: []k8s.ListenerStatus{
+							{
+								AttachedRoutes: 100,
+							},
+						},
+					},
+				},
+				Old: &k8sbeta.Gateway{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:        "update-1",
+						Namespace:   defaultNamespace.GetName(),
+						Labels:      map[string]string{"should": "see"},
+						Annotations: map[string]string{"should": "see"},
+					},
+					Spec: k8s.GatewaySpec{
+						GatewayClassName: k8s.ObjectName(features.GatewayAPIDefaultGatewayClass),
+					},
+					Status: k8s.GatewayStatus{
+						Listeners: []k8s.ListenerStatus{
+							{
+								AttachedRoutes: 1,
+							},
+						},
+					},
+				},
+			},
+			reconciles: int32(2),
+			objects:    defaultObjects,
+		},
+		{
+			name: "update event change label",
+			event: controllers.Event{
+				Event: controllers.EventUpdate,
+				New: &k8sbeta.Gateway{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:        "update-2",
+						Namespace:   defaultNamespace.GetName(),
+						Labels:      map[string]string{"should": "see-new"},
+						Annotations: map[string]string{"should": "see"},
+					},
+					Spec: k8s.GatewaySpec{
+						GatewayClassName: k8s.ObjectName(features.GatewayAPIDefaultGatewayClass),
+					},
+					Status: k8s.GatewayStatus{
+						Listeners: []k8s.ListenerStatus{
+							{
+								AttachedRoutes: 100,
+							},
+						},
+					},
+				},
+				Old: &k8sbeta.Gateway{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:        "update-2",
+						Namespace:   defaultNamespace.GetName(),
+						Labels:      map[string]string{"should": "see"},
+						Annotations: map[string]string{"should": "see"},
+					},
+					Spec: k8s.GatewaySpec{
+						GatewayClassName: k8s.ObjectName(features.GatewayAPIDefaultGatewayClass),
+					},
+					Status: k8s.GatewayStatus{
+						Listeners: []k8s.ListenerStatus{
+							{
+								AttachedRoutes: 1,
+							},
+						},
+					},
+				},
+			},
+			reconciles: int32(2),
+			objects:    defaultObjects,
+		},
+		{
+			name: "update event change gateway spec",
+			event: controllers.Event{
+				Event: controllers.EventUpdate,
+				New: &k8sbeta.Gateway{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:        "update-3",
+						Namespace:   defaultNamespace.GetName(),
+						Labels:      map[string]string{"should": "see"},
+						Annotations: map[string]string{"should": "see"},
+						Generation:  1,
+					},
+					Spec: k8s.GatewaySpec{
+						GatewayClassName: k8s.ObjectName(features.GatewayAPIDefaultGatewayClass),
+					},
+					Status: k8s.GatewayStatus{
+						Listeners: []k8s.ListenerStatus{
+							{
+								AttachedRoutes: 100,
+							},
+						},
+					},
+				},
+				Old: &k8sbeta.Gateway{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:        "update-3",
+						Namespace:   defaultNamespace.GetName(),
+						Labels:      map[string]string{"should": "see"},
+						Annotations: map[string]string{"should": "see"},
+						Generation:  0,
+					},
+					Spec: k8s.GatewaySpec{
+						GatewayClassName: k8s.ObjectName(features.GatewayAPIDefaultGatewayClass),
+					},
+					Status: k8s.GatewayStatus{
+						Listeners: []k8s.ListenerStatus{
+							{
+								AttachedRoutes: 1,
+							},
+						},
+					},
+				},
+			},
+			reconciles: int32(2),
+			objects:    defaultObjects,
+		},
+		{
+			name: "update event no change gateway spec",
+			event: controllers.Event{
+				Event: controllers.EventUpdate,
+				New: &k8sbeta.Gateway{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:        "update-4",
+						Namespace:   defaultNamespace.GetName(),
+						Labels:      map[string]string{"should": "see"},
+						Annotations: map[string]string{"should": "see"},
+						Generation:  0,
+					},
+					Spec: k8s.GatewaySpec{
+						GatewayClassName: k8s.ObjectName(features.GatewayAPIDefaultGatewayClass),
+					},
+					Status: k8s.GatewayStatus{
+						Listeners: []k8s.ListenerStatus{
+							{
+								AttachedRoutes: 100,
+							},
+						},
+					},
+				},
+				Old: &k8sbeta.Gateway{
+					ObjectMeta: metav1.ObjectMeta{
+						Name:        "update-4",
+						Namespace:   defaultNamespace.GetName(),
+						Labels:      map[string]string{"should": "see"},
+						Annotations: map[string]string{"should": "see"},
+						Generation:  0,
+					},
+					Spec: k8s.GatewaySpec{
+						GatewayClassName: k8s.ObjectName(features.GatewayAPIDefaultGatewayClass),
+					},
+					Status: k8s.GatewayStatus{
+						Listeners: []k8s.ListenerStatus{
+							{
+								AttachedRoutes: 1,
+							},
+						},
+					},
+				},
+			},
+			reconciles: int32(1),
+			objects:    defaultObjects,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			reconciles := atomic.NewInt32(0)
+			dummyReconcile := func(types.NamespacedName) error {
+				reconciles.Inc()
+				return nil
+			}
+
+			dummyWebHookInjectFn := func() inject.WebhookConfig {
+				return inject.WebhookConfig{}
+			}
+			stop := test.NewStop(t)
+
+			if tt.event.Event == controllers.EventUpdate || tt.event.Event == controllers.EventDelete {
+				tt.objects = append(tt.objects, tt.event.Old)
+			}
+			client := kube.NewFakeClient(tt.objects...)
+			kube.SetObjectFilter(client, discoveryNamespaceFilter)
+			client.Kube().Discovery().(*fakediscovery.FakeDiscovery).FakedServerVersion = &kubeVersion.Info{Major: "1", Minor: "28"}
+
+			tw := revisions.NewTagWatcher(client, "")
+			env := model.NewEnvironment()
+			go tw.Run(stop)
+
+			d := NewDeploymentController(client, cluster.ID(features.ClusterName), env, dummyWebHookInjectFn, func(fn func()) {
+			}, tw, "", "")
+			d.queue.ShutDownEarly()
+			d.queue = controllers.NewQueue("fake gateway queue",
+				controllers.WithReconciler(dummyReconcile))
+			client.RunAndWait(stop)
+			go d.Run(stop)
+
+			switch tt.event.Event {
+			case controllers.EventAdd:
+				gw := tt.event.New.(*k8sbeta.Gateway)
+				kclient.NewWriteClient[*k8sbeta.Gateway](client).Create(gw.DeepCopy())
+			case controllers.EventDelete:
+				gw := tt.event.Old.(*k8sbeta.Gateway)
+				kclient.NewWriteClient[*k8sbeta.Gateway](client).Delete(gw.Name, gw.Namespace)
+			case controllers.EventUpdate:
+				newGw := tt.event.New.(*k8sbeta.Gateway)
+				oldGw := tt.event.Old.(*k8sbeta.Gateway)
+				kclient.NewWriteClient[*k8sbeta.Gateway](client).Create(oldGw.DeepCopy())
+				kube.WaitForCacheSync("test", stop, d.queue.HasSynced)
+				kclient.NewWriteClient[*k8sbeta.Gateway](client).Update(newGw.DeepCopy())
+			}
+			kube.WaitForCacheSync("test", stop, d.queue.HasSynced)
+
+			assert.EventuallyEqual(t, reconciles.Load, tt.reconciles, retry.Timeout(time.Second*5), retry.Message("reconciliations count check"))
+		})
+	}
+}
+
 func testInjectionConfig(t test.Failer, values string) func() inject.WebhookConfig {
 	var vc inject.ValuesConfig
 	var err error