Repository: kubernetes-sigs/controller-runtime
Pull request #3152: :seedling: Handlers: Use low priority when object is unchanged and priority queue
Author: login: alvaroaleman
Labels: name: cncf-cla: yes
description: Indicates the PR's author has signed the CNCF CLA.
name: size/L
description: Denotes a PR that changes 100-499 lines, ignoring generated files.
name: approved
description: Indicates a PR has been approved by an approver from all required OWNERS files.
name: lgtm
description: "Looks good to me", indicates that a PR is ready to be merged.
Created: 2025-03-11T18:36:25Z | Merged: 2025-03-12T19:49:47Z

This change makes the `TypedFuncs` and `enqueueRequestsFromMapFunc` set a low priority when the object is unchanged by default, as well as extend the test coverage to validate this behavior for `EnqueueRequestForOwner`.

Fixes https://github.com/kubernetes-sigs/controller-runtime/issues/3105

<!-- please add an icon to the title of this PR (see VERSIONING.md), and delete this line and similar ones -->
<!-- the icon will be either ⚠ (:warning:, major), ✨ (:sparkles:, minor), 🐛 (:bug:, patch), 📖 (:book:, docs), or 🌱 (:seedling:, other) -->

<!-- What does this do, and why do we need it? -->



Diff:
diff --git a/pkg/handler/enqueue_mapped.go b/pkg/handler/enqueue_mapped.go
index 491bc40c42..7796ed5853 100644
--- a/pkg/handler/enqueue_mapped.go
+++ b/pkg/handler/enqueue_mapped.go
@@ -18,9 +18,11 @@ package handler
 
 import (
 	"context"
+	"reflect"
 
 	"k8s.io/client-go/util/workqueue"
 	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/controller/priorityqueue"
 	"sigs.k8s.io/controller-runtime/pkg/event"
 	"sigs.k8s.io/controller-runtime/pkg/reconcile"
 )
@@ -81,7 +83,15 @@ func (e *enqueueRequestsFromMapFunc[object, request]) Create(
 	q workqueue.TypedRateLimitingInterface[request],
 ) {
 	reqs := map[request]empty{}
-	e.mapAndEnqueue(ctx, q, evt.Object, reqs)
+
+	var lowPriority bool
+	if reflect.TypeFor[object]().Implements(reflect.TypeFor[client.Object]()) && isPriorityQueue(q) && !isNil(evt.Object) {
+		clientObjectEvent := event.CreateEvent{Object: any(evt.Object).(client.Object)}
+		if isObjectUnchanged(clientObjectEvent) {
+			lowPriority = true
+		}
+	}
+	e.mapAndEnqueue(ctx, q, evt.Object, reqs, lowPriority)
 }
 
 // Update implements EventHandler.
@@ -90,9 +100,13 @@ func (e *enqueueRequestsFromMapFunc[object, request]) Update(
 	evt event.TypedUpdateEvent[object],
 	q workqueue.TypedRateLimitingInterface[request],
 ) {
+	var lowPriority bool
+	if reflect.TypeFor[object]().Implements(reflect.TypeFor[client.Object]()) && isPriorityQueue(q) && !isNil(evt.ObjectOld) && !isNil(evt.ObjectNew) {
+		lowPriority = any(evt.ObjectOld).(client.Object).GetResourceVersion() == any(evt.ObjectNew).(client.Object).GetResourceVersion()
+	}
 	reqs := map[request]empty{}
-	e.mapAndEnqueue(ctx, q, evt.ObjectOld, reqs)
-	e.mapAndEnqueue(ctx, q, evt.ObjectNew, reqs)
+	e.mapAndEnqueue(ctx, q, evt.ObjectOld, reqs, lowPriority)
+	e.mapAndEnqueue(ctx, q, evt.ObjectNew, reqs, lowPriority)
 }
 
 // Delete implements EventHandler.
@@ -102,7 +116,7 @@ func (e *enqueueRequestsFromMapFunc[object, request]) Delete(
 	q workqueue.TypedRateLimitingInterface[request],
 ) {
 	reqs := map[request]empty{}
-	e.mapAndEnqueue(ctx, q, evt.Object, reqs)
+	e.mapAndEnqueue(ctx, q, evt.Object, reqs, false)
 }
 
 // Generic implements EventHandler.
