feat(controllers): Multi-controller/-finalizer dispatch via ShouldHandle(TEntity)#1084
Conversation
There was a problem hiding this comment.
Pull request overview
Adds predicate-based multi-controller and multi-finalizer dispatch for the same Kubernetes entity type by introducing ShouldHandle(TEntity) on controllers/finalizers and updating reconciliation to dispatch to all matching registrations.
Changes:
- Add default
ShouldHandle(TEntity)methods toIEntityController<TEntity>andIEntityFinalizer<TEntity>(defaulting totrue). - Update
OperatorBuilder.AddControllerto allow multiple scoped controller registrations for the same entity type. - Update
Reconciler<TEntity>to filter controllers/finalizers viaShouldHandleand add/adjust unit tests for multi-controller dispatch.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/KubeOps.Abstractions/Reconciliation/Controller/IEntityController{TEntity}.cs | Introduces ShouldHandle(TEntity) default interface method for controller responsibility claims. |
| src/KubeOps.Abstractions/Reconciliation/Finalizer/IEntityFinalizer{TEntity}.cs | Introduces ShouldHandle(TEntity) default interface method for finalizer responsibility claims (used at auto-attach time). |
| src/KubeOps.Operator/Builder/OperatorBuilder.cs | Switches controller registration to AddScoped to allow multiple controllers per entity type. |
| src/KubeOps.Operator/Reconciliation/Reconciler.cs | Dispatches to all controllers matching ShouldHandle and filters finalizers during auto-attach. |
| test/KubeOps.Operator.Test/Builder/OperatorBuilder.Test.cs | Adds tests validating multi-controller registrations and watcher non-duplication. |
| test/KubeOps.Operator.Test/LeaderElector/LeaderElectionBackgroundService.Test.cs | Improves retry test synchronization to reduce flakiness. |
| test/KubeOps.Operator.Test/Reconciliation/Reconciler.Test.cs | Updates mocks to account for new ShouldHandle and enumerable controller resolution. |
| test/KubeOps.Operator.Test/Reconciliation/Reconciler.MultiController.Test.cs | Adds multi-controller dispatch tests around ShouldHandle, ordering, and short-circuit behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| foreach (var controller in responsibleControllers) | ||
| { | ||
| cancellationToken.ThrowIfCancellationRequested(); | ||
| result = await operation(controller, result.Entity, cancellationToken); | ||
| if (!result.IsSuccess) return result; | ||
| } | ||
|
|
||
| return result; |
There was a problem hiding this comment.
In the multi-controller loop, any RequeueAfter requested by an earlier controller can be overwritten/dropped by a later controller that returns success with RequeueAfter == null, because result is replaced each iteration. This can change behavior when adding an additional controller (e.g., an auditing controller) and prevent intended requeues. Consider aggregating RequeueAfter across all successful controller results (e.g., keep the earliest/non-null) while still passing the latest Entity through the chain.
| foreach (var controller in responsibleControllers) | |
| { | |
| cancellationToken.ThrowIfCancellationRequested(); | |
| result = await operation(controller, result.Entity, cancellationToken); | |
| if (!result.IsSuccess) return result; | |
| } | |
| return result; | |
| var requeueAfter = result.RequeueAfter; | |
| foreach (var controller in responsibleControllers) | |
| { | |
| cancellationToken.ThrowIfCancellationRequested(); | |
| result = await operation(controller, result.Entity, cancellationToken); | |
| if (!result.IsSuccess) return result; | |
| if (result.RequeueAfter is not null && | |
| (requeueAfter is null || result.RequeueAfter < requeueAfter)) | |
| { | |
| requeueAfter = result.RequeueAfter; | |
| } | |
| } | |
| return ReconciliationResult<TEntity>.Success(result.Entity, requeueAfter); |
| var responsibleControllers = new List<IEntityController<TEntity>>(); | ||
| foreach (var controller in registeredControllers) | ||
| { | ||
| cancellationToken.ThrowIfCancellationRequested(); | ||
| if (await controller.ShouldHandle(entity)) | ||
| { | ||
| responsibleControllers.Add(controller); | ||
| } | ||
| } | ||
|
|
||
| if (responsibleControllers.Count == 0) | ||
| { | ||
| logger.LogWarning( | ||
| """No responsible controller found for "{Kind}/{Name}". Skipping.""", | ||
| entity.Kind, | ||
| entity.Name()); | ||
| return ReconciliationResult<TEntity>.Success(entity); | ||
| } | ||
|
|
||
| ReconciliationResult<TEntity> result = ReconciliationResult<TEntity>.Success(entity); | ||
| foreach (var controller in responsibleControllers) | ||
| { | ||
| cancellationToken.ThrowIfCancellationRequested(); | ||
| result = await operation(controller, result.Entity, cancellationToken); | ||
| if (!result.IsSuccess) return result; |
There was a problem hiding this comment.
ShouldHandle is evaluated for all controllers against the initial entity before any controller runs, but the dispatched controllers receive the potentially mutated result.Entity. This means a controller can be invoked even if it would return false for the entity state it actually receives. Consider evaluating ShouldHandle just-in-time per controller using the current entity (or document that ShouldHandle is evaluated only on the pre-dispatch entity).
| var registeredControllers = services.GetServices<IEntityController<TEntity>>().ToList(); | ||
| if (registeredControllers.Count == 0) | ||
| { | ||
| return ReconciliationResult<TEntity>.Failure( | ||
| entity, | ||
| $"No IEntityController<{typeof(TEntity).Name}> registered. Did you forget to call AddController<T, TEntity>() on the operator builder?"); | ||
| } |
There was a problem hiding this comment.
Cancellation is checked only after resolving/enumerating controllers via GetServices(...).ToList(). If cancellation is already requested, this still constructs all scoped controllers (and could run their constructors) unnecessarily. Consider calling cancellationToken.ThrowIfCancellationRequested() before resolving controllers (and similarly before enumerating finalizers) to avoid work after cancellation.
| var provider = _builder.Services.BuildServiceProvider(); | ||
| var controllers = provider | ||
| .GetServices<IEntityController<V1OperatorIntegrationTestEntity>>() | ||
| .ToList(); | ||
|
|
||
| controllers.Should().HaveCount(2); | ||
| controllers.Should().ContainItemsAssignableTo<IEntityController<V1OperatorIntegrationTestEntity>>(); | ||
| controllers.Select(c => c.GetType()).Should().Contain(typeof(TestController)); | ||
| controllers.Select(c => c.GetType()).Should().Contain(typeof(SecondTestController)); |
There was a problem hiding this comment.
This test resolves scoped IEntityController<T> instances directly from the root ServiceProvider. In production this is typically done from an IServiceScope (and can fail if ValidateScopes is enabled). Consider creating a scope (provider.CreateScope()) and resolving controllers from scope.ServiceProvider to better reflect runtime behavior.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
hey @stevefan1999-personal thanks for the new pull request.
- I've added some comments - in general I think the pattern should still be that only 1 controller/finalizer at a time should be responsible for the reconciliation of an entity but dispatching is more fine grained to that component (by
ShouldHandle). - I will close the old pull request - as this will replace it
- I think the docs should also get an update 😄
cheers
| /// <param name="entity">The entity the reconciler is about to dispatch.</param> | ||
| /// <param name="cancellationToken">The token used to signal cancellation of the operation.</param> | ||
| /// <returns>A <see cref="ValueTask{Boolean}"/> that resolves to <c>true</c> if this controller should reconcile the entity.</returns> | ||
| ValueTask<bool> ShouldHandle(TEntity entity, CancellationToken cancellationToken = default) => ValueTask.FromResult(true); |
There was a problem hiding this comment.
@stevefan1999-personal would prefer this not to be = default by default but mandatory
| /// <param name="entity">The entity the reconciler is considering for this finalizer.</param> | ||
| /// <param name="cancellationToken">The token to monitor for cancellation requests.</param> | ||
| /// <returns>A <see cref="ValueTask{Boolean}"/> that resolves to <c>true</c> if this finalizer should claim the entity.</returns> | ||
| ValueTask<bool> ShouldHandle(TEntity entity, CancellationToken cancellationToken = default) => ValueTask.FromResult(true); |
There was a problem hiding this comment.
same here (mandatory not = default)
| where TEntity : IKubernetesObject<V1ObjectMeta> | ||
| { | ||
| Services.TryAddScoped<IEntityController<TEntity>, TImplementation>(); | ||
| // TryAddEnumerable dedupes by (ServiceType, ImplementationType), so calling AddController |
There was a problem hiding this comment.
not sure this AI comment is really needed 😄
|
|
||
| if (operatorSettings.AutoAttachFinalizers) | ||
| { | ||
| cancellationToken.ThrowIfCancellationRequested(); |
There was a problem hiding this comment.
can't see any benefit in this line
| Func<IEntityController<TEntity>, TEntity, CancellationToken, Task<ReconciliationResult<TEntity>>> operation, | ||
| CancellationToken cancellationToken) | ||
| { | ||
| cancellationToken.ThrowIfCancellationRequested(); |
There was a problem hiding this comment.
same here - please remove
| /// returns <c>Success(entity)</c> never erases a requeue requested by an earlier controller. | ||
| /// </para> | ||
| /// </summary> | ||
| private async Task<ReconciliationResult<TEntity>> DispatchToMatchingControllers( |
There was a problem hiding this comment.
to be honest I am pretty unsure about the possibility to have more than 1 controller being responsible for an entity - shouldn't the pattern be more like:
- if there == 1 controller -> dispatch
- if there == 0 -> finish with not dispatch
- if there > 1 -> error
| foreach (var finalizer in finalizers) | ||
| { | ||
| cancellationToken.ThrowIfCancellationRequested(); | ||
| if (!await finalizer.ShouldHandle(entity, cancellationToken)) |
There was a problem hiding this comment.
I think this is the same as with the controllers - this could lead to being > 1 finalizers being responsible for an entity which might lead to weired results.
Multi-controller/-finalizer dispatch via
ShouldHandle(TEntity)Fixes #909. Also unblocks #803 and similar "dispatch by arbitrary entity predicate" requests. Subsequent PR from #1070 (comment)
Overview
Adds support for registering multiple controllers (and finalizers) for the same Kubernetes entity type, each claiming responsibility for a subset of resources based on any predicate the consumer wants to express in C# — labels, annotations, status conditions, namespace, external lookups, etc.
Per maintainer feedback on the original label-filter PR: instead of parsing a label-selector DSL string, the framework asks each controller
ValueTask<bool> ShouldHandle(TEntity)and dispatches to the ones that claim responsibility. This subsumes label filtering and generalises to every future dispatch scenario.Changes
IEntityController<TEntity>— new default methodControllers that want to scope themselves override it:
Because it's a default interface method returning
true, every existing controller implementation compiles and behaves unchanged.IEntityFinalizer<TEntity>— same new default methodSemantics:
ShouldHandleis consulted at auto-attach time. Only finalizers that returntrueare attached to the entity. Once attached, the finalizer is dispatched by its identifier as usual —ShouldHandleacts as a one-time responsibility claim, not an ongoing gate. This prevents orphaning entities whose attached finalizers later refuse to run.OperatorBuilder— unchanged from the previous PR iterationAddControllerstill usesAddScoped(notTryAddScoped) so multiple registrations for the same entity type coexist andGetServices<IEntityController<TEntity>>()resolves all of them.Reconciler<TEntity>— async filter viaShouldHandleDispatchToMatchingControllersiterates all registered controllers, awaitsShouldHandleon each, and builds the list of responsible controllers in registration order. On the first failure the chain short-circuits. If no controller claims the entity, the reconciler logs a warning and returns success (preserving backward-compatible behaviour for entities transitioning between operator deployments).ReconcileEntity(the auto-attach path) now awaitsShouldHandleon each finalizer before callingAddFinalizer, so finalizers only claim entities they're actually responsible for.Removed
LabelSelectorMatcher.cs— the string-based label-selector parserLabelSelectorMatcher.Test.cs— its 22 unit testsThe DSL-parsing layer is now the consumer's responsibility (they can reuse
KubeOps.KubernetesClient.LabelSelectorsor write any other predicate).What was explicitly NOT changed
IEntityLabelSelector<TEntity>— untouched; still drives the watcher-level server-side label filterResourceWatcher<TEntity>— untouched; one watcher per entity type regardless of controller countAddController<TImplementation, TEntity>()signature — unchangedTests
Reconciler.MultiController.Test.cs— 12 tests: default catch-allShouldHandle, predicate match/no-match, ordering, failure short-circuit, entity mutation pass-through,DeletedAsyncpath, asyncShouldHandleawaited before dispatchOperatorBuilder.Test.cs— 3 tests for multi-registration resolution and no-duplicate-watcher (unchanged from previous iteration)Reconciler.Test.cs— updated existing mocks to stubShouldHandle(returning true)All 67 non-integration tests in
KubeOps.Operator.Testpass. (Integration tests require a live Kubernetes cluster and are unrelated to this change.)Example
Backward compatibility
The default implementation of
ShouldHandlereturnstrue, so every existing controller and finalizer behaves identically to before. No existing code requires changes.Design decisions
ShouldHandleis consulted at attach time, not dispatch time. Rationale: once a finalizer is written into entity metadata, it's a contract with the API server. Re-evaluating at dispatch risks orphaning entities with finalizers that now refuse to run.ShouldHandleis called on every reconcile pass with no memoisation. Rationale: simplest, no cache-invalidation complexity. Consumers are responsible for keeping it fast; they can memoise internally if needed.