fix: invoke ConfigurationServiceOverrider consumer only once in Operator constructor by Dennis-Mircea · Pull Request #3353 · operator-framework/java-operator-sdk
Summary
Operator.initConfigurationService invokes the caller-supplied Consumer<ConfigurationServiceOverrider> twice when the Operator(Consumer<ConfigurationServiceOverrider>) constructor is used. Any side-effecting code inside that consumer (e.g. logging) is therefore emitted twice on startup.
Observed in downstream operators such as Apache Flink Kubernetes Operator, which configures the operator via new Operator(this::overrideOperatorConfigs) and ends up logging each startup line twice:
2026-05-11 14:52:16,723 o.a.f.k.o.FlinkOperator [INFO] Configuring operator with 50 reconciliation threads.
2026-05-11 14:52:16,723 o.a.f.k.o.FlinkOperator [INFO] Operator leader election is disabled.
2026-05-11 14:52:16,724 o.a.f.k.o.FlinkOperator [INFO] Configuring operator with 50 reconciliation threads.
2026-05-11 14:52:16,724 o.a.f.k.o.FlinkOperator [INFO] Operator leader election is disabled.
Root cause
Today's initConfigurationService does a two-pass build when client == null:
// Pass 1: build a throwaway service just to extract the client if (client == null) { var configurationService = ConfigurationService.newOverriddenConfigurationService(overrider); client = configurationService.getKubernetesClient(); } // ... wrap the overrider to pin that exact client ... // Pass 2: build the real service return ConfigurationService.newOverriddenConfigurationService(overrider);
Each newOverriddenConfigurationService(overrider) call invokes the user's overrider once, so it runs twice in the client == null path. The Operator(KubernetesClient) path is unaffected and it skips the first pass.
The second pass exists to "pin" the resolved KubernetesClient back onto the configuration service via withKubernetesClient(...). That pinning is redundant: AbstractConfigurationService#getKubernetesClient() already memoizes the client lazily on first read, so any consumer asking the service for its client gets the same instance whether it was set explicitly or constructed lazily.
Fix
Collapse to a single pass:
protected ConfigurationService initConfigurationService( KubernetesClient client, Consumer<ConfigurationServiceOverrider> overrider) { if (client != null) { Consumer<ConfigurationServiceOverrider> bindClient = o -> o.withKubernetesClient(client); overrider = overrider == null ? bindClient : overrider.andThen(bindClient); } return ConfigurationService.newOverriddenConfigurationService(overrider); }
- When the caller passes an explicit
KubernetesClient, the overrider is augmented to pin it (same behavior as before). - When no client is passed, the configuration service falls back to its already-documented lazy client construction (
AbstractConfigurationService.java:177-// lazy init to avoid needing initializing a client when not needed).
Compatibility
- Public API: unchanged - same method signature, same return type, same
protectedvisibility for subclass overrides (OperatorIT.OperatorExtensionstill overrides it; verified). - Observable behavior:
- User-supplied overriders are now invoked exactly once instead of twice. Any code that relied on being invoked twice (logging counts, increment-style side effects) would change, but invoking a configuration overrider twice was never a documented contract and is a clear bug.
- When no
KubernetesClientis passed, the client is constructed lazily on firstgetKubernetesClient()call instead of eagerly duringinitConfigurationService. This matches the already-documented intent of the lazy initialization inAbstractConfigurationService.
Test plan
./mvnw -pl operator-framework-core -am compile- succeeds, no new warnings../mvnw -pl operator-framework-core -am spotless:check- passes../mvnw -pl operator-framework-core -am test -Dtest='ConfigurationServiceOverriderTest,LeaderElectionManagerTest,ControllerTest,ReconciliationDispatcherTest,EventProcessorTest'- 77/77 pass.