Skip to content

Commit f311892

Browse files
committed
feat: informer pool to share informers across controllers and event sources
Introduce an informer pool so that InformerEventSources watching the same resource type with an equivalent configuration share a single underlying fabric8 SharedIndexInformer instead of creating one per event source. This reduces memory usage and the number of watch connections opened against the API server when many controllers watch the same (secondary) resource type. - Add an InformerPool abstraction with two strategies: - DefaultInformerPool (default): reference-counted sharing of informers keyed by an InformerClassifier (API server URL, resource type / GVK, namespace, label/field/shard selectors, item store). The informer is created on first use and stopped only when the last user releases it. informerListLimit is intentionally not part of the identity; indexers are added independently. - AlwaysNewInformerPool: never shares (one informer per event source), to opt out of pooling and keep the previous behavior. - Resolve the pool via ConfigurationService.informerPool() (an abstract method, cached as a per-ConfigurationService singleton in AbstractConfigurationService) and expose ConfigurationServiceOverrider.withInformerPool(...) to select the strategy. - Route InformerManager / ManagedInformerEventSource through the pool. Dynamic event source registration reuses an already-running informer and relies on the informer replaying its cache to the newly added handler for initial state. - Add group/version/kind and field-selector support to the classifier. - Tests: unit tests for InformerClassifier equality and pool reference counting; integration tests for basic sharing, dynamic registration and de-registration, each run against both pool strategies. - Add documentation for informer pooling. The feature is marked @experimental: the runtime behavior is production-ready, but the configuration API may still change in a non-backwards-compatible way. Signed-off-by: Attila Mészáros <a_meszaros@apple.com>
1 parent fbce8b2 commit f311892

54 files changed

