Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

⚠️ Source, Event, Predicate, Handler: Add generics support #2783

Merged
merged 2 commits into from Apr 22, 2024

Conversation

alvaroaleman
Copy link
Member

@alvaroaleman alvaroaleman commented Apr 16, 2024

This change adds support for generics into the Event, Predicate and Handler chain. The motivation for this is explained in #2214, but basically the current client.Object typing frequently requires type assertions when using custom handlers or predicates and is completely inappropriate when building a controller that is not based on Kubernetes objects.

It aims to minimize breaking changes as much as possible. It does so in two parts:

  • Source: Move Handler and Predicate out from Start and into constructor: This is needed because of proposal: spec: allow type parameters in methods golang/go#49085, otherwise we either
    • Can only support one type per Controller which makes no sense
    • Would need to change controller.Watch to take a wrapper type that encapsulates the generic part and hides it from the controller. This is pretty much the same, but more complicated
    • This change means that the concept of a Handler and Predicate effectively becomes optional for a Source which I think makes sense, as a custom source may decide it is easier not to implement these. The existing concept is also visibly shoehorned insofar as that Event/PredicateHandler have Create/Update/Delete and Generic variants, whereas the first three are only used in the context of a kube watch and the latter only outside of it
  • Source, Event, Predicate, Handler: Add generics support. This is achieved by adding a Typed variant to all constructors/types in the latter three packages and making the Kind and Channel source constructors generic. This means that this change is 100% backwards-compatible in the builder

This change will require users to adjust their code only if:

  • They directly call controller.Watch rather than the builder
  • They use a Channel source
  • They call builder.WatchRawSource with ...builder.WatchesOption
  • They built their own source.Source

The above is IMHO likely a relatively small fraction of our users and can be assumed to have advanced knowledge of controller-runtime.

As a side-effect of keeping the builder compatible, users of it will not see this feature. Finding a way to support this in the builder outside of WatchesRawSource is left as a future task and will likely not have a solution with a nice UX due to golang/go#49085

Fixes #2214

@k8s-ci-robot k8s-ci-robot added the cncf-cla: yes Indicates the PR's author has signed the CNCF CLA. label Apr 16, 2024
@k8s-ci-robot
Copy link
Contributor

[APPROVALNOTIFIER] This PR is APPROVED

This pull-request has been approved by: alvaroaleman

The full list of commands accepted by this bot can be found here.

The pull request process is described here

Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@k8s-ci-robot k8s-ci-robot added approved Indicates a PR has been approved by an approver from all required OWNERS files. size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files. labels Apr 16, 2024
pkg/internal/controller/controller.go Outdated Show resolved Hide resolved
pkg/internal/source/kind.go Outdated Show resolved Hide resolved
@sbueringer
Copy link
Member

Just did a first high-level review. Overall pretty nice!

