Skip to content

Commit 80d064c

Browse files
authored
feat(operator)!: support multiple controllers and selectors per entity (#1183)
## Summary Add support for multiple controllers targeting the same entity type. - Add `[LabelSelector]` and `[FieldSelector]` controller attributes. - Add generator error `KOG001` for conflicting selector attributes. - Add `WatchStrategy.PerController` and the opt-in `SharedPerEntity` strategy. - Add client-side selector matching through `IClientSideEntitySelector<TEntity>`. - Give each controller its own watcher, queue, and reconciliation pipeline. - Share parallelism limits and per-UID locking across controller pipelines. - Route `EntityQueue<TEntity>` requeues to the active controller pipeline. - Aggregate queue-depth metrics per entity type. - Reject multiple controllers when using custom queue or leader-election strategies. - Update tests, migration guidance, and operator documentation. Closes #909. ## Breaking changes - Remove `IEntityQueueFactory` and `EntityQueueFactory`. Use reconciliation results or the scoped `EntityQueue<TEntity>` delegate for requeues. - Add the required `OperatorSettings.WatchStrategy` property. Prefer `OperatorSettingsBuilder`, which supplies the default `PerController` strategy. - Stop registering `IReconciler<TEntity>` and `ITimedEntityQueue<TEntity>` in DI for the default in-memory pipeline. - Resolving `EntityQueue<TEntity>` outside a reconciliation scope is ambiguous and throws when multiple controller pipelines exist. - Add a `cachePartition` parameter to custom `ResourceWatcher<TEntity>` and `LeaderAwareResourceWatcher<TEntity>` constructors. - Add `IEntityReconcileCoordinator<TEntity>` to custom queue-background-service constructors. - Add the concrete controller type to direct `Reconciler<TEntity>` construction. - Report controllers declaring both `[LabelSelector]` and `[FieldSelector]` as generator error `KOG001`.
1 parent 5c88c18 commit 80d064c

66 files changed

Lines changed: 4116 additions & 498 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/docs/operator/building-blocks/controllers.mdx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ flowchart TB
8080

8181
The `requeueAfter` path (dashed) is triggered when your controller returns a `ReconciliationResult` with a non-zero `RequeueAfter` value.
8282

83+
KubeOps creates one such pipeline — watcher, queue, and background service — **per registered controller**. Multiple controllers for the same entity type (for example with different label selectors) therefore run fully independent pipelines that never suppress each other's events — see [Multiple Controllers per Entity](#multiple-controllers-per-entity). With the opt-in `WatchStrategy.SharedPerEntity`, a single shared watcher per entity type replaces the per-controller watchers and dispatches each event to all pipelines whose label selectors match — see [Watch Strategy](../configuration/watch-strategy) for the trade-offs.
84+
8385
## Reconciliation Loop
8486

8587
The reconciliation loop is the core of your operator's functionality. It consists of two main methods:
@@ -452,6 +454,73 @@ When no field selector is needed, the built-in `DefaultEntityFieldSelector<TEnti
452454
| `AddControllerWithLabelSelector<TImpl, TEntity, TLabel>()` | Custom | Default (none) |
453455
| `AddControllerWithFieldSelector<TImpl, TEntity, TField>()` | Default (none) | Custom |
454456

457+
### Declaring Selectors with Attributes
458+
459+
Instead of calling the `AddControllerWith…Selector` methods manually, you can annotate the
460+
controller class. The source generator picks the attribute up and emits the matching registration
461+
into `RegisterControllers()` / `RegisterComponents()`:
462+
463+
```csharp
464+
[LabelSelector(typeof(MyDemoEntityLabelSelector))]
465+
public class V1DemoEntityController : IEntityController<V1DemoEntity>
466+
{
467+
// ...
468+
}
469+
470+
// or
471+
[FieldSelector(typeof(MyDemoEntityFieldSelector))]
472+
public class V1DemoEntityController : IEntityController<V1DemoEntity>
473+
{
474+
// ...
475+
}
476+
```
477+
478+
A controller supports one selector kind at a time (label **or** field selector), matching the
479+
builder methods. Declaring both `[LabelSelector]` and `[FieldSelector]` on the same controller is a
480+
compile-time error (`KOG001`); split the work into two controllers instead.
481+
482+
## Multiple Controllers per Entity
483+
484+
The same entity type can be served by **multiple independent controllers**, each with its own label
485+
or field selector. Every registration creates a fully separate watch → queue → reconcile pipeline,
486+
so controllers with overlapping selectors both reconcile an entity that matches both:
487+
488+
```csharp
489+
builder.Services.AddKubernetesOperator()
490+
.AddControllerWithLabelSelector<ManagedController, V1DemoEntity, ManagedLabelSelector>()
491+
.AddControllerWithLabelSelector<ConfigController, V1DemoEntity, ConfigLabelSelector>();
492+
```
493+
494+
Notes:
495+
496+
- Each pipeline maintains its own watch connection, queue, and deduplication cache, so the
497+
controllers never suppress each other's events — even for the same object.
498+
- The same object is never reconciled by two of its controllers **concurrently**: a per-entity-type
499+
coordinator serializes reconciliation per object UID (and enforces one shared
500+
[parallelism budget](../configuration/parallel-reconciliation)). Finalizers are **entity-global**
501+
they are attached once and, under that same lock, are **not executed concurrently or duplicated
502+
solely because multiple controllers match** the object. This is not general exactly-once execution
503+
(e.g. a process crash between an external side effect and the finalizer removal can still re-run it),
504+
so finalizers must remain idempotent.
505+
- Registering the exact same controller/selector combination twice throws at registration time
506+
(it would only open a redundant watch stream).
507+
- The `EntityQueue<TEntity>` requeue delegate routes to the pipeline the current reconciliation
508+
originated from. Inject it into the controller (or another scoped service); with multiple
509+
controllers per entity it cannot be resolved from a singleton.
510+
- N controllers means N watch connections per entity type by default. If that becomes a concern
511+
(many controllers, many objects, overlapping selectors), switch to the shared watch mode — see
512+
[Watch Strategy](../configuration/watch-strategy).
513+
514+
:::warning
515+
Multiple controllers per entity are supported only on the **default in-memory queue** with
516+
**non-custom leader election** (`LeaderElectionType.None` or `LeaderElectionType.Single`), where each
517+
pipeline owns its queue and reconciler. Under
518+
[`QueueStrategy.Custom`](../configuration/queue-strategy#custom) or
519+
[`LeaderElectionType.Custom`](../configuration/leader-election) all pipelines share a single
520+
user-owned queue and a single reconciler with no per-pipeline identity, so only **one controller per
521+
entity** is allowed — registering a second one throws an `InvalidOperationException`.
522+
:::
523+
455524
## Common Pitfalls
456525

457526
1. **Infinite Loops**: Avoid creating reconciliation loops that trigger themselves

docs/docs/operator/configuration/caching.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Caching
33
description: Caching - Memory and Distributed
4-
sidebar_position: 7
4+
sidebar_position: 8
55
---
66

77
# Caching

docs/docs/operator/configuration/crd-installer.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: CRD Installer
33
description: Installing CRDs from the operator during development
4-
sidebar_position: 10
4+
sidebar_position: 11
55
---
66

77
# CRD Installer

docs/docs/operator/configuration/external-triggers.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: External Reconciliation Triggers
33
description: Triggering reconciliation from outside the watch stream
4-
sidebar_position: 6
4+
sidebar_position: 7
55
---
66

77
# External Reconciliation Triggers

docs/docs/operator/configuration/finalizer-lifecycle.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Finalizer Lifecycle
33
description: Automatic attachment and detachment of finalizers
4-
sidebar_position: 5
4+
sidebar_position: 6
55
---
66

77
# Finalizer Lifecycle

docs/docs/operator/configuration/generation-customization.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Customizing Resource Generation
33
description: Replacing or extending the CRD, webhook, and event factories
4-
sidebar_position: 9
4+
sidebar_position: 10
55
---
66

77
# Customizing Resource Generation

docs/docs/operator/configuration/index.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Each `With*` method corresponds to one of the options described on the pages of
2626
| Page | What it controls |
2727
|---|---|
2828
| [Reconcile Strategy](./reconcile-strategy) | Which watch events trigger a reconciliation (`ByGeneration` vs. `ByResourceVersion`) |
29+
| [Watch Strategy](./watch-strategy) | How watch connections to the API server are created (`PerController` vs. `SharedPerEntity`) |
2930
| [Queue Strategy](./queue-strategy) | How reconciliation work is scheduled and dispatched (`InMemory` vs. `Custom`) |
3031
| [Parallel Reconciliation](./parallel-reconciliation) | Concurrency limits and conflict handling for simultaneous reconciliations |
3132
| [Leader Election](./leader-election) | How multiple operator instances coordinate (`None`, `Single`, `Custom`) |

docs/docs/operator/configuration/leader-election.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Leader Election
33
description: Coordinating multiple operator instances
4-
sidebar_position: 4
4+
sidebar_position: 5
55
---
66

77
# Leader Election

docs/docs/operator/configuration/parallel-reconciliation.mdx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Parallel Reconciliation
33
description: Concurrency limits and conflict handling
4-
sidebar_position: 3
4+
sidebar_position: 4
55
---
66

77
# Parallel Reconciliation
@@ -14,18 +14,20 @@ the `WithParallelReconciliation` fluent method.
1414

1515
## Maximum Concurrency
1616

17-
`MaxParallelReconciliations` sets the upper bound on concurrently running reconciliations across all
18-
entity types. The default is `Environment.ProcessorCount * 2`.
17+
`MaxParallelReconciliations` sets the upper bound on concurrently running reconciliations **per entity
18+
type**. The default is `Environment.ProcessorCount * 2`. When several controllers are registered for the
19+
same entity type, they **share** this single budget, so the limit is not multiplied by the controller
20+
count.
1921

2022
```csharp
2123
builder.Services
2224
.AddKubernetesOperator(settings => settings
2325
.WithParallelReconciliation(p => p.WithMaxParallelReconciliations(16)));
2426
```
2527

26-
A global `SemaphoreSlim` is acquired **before** an entry is dequeued, which means the queue acts as
27-
a natural buffer and back-pressure mechanism: producers (the watcher) can continue enqueuing while
28-
all reconciler slots are occupied.
28+
A `SemaphoreSlim` — one per entity type, shared by all of that entity's controllers — is acquired
29+
**before** an entry is dequeued, which means the queue acts as a natural buffer and back-pressure
30+
mechanism: producers (the watcher) can continue enqueuing while all reconciler slots are occupied.
2931

3032
## Conflict Strategy
3133

@@ -48,9 +50,11 @@ builder.Services
4850
```
4951

5052
:::note
51-
The per-UID lock is independent of the global semaphore. Even with `MaxParallelReconciliations = 1`,
53+
The per-UID lock is independent of the parallelism semaphore. Even with `MaxParallelReconciliations = 1`,
5254
two events for *different* entities are never serialised by the UID lock; only events for the *same*
53-
UID are affected by `ConflictStrategy`.
55+
UID are affected by `ConflictStrategy`. Both the semaphore and the per-UID lock live in a coordinator
56+
shared by all controllers of an entity type, so the same object is never reconciled — or finalized —
57+
concurrently, even when two controllers match it.
5458
:::
5559

5660
## Best Practices

docs/docs/operator/configuration/queue-strategy.mdx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Queue Strategy
33
description: How reconciliation work is scheduled and dispatched
4-
sidebar_position: 2
4+
sidebar_position: 3
55
---
66

77
# Queue Strategy
@@ -68,6 +68,14 @@ instead (or derive from `LeaderAwareEntityQueueBackgroundService<TEntity>`) so v
6868
consumer drives the leadership gate.
6969
:::
7070

71+
:::warning[One controller per entity]
72+
`QueueStrategy.Custom` supports at most **one controller per entity type**. All pipelines share your
73+
single `ITimedEntityQueue<TEntity>`, whose entries carry no pipeline identity, and a single injected
74+
`IReconciler<TEntity>`, so a second controller for the same entity cannot be routed and is rejected at
75+
registration with an `InvalidOperationException`. Multiple controllers per entity require the default
76+
in-memory queue (see [Multiple Controllers per Entity](../building-blocks/controllers#multiple-controllers-per-entity)).
77+
:::
78+
7179
Custom queues should follow the same de-duplication contract as the built-in implementation: keep at
7280
most one pending entry per namespace/name key and preserve the earliest scheduled execution time.
7381

0 commit comments

Comments
 (0)