Informer pools - #3325
Conversation
There was a problem hiding this comment.
Pull request overview
Introduces initial scaffolding for “event source pooling” (starting with informers) under processing.event.source.pool, likely to enable sharing/reuse of informers across components/controllers.
Changes:
- Added
EventSourcePoolinterface andAbstractEventSourcePoolbase type. - Added
InformerClassifierrecord to key pooled informers by selector/namespace/resource type. - Added initial (currently incomplete)
InformerPoolimplementation and a minor whitespace cleanup inInformerManager.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/pool/InformerPool.java |
Adds a new pool for SharedIndexInformer<?> instances (currently stubbed/incomplete). |
operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/pool/InformerClassifier.java |
Adds a classifier record intended as the cache key for pooled informers. |
operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/pool/EventSourcePool.java |
Introduces a generic pool interface for event sources. |
operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/pool/AbstractEventSourcePool.java |
Adds a base class placeholder for pool implementations. |
operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerManager.java |
Removes an extraneous whitespace line in createEventSource. |
d5157bd to
0297680
Compare
e45bf4c to
1e91a47
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 52 out of 52 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (2)
operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AlwaysNewInformerPool.java:45
- The
containsKey(...)+put(...)sequence is not atomic on aConcurrentHashMap. If two threads request the same (controllerName,name,classifier) at the same time, both can passcontainsKeyand one will overwrite the other, leaking an informer instance. UseputIfAbsent(orcompute) to make registration atomic, and stop the newly created informer if it loses the race.
var key = new ClassifierWithName(controllerName, name, classifier);
if (informers.containsKey(key)) {
throw new OperatorException(
operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentResourceIT.java:136
informerPool()returningnullcan lead to a hard-to-diagnoseNullPointerExceptionif this test (or the default methods it calls) ever ends up touching the pool. If the pool is intentionally unsupported here, it’s safer to throw an explicitUnsupportedOperationException.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 52 out of 52 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (4)
operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AlwaysNewInformerPool.java:58
- The containsKey(...) check followed by informers.put(...) is not atomic. Under concurrent calls, two threads can both pass the containsKey check, create two informers, and one put will overwrite the other entry (leaking the overwritten informer) before the exception path is hit. Use an atomic putIfAbsent/compute and, if you still throw on duplicates, ensure any newly created informer is stopped before throwing to avoid leaking watch threads/connections.
var informer = createInformer(classifier, client);
informers.put(key, informer);
return informer;
operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AbstractInformerPool.java:140
- The thread name format string looks accidentally duplicated ("InformerInfo[informerInfo...") which makes debugging output harder to read/grep. Consider simplifying to a single, consistent prefix (e.g., "InformerInfo[] ").
thread.setName(
"InformerInfo[" + informer.getApiTypeClass().getSimpleName() + "] " + thread.getId());
final var resourceName = informer.getApiTypeClass().getSimpleName();
// idempotent: if the informer was already started (e.g. by the pool when it was
// created/reused), this just returns the existing start future without restarting it
var start = informer.start();
operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerWrapper.java:109
- This toString/informerInfo label contains an extra "informerInfo" token ("InformerWrapper [informerInfo]") which looks like an unintentional duplication and makes logs less clear. Consider emitting just the type name (and optionally namespace) in the wrapper label.
private String informerInfo() {
return "InformerWrapper [informerInfo" + informer.getApiTypeClass().getSimpleName() + "]";
}
operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentResourceIT.java:136
- Returning null from ConfigurationService#informerPool() violates the new contract and will cause a NullPointerException if this helper ConfigurationService is ever used beyond the current narrow purpose. Prefer failing fast with an UnsupportedOperationException (or returning a real pool) so future refactors don't introduce a hard-to-diagnose NPE.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 52 out of 52 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (3)
operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerWrapper.java:109
- informerInfo() currently returns "InformerWrapper [informerInfo]", which looks like a leftover debug label and makes toString()/logs confusing when diagnosing informer issues. It should format consistently without the stray "informerInfo" token.
private String informerInfo() {
return "InformerWrapper [informerInfo" + informer.getApiTypeClass().getSimpleName() + "]";
}
operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AlwaysNewInformerPool.java:47
- getInformer() uses informers.containsKey(key) followed by informers.put(key, informer). With a ConcurrentHashMap this check-then-act sequence is not atomic: two concurrent callers can both observe the key as absent, create two informers, and one put will overwrite the other (leaking an informer) without throwing the intended OperatorException. Use putIfAbsent (or compute) to make the registration atomic and stop the newly-created informer on collision.
var key = new ClassifierWithName(controllerName, name, classifier);
if (informers.containsKey(key)) {
throw new OperatorException(
"Informer already registered for controller: "
+ controllerName
operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentResourceIT.java:136
- This test-only ConfigurationService implementation returns null from informerPool(). Even if currently “never accessed”, it’s brittle and can turn into an unexpected NPE if the cloner implementation (or any other default method used here) starts consulting informerPool(). Since the test already has a running LocallyRunOperatorExtension, it can avoid the ad-hoc ConfigurationService entirely and reuse the operator’s real ConfigurationService/resource cloner instead.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 52 out of 52 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/AlwaysNewInformerPool.java:47
getInformerusescontainsKeyfollowed byput, which is not atomic even withConcurrentHashMap. Under concurrent calls for the same (controllerName, event source name, classifier) key, this can still create (and potentially leak) multiple informers and bypass the intended "already registered" exception. UseputIfAbsent(orcomputeIfAbsent) so the check+insert is atomic, and stop the extra informer if another thread won the race.
var key = new ClassifierWithName(controllerName, name, classifier);
if (informers.containsKey(key)) {
throw new OperatorException(
"Informer already registered for controller: "
+ controllerName
operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerWrapper.java:109
informerInfo()currently returns"InformerWrapper [informerInfo" + ..., which looks like an accidental leftover token and makestoString()output confusing.
private String informerInfo() {
return "InformerWrapper [informerInfo" + informer.getApiTypeClass().getSimpleName() + "]";
}
|
@SamBarker @k-wall I saw that you added explicit support in kroxy operator to share informers, in case you are interested and have the bandwidth PTAL if this makes sense for you; this should solve that problem out of the box. thank you! |
Thank you for the heads up. We'll take a look and feed back. |
xstefank
left a comment
There was a problem hiding this comment.
approving, I have only opinioned enhancements. Feel free to comment on each and we can either do it in this PR or as followups.
6345aab to
7306725
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 59 out of 59 changed files in this pull request and generated no new comments.
Suppressed comments (4)
operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/NonSharingInformerPool.java:43
NonSharingInformerPool#getInformerusescontainsKeyfollowed byput, which is not atomic on aConcurrentHashMap. Two concurrent calls for the same (controllerName, eventSourceName, classifier) can both pass thecontainsKeycheck and then overwrite each other, leaking the earlier informer (never released/stopped). UseputIfAbsent(orcompute) to make registration atomic and stop the newly-created informer if a previous one was already present.
var key = new ClassifierWithName(controllerName, name, classifier);
if (informers.containsKey(key)) {
throw new OperatorException(
"Informer already registered for controller: "
+ controllerName
operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentResourceIT.java:136
- This test
ConfigurationServiceimplementation returnsnullfrominformerPool(). With informer pooling now part of normal wiring, accidental access will fail with an NPE (and the comment here may become untrue as the code evolves). Return a non-null pool instance wired with thisConfigurationServiceinstead, even if the test currently doesn't use it.
operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerManager.java:174 getTargetClient()is meant to resolve and cache the client once, but it is not synchronized/volatile. WhenchangeNamespaces(...)starts multiple namespaces in parallel, multiple threads can observetargetClient == nulland fetch differentConfigurationService#getKubernetesClient()instances (the default implementation creates a new client per call), producing inconsistent classifiers within the same manager and preventing sharing. Make the accessor synchronized (or otherwise thread-safe) to ensure only one client instance is ever chosen.
private KubernetesClient getTargetClient() {
// resolved once: the client is part of the informer classifier's identity, so every classifier
// this manager builds (one per watched namespace, and more when namespaces change later on) has
// to see the very same instance. ConfigurationService#getKubernetesClient is expected to return
// a stable instance, but its default implementation does create a new client on every call.
operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerWrapper.java:157
- The
isAssignableFromcheck inversionedFullResourceName()is reversed. As written, it returnsfalsefor subclasses ofGenericKubernetesResource, so generic resources may be reported with the wrong type name (affecting logs / errors). The usual pattern isGenericKubernetesResource.class.isAssignableFrom(apiTypeClass).
private String versionedFullResourceName() {
final var apiTypeClass = informer.getApiTypeClass();
if (apiTypeClass.isAssignableFrom(GenericKubernetesResource.class)) {
return GenericKubernetesResource.class.getSimpleName();
}
Every InformerEventSource used to create its own SharedIndexInformer, so an operator whose controllers all watch the same secondary type - ConfigMap and Secret being the usual suspects - opened one watch connection and kept one cache per controller for the very same resources. Informers are now handed out by an InformerPool obtained from the ConfigurationService, keyed by an InformerClassifier. Event sources whose classifiers are equal are backed by one informer; the pool reference counts its users and stops the informer once the last one releases it. The classifier is made up of everything that decides what an informer watches and how: the KubernetesClient instance (compared by identity, since two clients for the same API server may still differ in credentials, impersonation or TLS material), the resource class or the group/version/kind for generic resources, the namespace, the label, field and shard selectors, and the item store. Two components are deliberately not part of that identity. The informerListLimit is excluded, so event sources that disagree only on it still share an informer, keeping the limit of whichever one created it and logging a warning. Indexers are excluded because they can be added to a running informer: they are registered under a name qualified with the controller and event source that added them, so index names stay private to an event source while callers keep using their own names, and they are removed again when that event source releases the informer. Two strategies ship: DefaultInformerPool shares as described and is the default, NonSharingInformerPool creates a dedicated informer per event source for anyone wanting to opt out. Either is selected with ConfigurationServiceOverrider#withInformerPool, and a custom strategy extends AbstractInformerPool, which already creates the informers from a classifier, starts them and waits for their caches to sync, leaving the subclass only the question of whether and when an informer is shared. Consequently informer creation and startup moved out of InformerWrapper and InformerManager into the pool, InformerManager acquires and releases informers instead of owning them, and it removes its own event handler and indexers from an informer that keeps running for others. An event source registered dynamically against an already running shared informer needs no special handling: the client replays the cache contents to a newly added handler. Also in support of the above: ConfigurationService#informerPool, an InformerEventSource constructor that no longer needs an EventSourceContext (the one taking it is deprecated), the resource group/version/kind on InformerConfiguration, equality and toString on FieldSelector, and equality of GroupVersionKindPlural made consistent with its hashCode so that an unspecified plural no longer splits informers. The pooling itself is production ready; the configuration API around it is marked experimental and may still change. Covered by unit tests for the pools, the classifier, the wrapper and the manager, and by integration tests for sharing, dynamic registration and de-registration that each run against both strategies. Signed-off-by: Attila Mészáros <a_meszaros@apple.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 59 out of 59 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
operator-framework/src/test/java/io/javaoperatorsdk/operator/dependent/standalonedependent/StandaloneDependentResourceIT.java:136
ConfigurationService#informerPool()returns null here. Even if this helper currently only callsgetResourceCloner(), returning null violates the new ConfigurationService contract and will cause a NullPointerException if this ConfigurationService instance is ever used by code paths that resolve/start informers (e.g. future refactors of this IT). Returning a cached, properly-initialized pool makes the test more robust.
operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/NonSharingInformerPool.java:65- The warning logged when a release misses the pool only includes the classifier. In this pool the lookup key also includes
controllerNameandname, so omitting them makes the message much harder to act on when diagnosing mismatched release calls.
} else {
log.warn("Informer was not found for classifier: {}", classifier);
}
operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ConfigurationServiceOverrider.java:195
- PR description mentions
ConfigurationServiceOverrider.withInformerPool(InformerPool), but the implemented API takesAbstractInformerPool. Please align the PR description (or the API) so users aren’t misled about which type they can pass and whether implementing the interface alone is sufficient.
public ConfigurationServiceOverrider withInformerPool(AbstractInformerPool informerPool) {
this.informerPool = informerPool;
return this;
operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/pool/DefaultInformerPool.java:89
- When a release misses the pool, the warning currently logs only the classifier. Including
controllerNameandnamewould make diagnosing double-releases / mismatched keys much easier, especially now that informers can be shared across controllers.
if (pooled == null) {
log.warn("No informer found in the pool for classifier: {}", classifier);
return Optional.empty();
| * <p><strong>Implementations must return the same instance on every call.</strong> The pool is | ||
| * effectively a per-{@code ConfigurationService} singleton: controllers share informers only if | ||
| * they resolve the same pool, and reference counting / informer shutdown are only correct if | ||
| * {@code getInformer} and {@code releaseInformer} operate on that same instance. This is | ||
| * intentionally not a {@code default} method, since a {@code default} could not cache the result | ||
| * and would hand out a fresh (unshared) pool on each call; {@link AbstractConfigurationService} | ||
| * provides a cached implementation backed by the default sharing pool. | ||
| * |
Summary
Introduces an informer pool so that informers backing
InformerEventSources (and the internal controller event source) can be shared across controllers instead of each controller/event source always creating its ownSharedIndexInformer. This reduces the number of watch connections opened against the API server and memory overhead in operators where multiple controllers (or multiple dynamically-registered event sources) watch the same secondary resource type (e.g.ConfigMap,Secret).This is marked
@Experimental: the pooling behavior itself is production ready, but the configuration API may still evolve in a non-backwards-compatible way.What's new
InformerPoolabstraction (processing/event/source/informer/pool)InformerPool— interface for getting/releasing informers, keyed by anInformerClassifier, plus reference counting vianumberOfInformersForResource.InformerClassifier— identifies "equivalent" informer requests: API server URL, label/field/shard selectors, namespace, resource type (or GVK for generic resources), and item store.informerListLimitand indexers are intentionally excluded from identity (a warning is logged if an existing informer is reused with a different list limit; indexers can be added independently to a shared informer as long as names don't collide).AbstractInformerPool— shared logic for building the underlyingSharedIndexInformerfrom a classifier (selectors, field selectors, item store, list limit, exception/stopped handlers) and starting it with the configuredcacheSyncTimeout.DefaultInformerPool(new default) — reference-counted, sharing pool: creates an informer on first request for a given classifier and reuses it for subsequent requests; only stops it once the last user releases it.NonSharingInformerPool— opt-out pool that creates a dedicated informer per event source, preserving pre-pooling behavior.Configuration
ConfigurationService.informerPool()— new (non-default) method; implementations must return the same instance on every call so controllers using the sameConfigurationServiceshare the pool.AbstractConfigurationServicecaches aDefaultInformerPoollazily (synchronized to avoid creating more than one instance under concurrent first access).ConfigurationServiceOverrider.withInformerPool(InformerPool)— lets users opt intoAlwaysNewInformerPool(or a custom implementation) to disable/customize sharing.Integration
InformerManagerandInformerWrapperwere reworked to go through the pool instead of creating/starting/stopping theSharedIndexInformerdirectly:InformerManagernow builds anInformerClassifierper namespace and callsinformerPool.getInformer(...)/releaseInformer(...)instead of constructing the informer from the Fabric8 client itself.getInformeronly registers/reference-counts,InformerPool#startblocks (idempotently) until cache sync, andreleaseInformerdecrements the reference count, stopping the informer only when it drops to zero. The event handler is always removed from the returned informer, even when it stays running for other users.InformerWrapperno longer owns lifecycle (start/stopremoved); it now just exposes the informer/classifier and cache-facing behavior.ControllerEventSourceandManagedInformerEventSourceupdated to go through the same pooled path.Docs
Added a new "Sharing Informers Between Controllers (Informer Pool)" section to
docs/content/en/docs/documentation/eventing.mddescribing sharing semantics, what counts toward informer identity, and how to select/override the pooling strategy.Tests
NonSharingInformerPool,DefaultInformerPool, andInformerClassifier(equality/identity semantics).informerpool:basic— two reconcilers sharing an informer for the same secondary resource type, parameterized to also run againstAlwaysNewInformerPoolfor regression coverage.deregister— verifies informer lifecycle when an event source is dynamically deregistered while shared.dynamic— verifies dynamically-registered/static shared informers, including replay of existing cache state to a newly attached handler.ControllerTest,EventSourceManagerTest,ReconciliationDispatcherTest,ControllerEventSourceTest,InformerEventSourceTest,MockKubernetesClient,StandaloneDependentResourceIT,WorkflowMultipleActivationIT) updated for the new construction/wiring paths.