Lines changed: 2605 additions & 230 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/content/en/docs/documentation/eventing.md

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -346,4 +346,57 @@ for [primary resources](https://github.com/operator-framework/java-operator-sdk/
346346

347347
See
348348
also [CaffeineBoundedItemStores](https://github.com/operator-framework/java-operator-sdk/blob/main/caffeine-bounded-cache-support/src/main/java/io/javaoperatorsdk/operator/processing/event/source/cache/CaffeineBoundedItemStores.java)
349-
for more details.
349+
for more details.
350+
351+
### Sharing Informers Between Controllers (Informer Pool)
352+
353+
{{% alert title="Experimental" color="warning" %}}
354+
Informer pooling is marked `@Experimental`: the feature itself is production ready, but its
355+
configuration API may still change in a non-backwards-compatible way.
356+
{{% /alert %}}
357+
358+
By default JOSDK maintains an *informer pool* so that informers are **shared** across controllers
359+
and event sources. When several `InformerEventSource`s (whether belonging to different controllers,
360+
or dynamically registered at runtime) watch the same resource type with an equivalent configuration,
361+
they are all backed by a single underlying `SharedIndexInformer` instead of one informer each. This
362+
reduces memory usage and the number of watch connections opened against the API server — which
363+
matters in operators where many controllers watch the same secondary resource type (for example
364+
`ConfigMap` or `Secret`).
365+
366+
Two event sources share an informer when their effective informer configuration matches on all of:
367+
368+
- the target cluster (API server URL, including the remote client used for
369+
[multi-cluster](#informereventsource-multi-cluster-support) event sources),
370+
- the resource type (or the group/version/kind for generic resources),
371+
- the watched namespace,
372+
- the label, field and shard selectors,
373+
- the configured [item store](#bounded-caches-for-informers).
374+
375+
The `informerListLimit` is intentionally *not* part of this identity: if two otherwise-equivalent
376+
event sources request a different list limit, the existing informer is reused (a warning is logged
377+
and the first-configured limit is kept). Indexers are also not part of the identity, since they can
378+
be added to a shared informer independently; just make sure indexer names don't collide.
379+
380+
The pool is reference-counted: the shared informer is created on first use and only stopped once the
381+
last event source using it is de-registered (or its controller stops). Dynamically registering an
382+
event source for a resource that is already backed by a running informer reuses that informer, and
383+
the initial state already in its cache is replayed to the newly added handler.
384+
385+
#### Selecting the pooling strategy
386+
387+
The strategy is provided by the
388+
[`InformerPool`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/InformerPool.java)
389+
configured on the `ConfigurationService`. Two implementations are available:
390+
391+
- [`DefaultInformerPool`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/DefaultInformerPool.java)
392+
(the default): shares informers as described above.
393+
- [`AlwaysNewInformerPool`](https://github.com/operator-framework/java-operator-sdk/blob/main/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AlwaysNewInformerPool.java):
394+
never shares informers, creating a dedicated informer for every event source. Use this to opt out
395+
of pooling and restore the pre-pooling behavior.
396+
397+
You can override the strategy through the `ConfigurationService`:
398+
399+
```java
400+
Operator operator = new Operator(overrider ->
401+
overrider.withInformerPool(new AlwaysNewInformerPool()));
402+
```

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/AbstractConfigurationService.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
import io.fabric8.kubernetes.client.KubernetesClient;
2525
import io.javaoperatorsdk.operator.ReconcilerUtilsInternal;
2626
import io.javaoperatorsdk.operator.api.reconciler.Reconciler;
27+
import io.javaoperatorsdk.operator.processing.event.source.informer.pool.DefaultInformerPool;
28+
import io.javaoperatorsdk.operator.processing.event.source.informer.pool.InformerPool;
2729

2830
/**
2931
* An abstract implementation of {@link ConfigurationService} meant to ease custom implementations
@@ -35,6 +37,7 @@ public class AbstractConfigurationService implements ConfigurationService {
3537
private KubernetesClient client;
3638
private Cloner cloner;
3739
private ExecutorServiceManager executorServiceManager;
40+
private InformerPool informerPool;
3841

3942
protected AbstractConfigurationService(Version version) {
4043
this(version, null);
@@ -190,4 +193,15 @@ public ExecutorServiceManager getExecutorServiceManager() {
190193
}
191194
return executorServiceManager;
192195
}
196+
197+
@Override
198+
public synchronized InformerPool informerPool() {
199+
// cached so that all controllers backed by this ConfigurationService share the same pool and
200+
// can therefore share the underlying informers; synchronized so concurrent first-access from
201+
// multiple controllers cannot create (and share out) more than one pool instance
202+
if (informerPool == null) {
203+
informerPool = new DefaultInformerPool(this);
204+
}
205+
return informerPool;
206+
}
193207
}

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationService.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResourceConfig;
4444
import io.javaoperatorsdk.operator.processing.dependent.workflow.ManagedWorkflowFactory;
4545
import io.javaoperatorsdk.operator.processing.event.source.controller.ControllerEventSource;
46+
import io.javaoperatorsdk.operator.processing.event.source.informer.pool.InformerPool;
4647

4748
/** An interface from which to retrieve configuration information. */
4849
public interface ConfigurationService {
@@ -476,4 +477,21 @@ default boolean useSSAToPatchPrimaryResource() {
476477
default boolean cloneSecondaryResourcesWhenGettingFromCache() {
477478
return false;
478479
}
480+
481+
/**
482+
* The {@link InformerPool} used to create and (when using the default, sharing pool) share the
483+
* informers backing the event sources of all controllers managed by this {@code
484+
* ConfigurationService}.
485+
*
486+
* <p><strong>Implementations must return the same instance on every call.</strong> The pool is
487+
* effectively a per-{@code ConfigurationService} singleton: controllers share informers only if
488+
* they resolve the same pool, and reference counting / informer shutdown are only correct if
489+
* {@code getInformer} and {@code releaseInformer} operate on that same instance. This is
490+
* intentionally not a {@code default} method, since a {@code default} could not cache the result
491+
* and would hand out a fresh (unshared) pool on each call; {@link AbstractConfigurationService}
492+
* provides a cached implementation backed by the default sharing pool.
493+
*
494+
* @return the informer pool for this configuration service
495+
*/
496+
InformerPool informerPool();
479497
}

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverrider.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import io.javaoperatorsdk.operator.Operator;
3030
import io.javaoperatorsdk.operator.api.monitoring.Metrics;
3131
import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResourceFactory;
32+
import io.javaoperatorsdk.operator.processing.event.source.informer.pool.InformerPool;
3233

3334
@SuppressWarnings({"unused", "UnusedReturnValue"})
3435
public class ConfigurationServiceOverrider {
@@ -53,6 +54,7 @@ public class ConfigurationServiceOverrider {
5354
private Set<Class<? extends HasMetadata>> defaultNonSSAResource;
5455
private Boolean useSSAToPatchPrimaryResource;
5556
private Boolean cloneSecondaryResourcesWhenGettingFromCache;
57+
private InformerPool informerPool;
5658

5759
@SuppressWarnings("rawtypes")
5860
private DependentResourceFactory dependentResourceFactory;
@@ -176,6 +178,15 @@ public ConfigurationServiceOverrider withCloneSecondaryResourcesWhenGettingFromC
176178
return this;
177179
}
178180

181+
/**
182+
* Overrides the {@link InformerPool} strategy used to create/share the informers backing the
183+
* event sources. When not set, the default (informer-sharing) pool is used.
184+
*/
185+
public ConfigurationServiceOverrider withInformerPool(InformerPool informerPool) {
186+
this.informerPool = informerPool;
187+
return this;
188+
}
189+
179190
public ConfigurationService build() {
180191
return new BaseConfigurationService(original.getVersion(), cloner, client) {
181192
@Override
@@ -309,6 +320,15 @@ public boolean cloneSecondaryResourcesWhenGettingFromCache() {
309320
cloneSecondaryResourcesWhenGettingFromCache,
310321
ConfigurationService::cloneSecondaryResourcesWhenGettingFromCache);
311322
}
323+
324+
@Override
325+
public InformerPool informerPool() {
326+
if (informerPool == null) {
327+
return super.informerPool();
328+
}
329+
informerPool.setConfigurationService(this);
330+
return informerPool;
331+
}
312332
};
313333
}
314334
}

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/FieldSelector.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import java.util.Arrays;
1919
import java.util.List;
20+
import java.util.Objects;
2021

2122
public class FieldSelector {
2223
private final List<Field> fields;
@@ -38,4 +39,16 @@ public Field(String path, String value) {
3839
this(path, value, false);
3940
}
4041
}
42+
43+
@Override
44+
public boolean equals(Object o) {
45+
if (o == null || getClass() != o.getClass()) return false;
46+
FieldSelector that = (FieldSelector) o;
47+
return Objects.equals(fields, that.fields);
48+
}
49+
50+
@Override
51+
public int hashCode() {
52+
return Objects.hashCode(fields);
53+
}
4154
}

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerConfiguration.java

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import io.javaoperatorsdk.operator.api.config.ControllerConfiguration;
3131
import io.javaoperatorsdk.operator.api.config.Utils;
3232
import io.javaoperatorsdk.operator.api.reconciler.Constants;
33+
import io.javaoperatorsdk.operator.processing.GroupVersionKind;
3334
import io.javaoperatorsdk.operator.processing.event.source.cache.BoundedItemStore;
3435
import io.javaoperatorsdk.operator.processing.event.source.filter.GenericFilter;
3536
import io.javaoperatorsdk.operator.processing.event.source.filter.OnAddFilter;
@@ -42,6 +43,7 @@
4243
public class InformerConfiguration<R extends HasMetadata> {
4344
private final Builder builder = new Builder();
4445
private final Class<R> resourceClass;
46+
private final GroupVersionKind resourceGroupVersionKind;
4547
private final String resourceTypeName;
4648
private String name;
4749
private Set<String> namespaces;
@@ -59,6 +61,7 @@ public class InformerConfiguration<R extends HasMetadata> {
5961

6062
protected InformerConfiguration(
6163
Class<R> resourceClass,
64+
GroupVersionKind resourceGroupVersionKind,
6265
String name,
6366
Set<String> namespaces,
6467
boolean followControllerNamespaceChanges,
@@ -74,7 +77,7 @@ protected InformerConfiguration(
7477
Boolean comparableResourceVersions,
7578
// TODO for removal in major release
7679
Duration ghostResourceCacheCheckInterval) {
77-
this(resourceClass);
80+
this(resourceClass, resourceGroupVersionKind);
7881
this.name = name;
7982
this.namespaces = namespaces;
8083
this.followControllerNamespaceChanges = followControllerNamespaceChanges;
@@ -90,8 +93,9 @@ protected InformerConfiguration(
9093
this.comparableResourceVersions = comparableResourceVersions;
9194
}
9295

93-
private InformerConfiguration(Class<R> resourceClass) {
96+
private InformerConfiguration(Class<R> resourceClass, GroupVersionKind resourceGroupVersionKind) {
9497
this.resourceClass = resourceClass;
98+
this.resourceGroupVersionKind = resourceGroupVersionKind;
9599
this.resourceTypeName =
96100
resourceClass.isAssignableFrom(GenericKubernetesResource.class)
97101
// in general this is irrelevant now for secondary resources it is used just by
@@ -101,17 +105,24 @@ private InformerConfiguration(Class<R> resourceClass) {
101105
: ReconcilerUtilsInternal.getResourceTypeName(resourceClass);
102106
}
103107

108+
@SuppressWarnings({"rawtypes", "unchecked"})
109+
public static <R extends HasMetadata> InformerConfiguration<R>.Builder builder(
110+
Class<R> resourceClass, GroupVersionKind groupVersionKind) {
111+
return new InformerConfiguration(resourceClass, groupVersionKind).builder;
112+
}
113+
104114
@SuppressWarnings({"rawtypes", "unchecked"})
105115
public static <R extends HasMetadata> InformerConfiguration<R>.Builder builder(
106116
Class<R> resourceClass) {
107-
return new InformerConfiguration(resourceClass).builder;
117+
return new InformerConfiguration(resourceClass, null).builder;
108118
}
109119

110120
@SuppressWarnings({"rawtypes", "unchecked"})
111121
public static <R extends HasMetadata> InformerConfiguration<R>.Builder builder(
112122
InformerConfiguration<R> original) {
113123
return new InformerConfiguration(
114124
original.resourceClass,
125+
original.resourceGroupVersionKind,
115126
original.name,
116127
original.namespaces,
117128
original.followControllerNamespaceChanges,
@@ -305,6 +316,10 @@ public Long getInformerListLimit() {
305316
return informerListLimit;
306317
}
307318

319+
public GroupVersionKind getResourceGroupVersionKind() {
320+
return resourceGroupVersionKind;
321+
}
322+
308323
public FieldSelector getFieldSelector() {
309324
return fieldSelector;
310325
}

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ private Builder(
167167
this.resourceClass = resourceClass;
168168
this.groupVersionKind = groupVersionKind;
169169
this.primaryResourceClass = primaryResourceClass;
170-
this.config = InformerConfiguration.builder(resourceClass);
170+
this.config = InformerConfiguration.builder(resourceClass, groupVersionKind);
171171
}
172172

173173
public Builder<R> withName(String name) {

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/GroupVersionKind.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,4 +136,8 @@ public int hashCode() {
136136
public String toString() {
137137
return toGVKString();
138138
}
139+
140+
public String getApiVersion() {
141+
return apiVersion;
142+
}
139143
}

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventSourceManager.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ private void logEventSourceEvent(EventSource eventSource, String event) {
127127
private <R> Void startEventSource(EventSource<R, P> eventSource) {
128128
try {
129129
logEventSourceEvent(eventSource, "Starting");
130-
eventSource.start();
130+
eventSource.start(false);
131131
logEventSourceEvent(eventSource, "Started");
132132
} catch (MissingCRDException e) {
133133
throw e; // leave untouched
@@ -148,7 +148,7 @@ private <R> Void stopEventSource(EventSource<R, P> eventSource) {
148148
return null;
149149
}
150150

151-
@SuppressWarnings("rawtypes")
151+
@SuppressWarnings({"rawtypes", "unchecked"})
152152
public final synchronized <R> void registerEventSource(EventSource<R, P> eventSource)
153153
throws OperatorException {
154154
Objects.requireNonNull(eventSource, "EventSource must not be null");
@@ -251,7 +251,7 @@ public <R> EventSource<R, P> dynamicallyRegisterEventSource(EventSource<R, P> ev
251251
}
252252
// The start itself is blocking thus blocking only the threads which are attempt to start the
253253
// actual event source. Think of this as a form of lock striping.
254-
eventSource.start();
254+
eventSource.start(true);
255255
return eventSource;
256256
}
257257

operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/EventSource.java

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@
1919
import java.util.Set;
2020

2121
import io.fabric8.kubernetes.api.model.HasMetadata;
22+
import io.javaoperatorsdk.operator.OperatorException;
2223
import io.javaoperatorsdk.operator.health.EventSourceHealthIndicator;
2324
import io.javaoperatorsdk.operator.health.Status;
24-
import io.javaoperatorsdk.operator.processing.LifecycleAware;
2525
import io.javaoperatorsdk.operator.processing.event.EventHandler;
2626
import io.javaoperatorsdk.operator.processing.event.source.filter.GenericFilter;
2727
import io.javaoperatorsdk.operator.processing.event.source.filter.OnAddFilter;
@@ -37,8 +37,7 @@
3737
* @param <P> the primary resource type which reconciler needs to be triggered when events occur on
3838
* resources of type R
3939
*/
40-
public interface EventSource<R, P extends HasMetadata>
41-
extends LifecycleAware, EventSourceHealthIndicator {
40+
public interface EventSource<R, P extends HasMetadata> extends EventSourceHealthIndicator {
4241

4342
static String generateName(EventSource<?, ?> eventSource) {
4443
return eventSource.getClass().getName() + "@" + Integer.toHexString(eventSource.hashCode());
@@ -119,4 +118,22 @@ default Optional<R> getSecondaryResource(P primary) {
119118
default Status getStatus() {
120119
return Status.UNKNOWN;
121120
}
121+
122+
/**
123+
* Start the event source. Normally, should synchronously populate caches, before the method
124+
* returns.
125+
*
126+
* @since 5.6.0
127+
* @param dynamicRegistration true if event source registered dynamically, false otherwise
128+
*/
129+
default void start(boolean dynamicRegistration) {
130+
start();
131+
}
132+
133+
/** Only for backwards compatible purposes. Use {@link #start(boolean)}. */
134+
@Deprecated(forRemoval = true)
135+
void start() throws OperatorException;
136+
137+
/** Strop the event source */
138+
void stop() throws OperatorException;
122139
}

0 commit comments

Comments
 (0)