@@ -112,14 +126,26 @@ func (e *enqueueRequestsFromMapFunc[object, request]) Generic(
 	q workqueue.TypedRateLimitingInterface[request],
 ) {
 	reqs := map[request]empty{}
-	e.mapAndEnqueue(ctx, q, evt.Object, reqs)
+	e.mapAndEnqueue(ctx, q, evt.Object, reqs, false)
 }
 
-func (e *enqueueRequestsFromMapFunc[object, request]) mapAndEnqueue(ctx context.Context, q workqueue.TypedRateLimitingInterface[request], o object, reqs map[request]empty) {
+func (e *enqueueRequestsFromMapFunc[object, request]) mapAndEnqueue(
+	ctx context.Context,
+	q workqueue.TypedRateLimitingInterface[request],
+	o object,
+	reqs map[request]empty,
+	unchanged bool,
+) {
 	for _, req := range e.toRequests(ctx, o) {
 		_, ok := reqs[req]
 		if !ok {
-			q.Add(req)
+			if unchanged {
+				q.(priorityqueue.PriorityQueue[request]).AddWithOpts(priorityqueue.AddOpts{
+					Priority: LowPriority,
+				}, req)
+			} else {
+				q.Add(req)
+			}
 			reqs[req] = empty{}
 		}
 	}
diff --git a/pkg/handler/eventhandler.go b/pkg/handler/eventhandler.go
index 00211b9ac7..bb919dba7a 100644
--- a/pkg/handler/eventhandler.go
+++ b/pkg/handler/eventhandler.go
@@ -18,6 +18,7 @@ package handler
 
 import (
 	"context"
+	"reflect"
 	"time"
 
 	"k8s.io/client-go/util/workqueue"
@@ -108,10 +109,40 @@ type TypedFuncs[object any, request comparable] struct {
 	GenericFunc func(context.Context, event.TypedGenericEvent[object], workqueue.TypedRateLimitingInterface[request])
 }
 
+func isPriorityQueue[request comparable](q workqueue.TypedRateLimitingInterface[request]) bool {
+	_, ok := q.(priorityqueue.PriorityQueue[request])
+	return ok
+}
+
 // Create implements EventHandler.
 func (h TypedFuncs[object, request]) Create(ctx context.Context, e event.TypedCreateEvent[object], q workqueue.TypedRateLimitingInterface[request]) {
 	if h.CreateFunc != nil {
-		h.CreateFunc(ctx, e, q)
+		if !reflect.TypeFor[object]().Implements(reflect.TypeFor[client.Object]()) || !isPriorityQueue(q) || isNil(e.Object) {
+			h.CreateFunc(ctx, e, q)
+			return
+		}
+		wq := workqueueWithCustomAddFunc[request]{
+			TypedRateLimitingInterface: q,
+			// We already know that we have a priority queue, that event.Object implements
+			// client.Object and that its not nil
+			addFunc: func(item request, q workqueue.TypedRateLimitingInterface[request]) {
+				// We construct a new event typed to client.Object because isObjectUnchanged
+				// is a generic and hence has to know at compile time the type of the event
+				// it gets. We only figure that out at runtime though, but we know for sure
+				// that it implements client.Object at this point so we can hardcode the event
+				// type to that.
+				evt := event.CreateEvent{Object: any(e.Object).(client.Object)}
+				var priority int
+				if isObjectUnchanged(evt) {
+					priority = LowPriority
+				}
+				q.(priorityqueue.PriorityQueue[request]).AddWithOpts(
+					priorityqueue.AddOpts{Priority: priority},
+					item,
+				)
+			},
+		}
+		h.CreateFunc(ctx, e, wq)
 	}
 }
 
@@ -125,7 +156,27 @@ func (h TypedFuncs[object, request]) Delete(ctx context.Context, e event.TypedDe
 // Update implements EventHandler.
 func (h TypedFuncs[object, request]) Update(ctx context.Context, e event.TypedUpdateEvent[object], q workqueue.TypedRateLimitingInterface[request]) {
 	if h.UpdateFunc != nil {
-		h.UpdateFunc(ctx, e, q)
+		if !reflect.TypeFor[object]().Implements(reflect.TypeFor[client.Object]()) || !isPriorityQueue(q) || isNil(e.ObjectOld) || isNil(e.ObjectNew) {
+			h.UpdateFunc(ctx, e, q)
+			return
+		}
+
+		wq := workqueueWithCustomAddFunc[request]{
+			TypedRateLimitingInterface: q,
+			// We already know that we have a priority queue, that event.ObjectOld and ObjectNew implement
+			// client.Object and that they are  not nil
+			addFunc: func(item request, q workqueue.TypedRateLimitingInterface[request]) {
+				var priority int
+				if any(e.ObjectOld).(client.Object).GetResourceVersion() == any(e.ObjectNew).(client.Object).GetResourceVersion() {
+					priority = LowPriority
+				}
+				q.(priorityqueue.PriorityQueue[request]).AddWithOpts(
+					priorityqueue.AddOpts{Priority: priority},
+					item,
+				)
+			},
+		}
+		h.UpdateFunc(ctx, e, wq)
 	}
 }
 
@@ -142,25 +193,10 @@ const LowPriority = -100
 // WithLowPriorityWhenUnchanged reduces the priority of events stemming from the initial listwatch or from a resync if
 // and only if a priorityqueue.PriorityQueue is used. If not, it does nothing.
 func WithLowPriorityWhenUnchanged[object client.Object, request comparable](u TypedEventHandler[object, request]) TypedEventHandler[object, request] {
+	// TypedFuncs already implements this so just wrap
 	return TypedFuncs[object, request]{
-		CreateFunc: func(ctx context.Context, tce event.TypedCreateEvent[object], trli workqueue.TypedRateLimitingInterface[request]) {
-			// Due to how the handlers are factored, we have to wrap the workqueue to be able
-			// to inject custom behavior.
-			u.Create(ctx, tce, workqueueWithCustomAddFunc[request]{
-				TypedRateLimitingInterface: trli,
-				addFunc: func(item request, q workqueue.TypedRateLimitingInterface[request]) {
-					addToQueueCreate(q, tce, item)
-				},
-			})
-		},
-		UpdateFunc: func(ctx context.Context, tue event.TypedUpdateEvent[object], trli workqueue.TypedRateLimitingInterface[request]) {
-			u.Update(ctx, tue, workqueueWithCustomAddFunc[request]{
-				TypedRateLimitingInterface: trli,
-				addFunc: func(item request, q workqueue.TypedRateLimitingInterface[request]) {
-					addToQueueUpdate(q, tue, item)
-				},
-			})
-		},
+		CreateFunc:  u.Create,
+		UpdateFunc:  u.Update,
 		DeleteFunc:  u.Delete,
 		GenericFunc: u.Generic,
 	}
diff --git a/pkg/handler/eventhandler_test.go b/pkg/handler/eventhandler_test.go
index f86760e4e4..db6203ef4c 100644
--- a/pkg/handler/eventhandler_test.go
+++ b/pkg/handler/eventhandler_test.go
@@ -775,6 +775,216 @@ var _ = Describe("Eventhandler", func() {
 		})
 	})
 
+	Describe("WithLowPriorityWhenUnchanged", func() {
+		handlerPriorityTests := []struct {
+			name    string
+			handler func() handler.EventHandler
+		}{
+			{
+				name:    "WithLowPriorityWhenUnchanged wrapper",
+				handler: func() handler.EventHandler { return handler.WithLowPriorityWhenUnchanged(customHandler{}) },
+			},
+			{
+				name:    "EnqueueRequestForObject",
+				handler: func() handler.EventHandler { return &handler.EnqueueRequestForObject{} },
+			},
+			{
+				name: "EnqueueRequestForOwner",
+				handler: func() handler.EventHandler {
+					return handler.EnqueueRequestForOwner(
+						scheme.Scheme,
+						mapper,
+						&corev1.Pod{},
+					)
+				},
+			},
+			{
+				name: "Funcs",
+				handler: func() handler.EventHandler {
+					return handler.TypedFuncs[client.Object, reconcile.Request]{
+						CreateFunc: func(ctx context.Context, tce event.TypedCreateEvent[client.Object], wq workqueue.TypedRateLimitingInterface[reconcile.Request]) {
+							wq.Add(reconcile.Request{NamespacedName: types.NamespacedName{
+								Namespace: tce.Object.GetNamespace(),
+								Name:      tce.Object.GetName(),
+							}})
+						},
+						UpdateFunc: func(ctx context.Context, tue event.TypedUpdateEvent[client.Object], wq workqueue.TypedRateLimitingInterface[reconcile.Request]) {
+							wq.Add(reconcile.Request{NamespacedName: types.NamespacedName{
+								Namespace: tue.ObjectNew.GetNamespace(),
+								Name:      tue.ObjectNew.GetName(),
+							}})
+						},
+					}
+				},
+			},
+			{
+				name: "EnqueueRequestsFromMapFunc",
+				handler: func() handler.EventHandler {
+					return handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request {
+						return []reconcile.Request{{NamespacedName: types.NamespacedName{
+							Name:      obj.GetName(),
+							Namespace: obj.GetNamespace(),
+						}}}
+					})
+				},
+			},
+		}
+		for _, test := range handlerPriorityTests {
+			When("handler is "+test.name, func() {
+				It("should lower the priority of a create request for an object that was created more than one minute in the past", func() {
+					actualOpts := priorityqueue.AddOpts{}
+					var actualRequests []reconcile.Request
+					wq := &fakePriorityQueue{
+						addWithOpts: func(o priorityqueue.AddOpts, items ...reconcile.Request) {
+							actualOpts = o
+							actualRequests = items
+						},
+					}
+
+					test.handler().Create(ctx, event.CreateEvent{
+						Object: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{
+							Name: "my-pod",
+							OwnerReferences: []metav1.OwnerReference{{
+								Kind: "Pod",
+								Name: "my-pod",
+							}},
+						}},
+					}, wq)
+
+					Expect(actualOpts).To(Equal(priorityqueue.AddOpts{Priority: handler.LowPriority}))
+					Expect(actualRequests).To(Equal([]reconcile.Request{{NamespacedName: types.NamespacedName{Name: "my-pod"}}}))
+				})
+
+				It("should not lower the priority of a create request for an object that was created less than one minute in the past", func() {
+					actualOpts := priorityqueue.AddOpts{}
+					var actualRequests []reconcile.Request
+					wq := &fakePriorityQueue{
+						addWithOpts: func(o priorityqueue.AddOpts, items ...reconcile.Request) {
+							actualOpts = o
+							actualRequests = items
+						},
+					}
+
+					test.handler().Create(ctx, event.CreateEvent{
+						Object: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{
+							Name:              "my-pod",
+							CreationTimestamp: metav1.Now(),
+							OwnerReferences: []metav1.OwnerReference{{
+								Kind: "Pod",
+								Name: "my-pod",
+							}},
+						}},
+					}, wq)
+
+					Expect(actualOpts).To(Equal(priorityqueue.AddOpts{}))
+					Expect(actualRequests).To(Equal([]reconcile.Request{{NamespacedName: types.NamespacedName{Name: "my-pod"}}}))
+				})
+
+				It("should lower the priority of an update request with unchanged RV", func() {
+					actualOpts := priorityqueue.AddOpts{}
+					var actualRequests []reconcile.Request
+					wq := &fakePriorityQueue{
+						addWithOpts: func(o priorityqueue.AddOpts, items ...reconcile.Request) {
+							actualOpts = o
+							actualRequests = items
+						},
+					}
+
+					test.handler().Update(ctx, event.UpdateEvent{
+						ObjectOld: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{
+							Name: "my-pod",
+							OwnerReferences: []metav1.OwnerReference{{
+								Kind: "Pod",
+								Name: "my-pod",
+							}},
+						}},
+						ObjectNew: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{
+							Name: "my-pod",
+							OwnerReferences: []metav1.OwnerReference{{
+								Kind: "Pod",
+								Name: "my-pod",
+							}},
+						}},
+					}, wq)
+
+					Expect(actualOpts).To(Equal(priorityqueue.AddOpts{Priority: handler.LowPriority}))
+					Expect(actualRequests).To(Equal([]reconcile.Request{{NamespacedName: types.NamespacedName{Name: "my-pod"}}}))
+				})
+
+				It("should not lower the priority of an update request with changed RV", func() {
+					actualOpts := priorityqueue.AddOpts{}
+					var actualRequests []reconcile.Request
+					wq := &fakePriorityQueue{
+						addWithOpts: func(o priorityqueue.AddOpts, items ...reconcile.Request) {
+							actualOpts = o
+							actualRequests = items
+						},
+					}
+
+					test.handler().Update(ctx, event.UpdateEvent{
+						ObjectOld: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{
+							Name: "my-pod",
+							OwnerReferences: []metav1.OwnerReference{{
+								Kind: "Pod",
+								Name: "my-pod",
+							}},
+						}},
+						ObjectNew: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{
+							Name:            "my-pod",
+							ResourceVersion: "1",
+							OwnerReferences: []metav1.OwnerReference{{
+								Kind: "Pod",
+								Name: "my-pod",
+							}},
+						}},
+					}, wq)
+
+					Expect(actualOpts).To(Equal(priorityqueue.AddOpts{}))
+					Expect(actualRequests).To(Equal([]reconcile.Request{{NamespacedName: types.NamespacedName{Name: "my-pod"}}}))
+				})
+
+				It("should have no effect on create if the workqueue is not a priorityqueue", func() {
+					test.handler().Create(ctx, event.CreateEvent{
+						Object: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{
+							Name: "my-pod",
+							OwnerReferences: []metav1.OwnerReference{{
+								Kind: "Pod",
+								Name: "my-pod",
+							}},
+						}},
+					}, q)
+
+					Expect(q.Len()).To(Equal(1))
+					item, _ := q.Get()
+					Expect(item).To(Equal(reconcile.Request{NamespacedName: types.NamespacedName{Name: "my-pod"}}))
+				})
+
+				It("should have no effect on Update if the workqueue is not a priorityqueue", func() {
+					test.handler().Update(ctx, event.UpdateEvent{
+						ObjectOld: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{
+							Name: "my-pod",
+							OwnerReferences: []metav1.OwnerReference{{
+								Kind: "Pod",
+								Name: "my-pod",
+							}},
+						}},
+						ObjectNew: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{
+							Name: "my-pod",
+							OwnerReferences: []metav1.OwnerReference{{
+								Kind: "Pod",
+								Name: "my-pod",
+							}},
+						}},
+					}, q)
+
+					Expect(q.Len()).To(Equal(1))
+					item, _ := q.Get()
+					Expect(item).To(Equal(reconcile.Request{NamespacedName: types.NamespacedName{Name: "my-pod"}}))
+				})
+			})
+		}
+	})
+
 	Describe("WithLowPriorityWhenUnchanged", func() {
 		It("should lower the priority of a create request for an object that was created more than one minute in the past", func() {
 			actualOpts := priorityqueue.AddOpts{}
@@ -1025,9 +1235,42 @@ type fakePriorityQueue struct {
 	addWithOpts func(o priorityqueue.AddOpts, items ...reconcile.Request)
 }
 
+func (f *fakePriorityQueue) Add(item reconcile.Request) {
+	f.AddWithOpts(priorityqueue.AddOpts{}, item)
+}
+
 func (f *fakePriorityQueue) AddWithOpts(o priorityqueue.AddOpts, items ...reconcile.Request) {
 	f.addWithOpts(o, items...)
 }
 func (f *fakePriorityQueue) GetWithPriority() (item reconcile.Request, priority int, shutdown bool) {
 	panic("GetWithPriority is not expected to be called")
 }
+
+// customHandler re-implements the basic enqueueRequestForObject logic
+// to be able to test the WithLowPriorityWhenUnchanged wrapper
+type customHandler struct{}
+
+func (ch customHandler) Create(ctx context.Context, evt event.CreateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) {
+	q.Add(reconcile.Request{NamespacedName: types.NamespacedName{
+		Namespace: evt.Object.GetNamespace(),
+		Name:      evt.Object.GetName(),
+	}})
+}
+func (ch customHandler) Update(ctx context.Context, evt event.UpdateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) {
+	q.Add(reconcile.Request{NamespacedName: types.NamespacedName{
+		Namespace: evt.ObjectNew.GetNamespace(),
+		Name:      evt.ObjectNew.GetName(),
+	}})
+}
+func (ch customHandler) Delete(ctx context.Context, evt event.DeleteEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) {
+	q.Add(reconcile.Request{NamespacedName: types.NamespacedName{
+		Namespace: evt.Object.GetNamespace(),
+		Name:      evt.Object.GetName(),
+	}})
+}
+func (ch customHandler) Generic(ctx context.Context, evt event.GenericEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) {
+	q.Add(reconcile.Request{NamespacedName: types.NamespacedName{
+		Namespace: evt.Object.GetNamespace(),
+		Name:      evt.Object.GetName(),
+	}})
+}


Reviews:
user: login: sbueringer
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-13T04:56:49Z

Review comments:
diff_hunk: @@ -81,7 +83,15 @@ func (e *enqueueRequestsFromMapFunc[object, request]) Create(
 	q workqueue.TypedRateLimitingInterface[request],
 ) {
 	reqs := map[request]empty{}
-	e.mapAndEnqueue(ctx, q, evt.Object, reqs)
+
+	var lowPriority bool
+	if reflect.TypeFor[object]().Implements(reflect.TypeFor[client.Object]()) && isPriorityQueue(q) && !isNil(evt.Object) {
path: pkg/handler/enqueue_mapped.go
user: login: sbueringer
body: I know this is probably a minor optimization, but what do you think about storing the result of `reflect.TypeFor[client.Object]()` in a packge-global unexported variable? This reduces the number of reflection calls quite a bit.

(Fine to keep it if you want, maybe the compiler optimizes it away anyway)
created_at: 2025-03-13T04:29:22Z
author_association: MEMBER
diff_hunk: @@ -81,7 +83,15 @@ func (e *enqueueRequestsFromMapFunc[object, request]) Create(
 	q workqueue.TypedRateLimitingInterface[request],
 ) {
 	reqs := map[request]empty{}
-	e.mapAndEnqueue(ctx, q, evt.Object, reqs)
+
+	var lowPriority bool
+	if reflect.TypeFor[object]().Implements(reflect.TypeFor[client.Object]()) && isPriorityQueue(q) && !isNil(evt.Object) {
path: pkg/handler/enqueue_mapped.go
user: login: sbueringer
body: Hm, could it be that the entire check with reflection here doesn't change during the lifetime of enqueueRequestsFromMapFunc? 

If yes, can we just run this once in TypedEnqueueRequestsFromMapFunc and store the result in enqueueRequestsFromMapFunc?
created_at: 2025-03-13T04:31:07Z
author_association: MEMBER
diff_hunk: @@ -112,14 +126,26 @@ func (e *enqueueRequestsFromMapFunc[object, request]) Generic(
 	q workqueue.TypedRateLimitingInterface[request],
 ) {
 	reqs := map[request]empty{}
-	e.mapAndEnqueue(ctx, q, evt.Object, reqs)
+	e.mapAndEnqueue(ctx, q, evt.Object, reqs, false)
 }
 
-func (e *enqueueRequestsFromMapFunc[object, request]) mapAndEnqueue(ctx context.Context, q workqueue.TypedRateLimitingInterface[request], o object, reqs map[request]empty) {
+func (e *enqueueRequestsFromMapFunc[object, request]) mapAndEnqueue(
+	ctx context.Context,
+	q workqueue.TypedRateLimitingInterface[request],
+	o object,
+	reqs map[request]empty,
+	unchanged bool,
path: pkg/handler/enqueue_mapped.go
user: login: sbueringer
body: nit. I would also call this lowPriority (like the variables we're passing in)
created_at: 2025-03-13T04:36:55Z
author_association: MEMBER
diff_hunk: @@ -775,6 +775,216 @@ var _ = Describe("Eventhandler", func() {
 		})
 	})
 
+	Describe("WithLowPriorityWhenUnchanged", func() {
+		handlerPriorityTests := []struct {
+			name    string
+			handler func() handler.EventHandler
+		}{
+			{
+				name:    "WithLowPriorityWhenUnchanged wrapper",
+				handler: func() handler.EventHandler { return handler.WithLowPriorityWhenUnchanged(customHandler{}) },
+			},
+			{
+				name:    "EnqueueRequestForObject",
+				handler: func() handler.EventHandler { return &handler.EnqueueRequestForObject{} },
+			},
+			{
+				name: "EnqueueRequestForOwner",
+				handler: func() handler.EventHandler {
+					return handler.EnqueueRequestForOwner(
+						scheme.Scheme,
+						mapper,
+						&corev1.Pod{},
+					)
+				},
+			},
+			{
+				name: "Funcs",
+				handler: func() handler.EventHandler {
+					return handler.TypedFuncs[client.Object, reconcile.Request]{
+						CreateFunc: func(ctx context.Context, tce event.TypedCreateEvent[client.Object], wq workqueue.TypedRateLimitingInterface[reconcile.Request]) {
+							wq.Add(reconcile.Request{NamespacedName: types.NamespacedName{
+								Namespace: tce.Object.GetNamespace(),
+								Name:      tce.Object.GetName(),
+							}})
+						},
+						UpdateFunc: func(ctx context.Context, tue event.TypedUpdateEvent[client.Object], wq workqueue.TypedRateLimitingInterface[reconcile.Request]) {
+							wq.Add(reconcile.Request{NamespacedName: types.NamespacedName{
+								Namespace: tue.ObjectNew.GetNamespace(),
+								Name:      tue.ObjectNew.GetName(),
+							}})
+						},
+					}
+				},
+			},
+			{
+				name: "EnqueueRequestsFromMapFunc",
+				handler: func() handler.EventHandler {
+					return handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request {
+						return []reconcile.Request{{NamespacedName: types.NamespacedName{
+							Name:      obj.GetName(),
+							Namespace: obj.GetNamespace(),
+						}}}
+					})
+				},
+			},
+		}
+		for _, test := range handlerPriorityTests {
+			When("handler is "+test.name, func() {
+				It("should lower the priority of a create request for an object that was created more than one minute in the past", func() {
+					actualOpts := priorityqueue.AddOpts{}
+					var actualRequests []reconcile.Request
+					wq := &fakePriorityQueue{
+						addWithOpts: func(o priorityqueue.AddOpts, items ...reconcile.Request) {
+							actualOpts = o
+							actualRequests = items
+						},
+					}
+
+					test.handler().Create(ctx, event.CreateEvent{
+						Object: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{
+							Name: "my-pod",
+							OwnerReferences: []metav1.OwnerReference{{
+								Kind: "Pod",
+								Name: "my-pod",
+							}},
+						}},
+					}, wq)
+
+					Expect(actualOpts).To(Equal(priorityqueue.AddOpts{Priority: handler.LowPriority}))
+					Expect(actualRequests).To(Equal([]reconcile.Request{{NamespacedName: types.NamespacedName{Name: "my-pod"}}}))
+				})
+
+				It("should not lower the priority of a create request for an object that was created less than one minute in the past", func() {
+					actualOpts := priorityqueue.AddOpts{}
+					var actualRequests []reconcile.Request
+					wq := &fakePriorityQueue{
+						addWithOpts: func(o priorityqueue.AddOpts, items ...reconcile.Request) {
+							actualOpts = o
+							actualRequests = items
+						},
+					}
+
+					test.handler().Create(ctx, event.CreateEvent{
+						Object: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{
+							Name:              "my-pod",
+							CreationTimestamp: metav1.Now(),
+							OwnerReferences: []metav1.OwnerReference{{
+								Kind: "Pod",
+								Name: "my-pod",
+							}},
+						}},
+					}, wq)
+
+					Expect(actualOpts).To(Equal(priorityqueue.AddOpts{}))
+					Expect(actualRequests).To(Equal([]reconcile.Request{{NamespacedName: types.NamespacedName{Name: "my-pod"}}}))
+				})
+
+				It("should lower the priority of an update request with unchanged RV", func() {
+					actualOpts := priorityqueue.AddOpts{}
+					var actualRequests []reconcile.Request
+					wq := &fakePriorityQueue{
+						addWithOpts: func(o priorityqueue.AddOpts, items ...reconcile.Request) {
+							actualOpts = o
+							actualRequests = items
+						},
+					}
+
+					test.handler().Update(ctx, event.UpdateEvent{
+						ObjectOld: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{
+							Name: "my-pod",
+							OwnerReferences: []metav1.OwnerReference{{
+								Kind: "Pod",
+								Name: "my-pod",
+							}},
+						}},
+						ObjectNew: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{
+							Name: "my-pod",
+							OwnerReferences: []metav1.OwnerReference{{
+								Kind: "Pod",
+								Name: "my-pod",
+							}},
+						}},
+					}, wq)
+
+					Expect(actualOpts).To(Equal(priorityqueue.AddOpts{Priority: handler.LowPriority}))
+					Expect(actualRequests).To(Equal([]reconcile.Request{{NamespacedName: types.NamespacedName{Name: "my-pod"}}}))
+				})
+
+				It("should not lower the priority of an update request with changed RV", func() {
+					actualOpts := priorityqueue.AddOpts{}
+					var actualRequests []reconcile.Request
+					wq := &fakePriorityQueue{
+						addWithOpts: func(o priorityqueue.AddOpts, items ...reconcile.Request) {
+							actualOpts = o
+							actualRequests = items
+						},
+					}
+
+					test.handler().Update(ctx, event.UpdateEvent{
+						ObjectOld: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{
+							Name: "my-pod",
+							OwnerReferences: []metav1.OwnerReference{{
+								Kind: "Pod",
+								Name: "my-pod",
+							}},
+						}},
+						ObjectNew: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{
+							Name:            "my-pod",
+							ResourceVersion: "1",
+							OwnerReferences: []metav1.OwnerReference{{
+								Kind: "Pod",
+								Name: "my-pod",
+							}},
+						}},
+					}, wq)
+
+					Expect(actualOpts).To(Equal(priorityqueue.AddOpts{}))
+					Expect(actualRequests).To(Equal([]reconcile.Request{{NamespacedName: types.NamespacedName{Name: "my-pod"}}}))
+				})
+
+				It("should have no effect on create if the workqueue is not a priorityqueue", func() {
+					test.handler().Create(ctx, event.CreateEvent{
+						Object: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{
+							Name: "my-pod",
+							OwnerReferences: []metav1.OwnerReference{{
+								Kind: "Pod",
+								Name: "my-pod",
+							}},
+						}},
+					}, q)
+
+					Expect(q.Len()).To(Equal(1))
+					item, _ := q.Get()
+					Expect(item).To(Equal(reconcile.Request{NamespacedName: types.NamespacedName{Name: "my-pod"}}))
+				})
+
+				It("should have no effect on Update if the workqueue is not a priorityqueue", func() {
+					test.handler().Update(ctx, event.UpdateEvent{
+						ObjectOld: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{
+							Name: "my-pod",
+							OwnerReferences: []metav1.OwnerReference{{
+								Kind: "Pod",
+								Name: "my-pod",
+							}},
+						}},
+						ObjectNew: &corev1.Pod{ObjectMeta: metav1.ObjectMeta{
+							Name: "my-pod",
+							OwnerReferences: []metav1.OwnerReference{{
+								Kind: "Pod",
+								Name: "my-pod",
+							}},
+						}},
+					}, q)
+
+					Expect(q.Len()).To(Equal(1))
+					item, _ := q.Get()
+					Expect(item).To(Equal(reconcile.Request{NamespacedName: types.NamespacedName{Name: "my-pod"}}))
+				})
+			})
+		}
+	})
+
 	Describe("WithLowPriorityWhenUnchanged", func() {
path: pkg/handler/eventhandler_test.go
user: login: sbueringer
body: Nice! Can we please remove the now redundant tests below?
created_at: 2025-03-13T04:56:33Z
author_association: MEMBER

PR comments:
user: login: k8s-ci-robot
created_at: 2025-03-11T18:36:31Z
body: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubernetes-sigs/controller-runtime/pull/3152#" title="Author self-approved">alvaroaleman</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes-sigs%2Fcontroller-runtime).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/kubernetes-sigs/controller-runtime/blob/main/OWNERS)~~ [alvaroaleman]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
author_association: CONTRIBUTOR
user: login: troy0820
created_at: 2025-03-12T19:48:24Z
body: /lgtm 

Gotcha, yeah I do see what you are saying now @alvaroaleman ! 

Thanks for doing this.
author_association: MEMBER
user: login: k8s-ci-robot
created_at: 2025-03-12T19:48:31Z
body: LGTM label has been added.  <details>Git tree hash: 14b877a0c85c41277b43041e0be4793a1942839d</details>
author_association: CONTRIBUTOR
user: login: sbueringer
created_at: 2025-03-13T04:59:30Z
body: Also add a comment here: https://github.com/kubernetes-sigs/controller-runtime/issues/3105#issuecomment-2719962744
author_association: MEMBER
user: login: sbueringer
created_at: 2025-03-16T07:28:42Z
body: Just for reference, comments have been addressed via https://github.com/kubernetes-sigs/controller-runtime/pull/3160

Thx!
author_association: MEMBER