@alvaroaleman alvaroaleman force-pushed the compatible-generics branch 4 times, most recently from e9c3ab9 to a0359e5 Compare April 16, 2024 15:47
type UpdateEvent struct {
// TypedUpdateEvent is an event where a Kubernetes object was updated. TypedUpdateEvent should be generated
// by a source.Source and transformed into a reconcile.Request by an handler.TypedEventHandler.
type TypedUpdateEvent[T any] struct {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems to me that having a typed EventHandler here is not as convenient as to have a method which will infer the type of the object. Client-go works with 2 interfaces under the hood, passed in a method if I’m not mistaken. Can this be implemented this way but with generics?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean a constructor for events and unexporting the current types? That would be a breaking change.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather leave Event structures for non generic types without any change, and introduce a new handler for generic types. I did it this way: https://github.com/Danil-Grigorev/controller-runtime/blob/4d770047de3a10110f5ded3bce46c178d68d0500/pkg/handler/eventhandler.go#L61-L73

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the advantage of that? That means sources have to be able to deal with either or or there needs to be some sort of adapter to convert, both of which is very inconvenient. How does this improve the overall UX?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly for the ability to infer type, I’d say. Anytime that you specify [anything] before the method, the syntax highlighting breaks. Might be only my personal observation, env, etc. The goal is that as controller-runtime is a library, exposing a list of interfaces to extend (in case you need this), having one which unconditionally requires to use generics might make it hard for newcomers to implement custom logic. With a method one can always use just *corev1.Pod as an arg to custom EventHandler implementation and won’t have to use advanced features immediately. And wrapping EventHandle into constructors to eliminate the issue didn’t make sense to me. I can’t imagine how a structure for events may grow beyond just hosting old/new objects, which is low enough to consider a method.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The goal is that as controller-runtime is a library, exposing a list of interfaces to extend (in case you need this), having one which unconditionally requires to use generics might make it hard for newcomers to implement custom logic.

Both your and my approach leave the currently-exported interfaces in place, they have the same shape in both approaches. The difference in your approach is that because EventHandler is not an alias to TypedEventHandler, a Source can only support one of the two, because for the go type system those are different types.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m trying to find a reason why I abandoned aliasing in the first place. Yes, it seems to be working, and having to specify a typedEvent[*Pod] is a minor inconvenience. My guess would be that the issue discussion lead to overcomplicating the solution. But I see a difference with using Source().Prepare() that the current builder logic was not refactored in my implementation. This means there will not be issues with existing functionality

//
// For TypedUpdateEvents which contain both a new and old object, the transformation function is run on both
// objects and both sets of Requests are enqueue.
func EnqueueRequestsFromTypedMapFunc[T any](fn TypedMapFunc[T]) TypedEventHandler[T] {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here is one problem as I can see. There should be an adapter method for EventHandler for a non-generic controller using type specific generic map method. Something like:

func EnqueueRequestsFromTypedMapFunc[T client.Object](fn TypedMapFunc[T]) EventHandler

The problem now is that EventHandler = TypedEventHandler[client.Object] and you can’t type cast from one generic type to another, regardless of the fact that both implement client.Object. That’s where the simpler form of type aliasing broke for me, and where this (obviously simpler) design is a breaking change. Sometimes you can’t pair generics with a project due to reasons like the one we are facing - using builder or any sort of nested structures. Go generics just don’t work where there is a type ambiguity. But using generics should not be either/or decision either, it just needs a way to make a split at some point where it no longer works or does not make sense, I think.

As for real world example where this is important - I rebased CAPI testing PR on your fork + new builder methods, all works well except one common method. It is the only blocker for it to compile and work, but it is a deal breaker if this has to be continued (cc @sbueringer):

return r.Tracker.Watch(ctx, remote.WatchInput{
    Name:         "machinepool-watchNodes",
 	Cluster:      util.ObjectKey(cluster),
 	Watcher:      &controller.ControllerAdapter{Controller: r.controller},
 	Kind:         &corev1.Node{},
 	EventHandler: handler.EnqueueRequestsFromMapFunc(r.nodeToMachinePool), // Does not compile because expects TypedMapFunc[client.Object] and gets TypedMapFunc[*corev1.Node]
 })

func (r *MachinePoolReconciler) nodeToMachinePool(ctx context.Context, node *corev1.Node) []reconcile.Request {

And there is no workaround except for having TypedEventHandler and EventHandler being separate interfaces with different methods working on client.Object and T simultaneously. Here is how I did it originally. I didn’t see a point in having TypedEvent structure either, as a simplification. Another user story for this - one less controller-runtime dependency for reusable business logic around event mapping exported in a package at the API level.

Edit: probably not that definite as I thought in CAPI scenario, as methods defined there for tracker are only used with remote tracker, so can still use client.Object. Still I think this is a limitation worth knowing about/decided.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cluster-API was also bumped here and it doesn't look like the problem you are describing happened there: kubernetes-sigs/cluster-api#10460

The problem now is that EventHandler = TypedEventHandler[client.Object] and you can’t type cast from one generic type to another,

You can not type cast but you can use them interchangably. I still do not quite understand what this breaks that your approach doesn't. I am not very familiar with cluster-api, can you give a simpler example?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cluster-API was also bumped here and it doesn't look like the problem you are describing happened there: kubernetes-sigs/cluster-api#10460

I think this didn't happen here because Christian rebased on this PR, and not on "your fork + new builder methods". Otherwise I don't follow, to be honest :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Danil-Grigorev Can you provide more details how this func is broken? (and push a branch somewhere where it is broken)

(I looked at kubernetes-sigs/cluster-api#10464 and that one is not broken, but I assume you're describing some other version of the code)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One detail that maybe is what you are referring to but I am not sure: It makes a difference if you do

type foo = bar[something]

or

type foo bar[something]

The latter means that you do not get the methods of bar and as a result, foo can not be used in place of bar[something]

But yeah, I also don't really understand the example given so I don't know if this might be what you mean.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, as I mentioned, I just had to stick with non generic nodeToMachinePool method which kept using client.Object and that probably it wasn’t the right example, because nodeToMachinePool is only used once - in the tracker setup.

My concern is about more complex scenarios, when the same method has to be used with generic implementation elsewhere. It can’t be done without an adapter or code duplication purely for type casting. Which might lead to decision against using generics due to added complexity in having use methods interchangeably.

The example I provided is good in outlining the second problem related to golang/go#49085 as in builder pattern UX changes. We can’t make Tracker a generic method with specialization on *corev1.Node for the CAPI use case to make it simple, because the Tracker is stored in MachinePoolReconciler which works with MachinePools - it would look strange and may block MachinePoolReconciler from being a generic method on MachinePoolReconciler[*MachinePool] for example if needed.

This is theoretical, I’m struggling to find a real world example which would explain the point better, still looking though. I think controller-runtime should take a couple more steps further to make the library UX better aligned with generics, despite the golang/go#49085 limitation, and get the design of how it looks in the end with possible edge scenarios covered.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only case I am aware of where in type foo = bar[something] foo and bar can not be used interchangably are slices: a []foo can not be used in place of []bar[something] but you can convert between them by iterating

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is theoretical, I’m struggling to find a real world example which would explain the point better, still looking though. I think controller-runtime should take a couple more steps further to make the library UX better aligned with generics, despite the golang/go#49085 limitation, and get the design of how it looks in the end with possible edge scenarios covered.

I am not disagreeing. But I do not think that your approach is better at that than the aliasing. The only thing that you added is the func builder.For(stuff) source.Source/func builder.Owns(stuff) source.Source. I think the only case where this might be useful is if you have predicates, as you can have proper types in there as the Handler is implicit and I am not fully convinced that warrants including such helpers in controller-runtime.

That being said, this change does nothing that would keep us from in the future adding such helpers or improving the UX further if golang/go#49085 gets resolved or we think of other ways to work around it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good to me. Tried this once again and this approach seems to work:

func convert[T any](fn TypedMapFunc[T]) MapFunc {
	return func(ctx context.Context, obj client.Object) (req []reconcile.Request) {
		if typed, ok := obj.(T); !ok {
			return req
		} else {
			return fn(ctx, typed)
		}
	}
}

func EnqueueRequestsFromTypedMapFunc[T any](fn TypedMapFunc[T]) EventHandler {
	return &enqueueRequestsFromMapFunc[client.Object]{
		toRequests: convert(fn),
	}
}

Copy link
Member

@sbueringer sbueringer left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall looks good

pkg/builder/controller.go Outdated Show resolved Hide resolved
pkg/builder/controller.go Outdated Show resolved Hide resolved
pkg/builder/controller.go Outdated Show resolved Hide resolved
pkg/builder/controller.go Show resolved Hide resolved
pkg/handler/enqueue_owner.go Outdated Show resolved Hide resolved
pkg/handler/eventhandler.go Show resolved Hide resolved
pkg/internal/controller/controller.go Outdated Show resolved Hide resolved
pkg/internal/controller/controller.go Outdated Show resolved Hide resolved
pkg/internal/controller/controller_test.go Show resolved Hide resolved
pkg/source/source.go Show resolved Hide resolved
return err
}
srcKind.Type = typeForSrc
projected, err := blder.project(w.obj, w.objectProjection)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think here a change suggested in #2685 will fit better. Projection might not work for different sources.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Projection might not work for different sources.

This is only used to construct a source.Kind.

I think here a change suggested in #2685 will fit better.

I disagree. Both what we did previously and what is done over there is IMHO hacky and will not work after the Source was started. Deferring the creation and creating it with the right args rather than manipulating it later on is IMHO much cleaner.

pkg/handler/eventhandler.go Outdated Show resolved Hide resolved
@@ -43,19 +43,19 @@ func NewEventHandler(ctx context.Context, queue workqueue.RateLimitingInterface,
}

// EventHandler adapts a handler.EventHandler interface to a cache.ResourceEventHandler interface.
type EventHandler struct {
type EventHandler[T client.Object] struct {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can also use EventHandler[T any] without changes to allow watching non-kubernetes resources.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is internal and only used by source.Kind which only works with client.Object, so there is no reason to have a different type constraint here

pkg/predicate/predicate.go Outdated Show resolved Hide resolved
pkg/source/source.go Show resolved Hide resolved
pkg/source/source.go Show resolved Hide resolved
pkg/internal/source/kind.go Show resolved Hide resolved
@inteon
Copy link
Member

inteon commented Apr 22, 2024

@alvaroaleman
Instead of passing both a handler and a set of predicates to each source, we could also use the predicates to filter what is passed to the handler and return a new handler. I created a POC for this here: alvaroaleman#1.
PTAL and let me know what you think.

Copy link
Member

@Danil-Grigorev Danil-Grigorev left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall looks good to me, after some more time spent reviewing, only a couple more minor things I still think are nice to haves.

}

// ToTyped converts a Predicate to a TypedPredicate.
func ToTyped[T client.Object](p Predicate) TypedPredicate[T] {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is method is not needed. As generic sources are not released yet, anyone considering to use them will have to refactor their code after it is supported. And using this method negates benefits the more robust compile time type checking system brings, I don’t think anyone will use it. On the other hand something like the ToUntyped may be useful, if you don’t want to immediately refactor. Use case is similar to AsReconciler - just to spare a couple lines of code which will compound as time goes on and project grows.

This can be done as a separate PR as well if you think it’s better.

// ToUntyped converts a TypedPredicate to a regular Predicate.
func ToUntyped[T client.Object](p TypedPredicate[T]) Predicate {
	return TypedFuncs[client.Object]{
		CreateFunc: func(e event.TypedCreateEvent[client.Object]) bool {
			obj, ok := e.Object.(T)
			return ok && p.Create(event.TypedCreateEvent[T]{Object: obj})
		},
		UpdateFunc: func(e event.TypedUpdateEvent[client.Object]) bool {
			objOld, okOld := e.ObjectOld.(T)
			objNew, okNew := e.ObjectNew.(T)
			return okOld && okNew && p.Update(event.TypedUpdateEvent[T]{
				ObjectOld: objOld,
				ObjectNew: objNew,
			})
		},
		DeleteFunc: func(e event.TypedDeleteEvent[client.Object]) bool {
			obj, ok := e.Object.(T)
			return ok && p.Delete(event.TypedDeleteEvent[T]{
				Object:             obj,
				DeleteStateUnknown: e.DeleteStateUnknown,
			})
		},
		GenericFunc: func(e event.TypedGenericEvent[client.Object]) bool {
			obj, ok := e.Object.(T)
			return ok && p.Generic(event.TypedGenericEvent[T]{
				Object: obj,
			})
		},
	}
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm you are right, I think I used this at some point in the builder but apparently not anymore. Removed it, we can still add adapters like that later if we find that there is demand.

pkg/handler/example_test.go Outdated Show resolved Hide resolved
This change add generic versions of event, handler and predicates along
the existing ones, prefixed with `Typed`. The existing ones are left in
place under the same signature.

The `source` constructors are changed to be generic.
@k8s-ci-robot
Copy link
Contributor

@alvaroaleman: The following test failed, say /retest to rerun all failed tests or /retest-required to rerun all mandatory failed tests:

Test name Commit Details Required Rerun command
pull-controller-runtime-apidiff 2add01e link false /test pull-controller-runtime-apidiff

Full PR test history. Your PR dashboard. Please help us cut down on flakes by linking to an open issue when you hit one in your PR.

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes/test-infra repository. I understand the commands that are listed here.

@alvaroaleman
Copy link
Member Author

Instead of passing both a handler and a set of predicates to each source, we could also use the predicates to filter what is > passed to the handler and return a new handler. I created a POC for this here: alvaroaleman#1.
PTAL and let me know what you think.

I ended up adding an option type for source.Channel after finding that there are some examples where ppl set a custom buffer size, e.g. here: https://github.com/alibaba/hybridnet/blob/b847fd2505c05ec4f48c4d387f511401fb6b4f0b/pkg/controllers/networking/subnetstatus_controller.go#L145

It doesn't seem very common but it would be bad to have to break ppl a second time to add this.

WRT the idea of modeling Predicates as a handler wrapper you are right that that is possible but I think it might cause a lot of confusion if we do and enforce that.

@Danil-Grigorev
Copy link
Member

/lgtm

@k8s-ci-robot k8s-ci-robot added the lgtm "Looks good to me", indicates that a PR is ready to be merged. label Apr 22, 2024
@k8s-ci-robot
Copy link
Contributor

LGTM label has been added.

Git tree hash: 26a0430ab6b64803b9cea470b1d1a494a04c8f8d

@k8s-ci-robot k8s-ci-robot merged commit ae0f6ab into kubernetes-sigs:main Apr 22, 2024
8 of 9 checks passed
type ChannelOpt[T any] func(*channel[T])

// WithPrededicates adds the configured predicates to a source.Channel.
func WithPrededicates[T any](p ...predicate.TypedPredicate[T]) ChannelOpt[T] {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR: #2792

nixpanic added a commit to ceph/ceph-csi that referenced this pull request Apr 30, 2024
controller-runtime changed the format of the controller.Watch()
function, so that needs adjusting.

See-also: kubernetes-sigs/controller-runtime#2783
Signed-off-by: Niels de Vos <ndevos@ibm.com>
nixpanic added a commit to ceph/ceph-csi that referenced this pull request Apr 30, 2024
controller-runtime changed the format of the controller.Watch()
function, so that needs adjusting.

See-also: kubernetes-sigs/controller-runtime#2783
Signed-off-by: Niels de Vos <ndevos@ibm.com>
nixpanic added a commit to ceph/ceph-csi that referenced this pull request Apr 30, 2024
controller-runtime changed the format of the controller.Watch()
function, so that needs adjusting.

See-also: kubernetes-sigs/controller-runtime#2783
Signed-off-by: Niels de Vos <ndevos@ibm.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
approved Indicates a PR has been approved by an approver from all required OWNERS files. cncf-cla: yes Indicates the PR's author has signed the CNCF CLA. lgtm "Looks good to me", indicates that a PR is ready to be merged. size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Evaluate using generics for source/predicate/handler chain
5 participants