From 58301d5440587df3097b49a8a1abf86570d99c4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 28 May 2026 13:45:42 +0200 Subject: [PATCH 01/21] improve: extend SecondaryToPrimaryMapper so it also gets the old resource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This might needed in some corner cases where might help with optimizations to which resource to trigger. Signed-off-by: Attila Mészáros --- .../content/en/docs/documentation/eventing.md | 19 ++++++++ .../source/SecondaryToPrimaryMapper.java | 43 ++++++++++++++++++- .../DefaultPrimaryToSecondaryIndex.java | 6 ++- .../source/informer/InformerEventSource.java | 4 +- .../informer/InformerEventSourceTest.java | 2 +- .../event/source/informer/MappersTest.java | 9 ++-- .../informer/PrimaryToSecondaryIndexTest.java | 2 +- 7 files changed, 73 insertions(+), 12 deletions(-) diff --git a/docs/content/en/docs/documentation/eventing.md b/docs/content/en/docs/documentation/eventing.md index 06b8ccf9e9..107a8a7db2 100644 --- a/docs/content/en/docs/documentation/eventing.md +++ b/docs/content/en/docs/documentation/eventing.md @@ -139,6 +139,25 @@ rare corner cases. Returning an empty set means that the mapper considered the s resource event as irrelevant and the SDK will thus not trigger a reconciliation of the primary resource in that situation. +`SecondaryToPrimaryMapper` exposes two methods: + +- `toPrimaryResourceIDs(R resource)` — the original mapper. Implementing it is sufficient for + the vast majority of use cases. +- `toPrimaryResourceIDs(R newResource, R oldResource)` — a variant that is the one actually + invoked by the SDK on every secondary event. Its default implementation delegates to the + single-argument method, so existing mappers keep working unchanged. + +Override the two-argument variant only in edge cases where the set of primary resources to +reconcile depends on what changed between the previous and the new version of the secondary +resource (e.g. a reference that moved from one primary to another, where both primaries need +to be reconciled). **Use it with caution:** `oldResource` is sourced from the informer cache and +is only populated for genuine update events observed while the controller is already running. +On controller startup the cache is empty, so the initial events received for resources that +already exist in the cluster are delivered as adds with `oldResource == null` — even if those +resources had been updated before the operator came up. `oldResource` is also `null` for delete +events and for events triggered through the primary-to-secondary index. Implementations must +therefore handle a `null` `oldResource` gracefully. + Adding a `SecondaryToPrimaryMapper` is typically sufficient when there is a one-to-many relationship between primary and secondary resources. The secondary resources can be mapped to its primary owner, and this is enough information to also get these secondary resources from the `Context` diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java index 0c6126105c..6861b6592a 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java @@ -26,9 +26,48 @@ */ @FunctionalInterface public interface SecondaryToPrimaryMapper { + /** - * @param resource - secondary - * @return set of primary resource IDs + * Maps a secondary resource to the set of primary resources that should be reconciled in + * response. Implementing this single-argument form is sufficient for the vast majority of use + * cases — prefer it unless you specifically need access to the previous version of the + * secondary resource (see {@link #toPrimaryResourceIDs(Object, Object)}). + * + * @param resource the secondary resource for which an event was received + * @return set of primary resource IDs to enqueue for reconciliation; an empty set means the + * event is irrelevant and no reconciliation is triggered */ Set toPrimaryResourceIDs(R resource); + + /** + * Variant invoked by the framework for every secondary resource event, providing both the new and + * the previous version of the resource (when available). The default implementation simply + * delegates to {@link #toPrimaryResourceIDs(Object)} and ignores {@code oldResource}, so existing + * mappers keep working unchanged. + * + *

Override this method only for edge cases where the set of primary resources to reconcile + * depends on what changed between the old and the new version of the secondary resource (for + * example, when a reference held by the secondary resource has moved from one primary to another + * and both primaries need to be reconciled). + * + *

Use with caution. {@code oldResource} is sourced from the informer cache + * and is therefore only populated for genuine update events observed while the controller is + * already running. In particular, when the controller starts up, the cache is empty and the + * initial events received for resources that already existed in the cluster are delivered as adds + * with {@code oldResource == null} (even if those resources had been updated previously). {@code + * oldResource} is also {@code null} for delete events and for events triggered through the + * primary-to-secondary index. + * + *

Implementations must therefore handle a {@code null} {@code oldResource} gracefully and not + * rely on it being present for correctness — overriding this method is intended for edge cases + * only. + * + * @param newResource the current version of the secondary resource + * @param oldResource the previous version of the secondary resource, or {@code null} if not + * available (see above) + * @return set of primary resource IDs to enqueue for reconciliation + */ + default Set toPrimaryResourceIDs(R newResource, R oldResource) { + return toPrimaryResourceIDs(newResource); + } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java index 2b4f3814b3..c9a99aadb0 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java @@ -33,7 +33,8 @@ public DefaultPrimaryToSecondaryIndex(SecondaryToPrimaryMapper secondaryToPri @Override public synchronized void onAddOrUpdate(R resource) { - Set primaryResources = secondaryToPrimaryMapper.toPrimaryResourceIDs(resource); + Set primaryResources = + secondaryToPrimaryMapper.toPrimaryResourceIDs(resource, null); primaryResources.forEach( primaryResource -> { var resourceSet = @@ -44,7 +45,8 @@ public synchronized void onAddOrUpdate(R resource) { @Override public synchronized void onDelete(R resource) { - Set primaryResources = secondaryToPrimaryMapper.toPrimaryResourceIDs(resource); + Set primaryResources = + secondaryToPrimaryMapper.toPrimaryResourceIDs(resource, null); primaryResources.forEach( primaryResource -> { var secondaryResources = index.get(primaryResource); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java index c425a4d413..80f0f62b50 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java @@ -200,9 +200,9 @@ private synchronized void onAddOrUpdate(ResourceAction action, R newObject, R ol } } - protected void propagateEvent(R object) { + private void propagateEvent(R resource, R oldResource) { var primaryResourceIdSet = - configuration().getSecondaryToPrimaryMapper().toPrimaryResourceIDs(object); + configuration().getSecondaryToPrimaryMapper().toPrimaryResourceIDs(resource, oldResource); if (primaryResourceIdSet.isEmpty()) { return; } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java index dda08a7c98..555ffec924 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java @@ -85,7 +85,7 @@ void setup() { SecondaryToPrimaryMapper secondaryToPrimaryMapper = mock(SecondaryToPrimaryMapper.class); when(informerEventSourceConfiguration.getSecondaryToPrimaryMapper()) .thenReturn(secondaryToPrimaryMapper); - when(secondaryToPrimaryMapper.toPrimaryResourceIDs(any())) + when(secondaryToPrimaryMapper.toPrimaryResourceIDs(any(), any())) .thenReturn(Set.of(ResourceID.fromResource(testDeployment()))); when(informerEventSourceConfiguration.getInformerConfig()).thenReturn(informerConfig); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/MappersTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/MappersTest.java index 8d08c4e8a0..8b4a9fbff7 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/MappersTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/MappersTest.java @@ -40,7 +40,8 @@ void secondaryToPrimaryMapperFromOwnerReference() { var secondary = getConfigMap(primary); secondary.addOwnerReference(primary); - var res = Mappers.fromOwnerReferences(TestCustomResource.class).toPrimaryResourceIDs(secondary); + var res = + Mappers.fromOwnerReferences(TestCustomResource.class).toPrimaryResourceIDs(secondary, null); assertThat(res).contains(ResourceID.fromResource(primary)); } @@ -65,7 +66,7 @@ void secondaryToPrimaryMapperFromOwnerReferenceWhereGroupIdIsEmpty() { .build(); secondary.addOwnerReference(primary); - var res = Mappers.fromOwnerReferences(ConfigMap.class).toPrimaryResourceIDs(secondary); + var res = Mappers.fromOwnerReferences(ConfigMap.class).toPrimaryResourceIDs(secondary, null); assertThat(res).contains(ResourceID.fromResource(primary)); } @@ -79,7 +80,7 @@ void secondaryToPrimaryMapperFromOwnerReferenceFiltersByType() { var res = Mappers.fromOwnerReferences(TestCustomResourceOtherV1.class) - .toPrimaryResourceIDs(secondary); + .toPrimaryResourceIDs(secondary, null); assertThat(res).isEmpty(); } @@ -103,7 +104,7 @@ void fromOwnerReferenceIgnoresVersionFromApiVersion() { HasMetadata.getGroup(TestCustomResource.class) + "/v2", HasMetadata.getKind(TestCustomResource.class), false) - .toPrimaryResourceIDs(secondary); + .toPrimaryResourceIDs(secondary, null); assertThat(res).contains(ResourceID.fromResource(primary)); } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndexTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndexTest.java index 91bca3708c..9503085a03 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndexTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndexTest.java @@ -46,7 +46,7 @@ class PrimaryToSecondaryIndexTest { @BeforeEach void setup() { - when(secondaryToPrimaryMapperMock.toPrimaryResourceIDs(any())) + when(secondaryToPrimaryMapperMock.toPrimaryResourceIDs(any(), any())) .thenReturn(Set.of(primaryID1, primaryID2)); } From 8c03c7eb2e1e8e2ace60ca1fff4256ca53cdd781 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 28 May 2026 13:50:27 +0200 Subject: [PATCH 02/21] format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../processing/event/source/SecondaryToPrimaryMapper.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java index 6861b6592a..be38dde4a7 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java @@ -30,12 +30,12 @@ public interface SecondaryToPrimaryMapper { /** * Maps a secondary resource to the set of primary resources that should be reconciled in * response. Implementing this single-argument form is sufficient for the vast majority of use - * cases — prefer it unless you specifically need access to the previous version of the - * secondary resource (see {@link #toPrimaryResourceIDs(Object, Object)}). + * cases — prefer it unless you specifically need access to the previous version of the secondary + * resource (see {@link #toPrimaryResourceIDs(Object, Object)}). * * @param resource the secondary resource for which an event was received - * @return set of primary resource IDs to enqueue for reconciliation; an empty set means the - * event is irrelevant and no reconciliation is triggered + * @return set of primary resource IDs to enqueue for reconciliation; an empty set means the event + * is irrelevant and no reconciliation is triggered */ Set toPrimaryResourceIDs(R resource); From 5add55171c76fb2931385dc9e6a6c37dd3d154e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 16 Jun 2026 14:47:35 +0200 Subject: [PATCH 03/21] javadoc improve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../processing/event/source/SecondaryToPrimaryMapper.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java index be38dde4a7..8424c423d5 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java @@ -60,7 +60,8 @@ public interface SecondaryToPrimaryMapper { * *

Implementations must therefore handle a {@code null} {@code oldResource} gracefully and not * rely on it being present for correctness — overriding this method is intended for edge cases - * only. + * only. Genericly speaking controller should also handle such change checking during + * reconciliation, so when controller starts and event is missed it is properly reconiled. * * @param newResource the current version of the secondary resource * @param oldResource the previous version of the secondary resource, or {@code null} if not From a662e1af388ab5805224c6077c539177d1ef3d14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 17 Jun 2026 13:59:07 +0200 Subject: [PATCH 04/21] naive IT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../ConfigCustomResource.java | 33 ++++++ .../ConfigSpec.java | 45 ++++++++ .../ConfigToTargetMapper.java | 57 ++++++++++ .../SecondaryToPrimaryReferenceChangeIT.java | 102 ++++++++++++++++++ .../TargetCustomResource.java | 35 ++++++ .../TargetReconciler.java | 76 +++++++++++++ .../TargetStatus.java | 30 ++++++ 7 files changed, 378 insertions(+) create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigCustomResource.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigSpec.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigToTargetMapper.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/SecondaryToPrimaryReferenceChangeIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetCustomResource.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetStatus.java diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigCustomResource.java new file mode 100644 index 0000000000..5216547c3e --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigCustomResource.java @@ -0,0 +1,33 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.secondarytoprimaryreferencechange; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Kind; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +/** + * Secondary resource that references a {@link TargetCustomResource} via {@code spec.targetName} and + * serves as input for it. + */ +@Group("sample.javaoperatorsdk") +@Version("v1") +@Kind("SecondaryToPrimaryRefConfig") +@ShortNames("s2pconfig") +public class ConfigCustomResource extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigSpec.java new file mode 100644 index 0000000000..eacd6f7843 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigSpec.java @@ -0,0 +1,45 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.secondarytoprimaryreferencechange; + +public class ConfigSpec { + + /** + * Name of the {@link TargetCustomResource} (in the same namespace) this config provides input. + */ + private String targetName; + + /** Value to be applied to the referenced target's status. */ + private String value; + + public String getTargetName() { + return targetName; + } + + public ConfigSpec setTargetName(String targetName) { + this.targetName = targetName; + return this; + } + + public String getValue() { + return value; + } + + public ConfigSpec setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigToTargetMapper.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigToTargetMapper.java new file mode 100644 index 0000000000..342e85fe7f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigToTargetMapper.java @@ -0,0 +1,57 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.secondarytoprimaryreferencechange; + +import java.util.HashSet; +import java.util.Set; + +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper; + +/** + * Maps a {@link ConfigCustomResource} (secondary) to the {@link TargetCustomResource} (primary) it + * references via {@code spec.targetName}. + * + *

The interesting case is handling a reference change: when {@code spec.targetName} is + * edited to point from one target to another, both targets must be reconciled — the previously + * referenced one so it can fall back to its default value, and the newly referenced one so it can + * pick up the config's value. The single-argument mapper only knows about the new reference, so it + * would only enqueue the new target, leaving the old target with a stale value. By overriding the + * two-argument variant we additionally enqueue the old target whenever the reference moved. + */ +public class ConfigToTargetMapper implements SecondaryToPrimaryMapper { + + @Override + public Set toPrimaryResourceIDs(ConfigCustomResource config) { + var targetName = config.getSpec().getTargetName(); + if (targetName == null || targetName.isBlank()) { + return Set.of(); + } + return Set.of(new ResourceID(targetName, config.getMetadata().getNamespace())); + } + + @Override + public Set toPrimaryResourceIDs( + ConfigCustomResource newConfig, ConfigCustomResource oldConfig) { + var result = new HashSet<>(toPrimaryResourceIDs(newConfig)); + // oldConfig is only populated for genuine update events while the controller is running; for + // adds, deletes and startup it is null and there is no previous reference to reconcile. + if (oldConfig != null) { + result.addAll(toPrimaryResourceIDs(oldConfig)); + } + return result; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/SecondaryToPrimaryReferenceChangeIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/SecondaryToPrimaryReferenceChangeIT.java new file mode 100644 index 0000000000..288e6e6651 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/SecondaryToPrimaryReferenceChangeIT.java @@ -0,0 +1,102 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.secondarytoprimaryreferencechange; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.annotation.Sample; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.baseapi.secondarytoprimaryreferencechange.TargetReconciler.DEFAULT_VALUE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@Sample( + tldr = "Handling a Secondary Resource Whose Reference to a Primary Changes", + description = + """ + Demonstrates a configuration custom resource (the secondary) that references a target \ + custom resource (the primary) via a spec field and serves as input for it. The target is \ + reconciled so that, if a config references it, it takes the value from that config; \ + otherwise it falls back to a default. The test shows how to handle the config's reference \ + changing from one target to another: a SecondaryToPrimaryMapper that overrides the \ + two-argument variant enqueues both the previously referenced target (so it reverts to the \ + default) and the newly referenced one (so it picks up the value). + """) +class SecondaryToPrimaryReferenceChangeIT { + + static final String TARGET_A = "target-a"; + static final String TARGET_B = "target-b"; + static final String CONFIG_NAME = "config"; + static final String CONFIG_VALUE = "value-from-config"; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withAdditionalCustomResourceDefinition(ConfigCustomResource.class) + .withReconciler(new TargetReconciler()) + .build(); + + @Test + void targetTakesValueFromReferencingConfigAndHandlesReferenceChange() { + operator.create(target(TARGET_A)); + operator.create(target(TARGET_B)); + + // With no config, both targets fall back to the default value. + awaitTargetValue(TARGET_A, DEFAULT_VALUE); + awaitTargetValue(TARGET_B, DEFAULT_VALUE); + + // A config referencing target A makes A take the config's value; B stays on the default. + var config = operator.create(config(TARGET_A)); + awaitTargetValue(TARGET_A, CONFIG_VALUE); + awaitTargetValue(TARGET_B, DEFAULT_VALUE); + + // Moving the reference from A to B reconciles both: A reverts to the default and B picks it up. + config.getSpec().setTargetName(TARGET_B); + operator.replace(config); + + awaitTargetValue(TARGET_B, CONFIG_VALUE); + awaitTargetValue(TARGET_A, DEFAULT_VALUE); + } + + private void awaitTargetValue(String name, String expectedValue) { + await() + .atMost(Duration.ofSeconds(10)) + .untilAsserted( + () -> { + var target = operator.get(TargetCustomResource.class, name); + assertThat(target.getStatus()).isNotNull(); + assertThat(target.getStatus().getValue()).isEqualTo(expectedValue); + }); + } + + private TargetCustomResource target(String name) { + var target = new TargetCustomResource(); + target.setMetadata(new ObjectMetaBuilder().withName(name).build()); + return target; + } + + private ConfigCustomResource config(String targetName) { + var config = new ConfigCustomResource(); + config.setMetadata(new ObjectMetaBuilder().withName(CONFIG_NAME).build()); + config.setSpec(new ConfigSpec().setTargetName(targetName).setValue(CONFIG_VALUE)); + return config; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetCustomResource.java new file mode 100644 index 0000000000..b3bcae6ccb --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetCustomResource.java @@ -0,0 +1,35 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.secondarytoprimaryreferencechange; + +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Kind; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +/** + * Primary resource that is reconciled. Its desired status value is provided by a {@link + * ConfigCustomResource} that references it (see {@link TargetReconciler}); when no config + * references it, a default value is used. + */ +@Group("sample.javaoperatorsdk") +@Version("v1") +@Kind("SecondaryToPrimaryRefTarget") +@ShortNames("s2ptarget") +public class TargetCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java new file mode 100644 index 0000000000..8668695bb4 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java @@ -0,0 +1,76 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.secondarytoprimaryreferencechange; + +import java.util.List; + +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; + +/** + * Reconciles {@link TargetCustomResource}s. The desired status value comes from a {@link + * ConfigCustomResource} that references the target via {@code spec.targetName}; if no config + * references the target, {@link #DEFAULT_VALUE} is used. + * + *

{@link ConfigCustomResource}s are watched as secondary resources through an {@link + * InformerEventSource} configured with {@link ConfigToTargetMapper}, so changes to a config trigger + * reconciliation of the referenced target(s). + */ +@ControllerConfiguration +public class TargetReconciler implements Reconciler { + + public static final String DEFAULT_VALUE = "default"; + + @Override + public List> prepareEventSources( + EventSourceContext context) { + + var configuration = + InformerEventSourceConfiguration.from( + ConfigCustomResource.class, TargetCustomResource.class) + .withSecondaryToPrimaryMapper(new ConfigToTargetMapper()) + .withPrimaryToSecondaryMapper(p -> null) + .build(); + + var ies = new InformerEventSource<>(configuration, context); + return List.of(ies); + } + + @Override + public UpdateControl reconcile( + TargetCustomResource target, Context context) { + + // There may be a stale secondary mapping after a reference change (the primary-to-secondary + // index does not remove the old association), so we also verify here that the config actually + // references this target. This makes reconciliation robust regardless of how the event arrived. + var value = + context.getSecondaryResources(ConfigCustomResource.class).stream() + .filter( + config -> target.getMetadata().getName().equals(config.getSpec().getTargetName())) + .map(config -> config.getSpec().getValue()) + .findFirst() + .orElse(DEFAULT_VALUE); + + target.setStatus(new TargetStatus().setValue(value)); + return UpdateControl.patchStatus(target); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetStatus.java new file mode 100644 index 0000000000..3679813070 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetStatus.java @@ -0,0 +1,30 @@ +/* + * Copyright Java Operator SDK Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.javaoperatorsdk.operator.baseapi.secondarytoprimaryreferencechange; + +public class TargetStatus { + + private String value; + + public String getValue() { + return value; + } + + public TargetStatus setValue(String value) { + this.value = value; + return this; + } +} From 8f80b297cecdcd3b6c79fe8cf1629b1ce8e1326a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 17 Jun 2026 14:33:12 +0200 Subject: [PATCH 05/21] fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../event/source/informer/InformerEventSource.java | 8 ++++---- .../event/source/informer/InformerEventSourceTest.java | 6 ++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java index 80f0f62b50..74853e0afe 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java @@ -130,7 +130,7 @@ public synchronized void onDelete(R resource, boolean deletedFinalStateUnknown) primaryToSecondaryIndex.onDelete(resource); if (eventAcceptedByFilter( ResourceAction.DELETED, resource, null, deletedFinalStateUnknown)) { - propagateEvent(resource); + propagateEvent(resource, null); } }); } @@ -166,7 +166,7 @@ protected void handleEvent( action, resource.getMetadata().getResourceVersion()); } - propagateEvent(resource); + propagateEvent(resource, oldResource); } @Override @@ -194,13 +194,13 @@ private synchronized void onAddOrUpdate(ResourceAction action, R newObject, R ol "Propagating event for {}, resource with same version not result of a our update.", action); var event = resultEvent.get(); - propagateEvent((R) event.getResource().orElseThrow()); + propagateEvent((R) event.getResource().orElseThrow(), oldObject); } else { log.debug("Event filtered out for operation: {}, resourceID: {}", action, resourceID); } } - private void propagateEvent(R resource, R oldResource) { + void propagateEvent(R resource, R oldResource) { var primaryResourceIdSet = configuration().getSecondaryToPrimaryMapper().toPrimaryResourceIDs(resource, oldResource); if (primaryResourceIdSet.isEmpty()) { diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java index 555ffec924..6ef19acd78 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java @@ -745,14 +745,16 @@ private void assertNoEventProduced() { await() .pollDelay(Duration.ofMillis(70)) .timeout(Duration.ofMillis(150)) - .untilAsserted(() -> verify(informerEventSource, never()).propagateEvent(any())); + .untilAsserted(() -> verify(informerEventSource, never()).propagateEvent(any(), any())); } private void expectPropagateEvent(Deployment newResourceVersion) { await() .atMost(Duration.ofSeconds(1)) .untilAsserted( - () -> verify(informerEventSource, times(1)).propagateEvent(newResourceVersion)); + () -> + verify(informerEventSource, times(1)) + .propagateEvent(eq(newResourceVersion), any())); } private void expectHandleUpdateEvent(int newResourceVersion, int oldResourceVersion) { From 04b809a47e4e53005f65cadac7b3ae173a1710df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 17 Jun 2026 14:35:20 +0200 Subject: [PATCH 06/21] fix test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../secondarytoprimaryreferencechange/TargetReconciler.java | 1 - 1 file changed, 1 deletion(-) diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java index 8668695bb4..fcc0eab68c 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java @@ -48,7 +48,6 @@ public List> prepareEventSources( InformerEventSourceConfiguration.from( ConfigCustomResource.class, TargetCustomResource.class) .withSecondaryToPrimaryMapper(new ConfigToTargetMapper()) - .withPrimaryToSecondaryMapper(p -> null) .build(); var ies = new InformerEventSource<>(configuration, context); From c289cc80b776a591abec6527389aff2e687e6b9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 17 Jun 2026 14:23:03 +0200 Subject: [PATCH 07/21] feat: strcit secondary to primary index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../DefaultPrimaryToSecondaryIndex.java | 17 +++++++++++++++-- .../source/informer/InformerEventSource.java | 12 ++++++------ .../informer/NOOPPrimaryToSecondaryIndex.java | 2 +- .../informer/PrimaryToSecondaryIndex.java | 2 +- .../TargetReconciler.java | 4 +--- 5 files changed, 24 insertions(+), 13 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java index c9a99aadb0..93e3b3d9e6 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java @@ -32,15 +32,28 @@ public DefaultPrimaryToSecondaryIndex(SecondaryToPrimaryMapper secondaryToPri } @Override - public synchronized void onAddOrUpdate(R resource) { + public synchronized void onAddOrUpdate(R resource, R oldResource) { Set primaryResources = - secondaryToPrimaryMapper.toPrimaryResourceIDs(resource, null); + secondaryToPrimaryMapper.toPrimaryResourceIDs(resource, oldResource); + + var secondaryId = ResourceID.fromResource(resource); + primaryResources.forEach( primaryResource -> { var resourceSet = index.computeIfAbsent(primaryResource, pr -> ConcurrentHashMap.newKeySet()); resourceSet.add(ResourceID.fromResource(resource)); }); + + if (oldResource != null) { + var oldPrimaryResources = secondaryToPrimaryMapper.toPrimaryResourceIDs(oldResource, null); + oldPrimaryResources.removeAll(primaryResources); + oldPrimaryResources.forEach(p->index.computeIfPresent(p, + (id,currentSet)-> { + currentSet.remove(secondaryId); + return currentSet.isEmpty() ? null : currentSet; + })); + } } @Override diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java index 74853e0afe..72c10be47d 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java @@ -177,12 +177,12 @@ public synchronized void start() { super.start(); // this makes sure that on first reconciliation all resources are // present on the index - manager().list().forEach(primaryToSecondaryIndex::onAddOrUpdate); + manager().list().forEach(r->primaryToSecondaryIndex.onAddOrUpdate(r,null)); } @SuppressWarnings("unchecked") private synchronized void onAddOrUpdate(ResourceAction action, R newObject, R oldObject) { - primaryToSecondaryIndex.onAddOrUpdate(newObject); + primaryToSecondaryIndex.onAddOrUpdate(newObject,oldObject); var resourceID = ResourceID.fromResource(newObject); var resultEvent = temporaryResourceCache.onAddOrUpdateEvent(action, newObject, oldObject); @@ -249,16 +249,16 @@ public Set getSecondaryResources(P primary) { @Override public void handleRecentResourceUpdate( ResourceID resourceID, R resource, R previousVersionOfResource) { - handleRecentCreateOrUpdate(resource); + handleRecentCreateOrUpdate(resource,previousVersionOfResource); } @Override public void handleRecentResourceCreate(ResourceID resourceID, R resource) { - handleRecentCreateOrUpdate(resource); + handleRecentCreateOrUpdate(resource,null); } - private void handleRecentCreateOrUpdate(R newResource) { - primaryToSecondaryIndex.onAddOrUpdate(newResource); + private void handleRecentCreateOrUpdate(R newResource, R previousVersion) { + primaryToSecondaryIndex.onAddOrUpdate(newResource, previousVersion); temporaryResourceCache.putResource(newResource); } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/NOOPPrimaryToSecondaryIndex.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/NOOPPrimaryToSecondaryIndex.java index ce217a5543..31c3c58470 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/NOOPPrimaryToSecondaryIndex.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/NOOPPrimaryToSecondaryIndex.java @@ -33,7 +33,7 @@ public static NOOPPrimaryToSecondaryIndex getInstance private NOOPPrimaryToSecondaryIndex() {} @Override - public void onAddOrUpdate(R resource) { + public void onAddOrUpdate(R resource, R oldResource) { // empty method because of noop implementation } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndex.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndex.java index f88e481316..7f355964d9 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndex.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndex.java @@ -22,7 +22,7 @@ public interface PrimaryToSecondaryIndex { - void onAddOrUpdate(R resource); + void onAddOrUpdate(R resource, R oldResource); void onDelete(R resource); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java index fcc0eab68c..8207d00d00 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java @@ -62,9 +62,7 @@ public UpdateControl reconcile( // index does not remove the old association), so we also verify here that the config actually // references this target. This makes reconciliation robust regardless of how the event arrived. var value = - context.getSecondaryResources(ConfigCustomResource.class).stream() - .filter( - config -> target.getMetadata().getName().equals(config.getSpec().getTargetName())) + context.getSecondaryResource(ConfigCustomResource.class).stream() .map(config -> config.getSpec().getValue()) .findFirst() .orElse(DEFAULT_VALUE); From 696769c6c524dd4d609eaf5dae1570688463ba38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 17 Jun 2026 15:15:05 +0200 Subject: [PATCH 08/21] fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../DefaultPrimaryToSecondaryIndex.java | 33 ++++--- .../source/informer/InformerEventSource.java | 8 +- .../informer/InformerEventSourceTest.java | 4 +- .../informer/PrimaryToSecondaryIndexTest.java | 96 +++++++++++++++++-- 4 files changed, 117 insertions(+), 24 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java index 93e3b3d9e6..502f92ed55 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java @@ -33,8 +33,12 @@ public DefaultPrimaryToSecondaryIndex(SecondaryToPrimaryMapper secondaryToPri @Override public synchronized void onAddOrUpdate(R resource, R oldResource) { - Set primaryResources = - secondaryToPrimaryMapper.toPrimaryResourceIDs(resource, oldResource); + // The index reflects the *current* associations of the secondary, so it must use the + // single-argument mapper. The two-argument variant may legitimately return additional primaries + // (e.g. the previously referenced one) so that they get reconciled on a reference change; those + // extra primaries are an event-propagation concern and must not be recorded as current + // associations here. + Set primaryResources = secondaryToPrimaryMapper.toPrimaryResourceIDs(resource); var secondaryId = ResourceID.fromResource(resource); @@ -42,18 +46,23 @@ public synchronized void onAddOrUpdate(R resource, R oldResource) { primaryResource -> { var resourceSet = index.computeIfAbsent(primaryResource, pr -> ConcurrentHashMap.newKeySet()); - resourceSet.add(ResourceID.fromResource(resource)); + resourceSet.add(secondaryId); }); - if (oldResource != null) { - var oldPrimaryResources = secondaryToPrimaryMapper.toPrimaryResourceIDs(oldResource, null); - oldPrimaryResources.removeAll(primaryResources); - oldPrimaryResources.forEach(p->index.computeIfPresent(p, - (id,currentSet)-> { - currentSet.remove(secondaryId); - return currentSet.isEmpty() ? null : currentSet; - })); - } + if (oldResource != null) { + // copy into a mutable set: mappers may return an immutable Set (e.g. Set.of(...)) + var obsoletePrimaries = + new HashSet<>(secondaryToPrimaryMapper.toPrimaryResourceIDs(oldResource, null)); + obsoletePrimaries.removeAll(primaryResources); + obsoletePrimaries.forEach( + p -> + index.computeIfPresent( + p, + (id, currentSet) -> { + currentSet.remove(secondaryId); + return currentSet.isEmpty() ? null : currentSet; + })); + } } @Override diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java index 72c10be47d..fb432f31e7 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java @@ -177,12 +177,12 @@ public synchronized void start() { super.start(); // this makes sure that on first reconciliation all resources are // present on the index - manager().list().forEach(r->primaryToSecondaryIndex.onAddOrUpdate(r,null)); + manager().list().forEach(r -> primaryToSecondaryIndex.onAddOrUpdate(r, null)); } @SuppressWarnings("unchecked") private synchronized void onAddOrUpdate(ResourceAction action, R newObject, R oldObject) { - primaryToSecondaryIndex.onAddOrUpdate(newObject,oldObject); + primaryToSecondaryIndex.onAddOrUpdate(newObject, oldObject); var resourceID = ResourceID.fromResource(newObject); var resultEvent = temporaryResourceCache.onAddOrUpdateEvent(action, newObject, oldObject); @@ -249,12 +249,12 @@ public Set getSecondaryResources(P primary) { @Override public void handleRecentResourceUpdate( ResourceID resourceID, R resource, R previousVersionOfResource) { - handleRecentCreateOrUpdate(resource,previousVersionOfResource); + handleRecentCreateOrUpdate(resource, previousVersionOfResource); } @Override public void handleRecentResourceCreate(ResourceID resourceID, R resource) { - handleRecentCreateOrUpdate(resource,null); + handleRecentCreateOrUpdate(resource, null); } private void handleRecentCreateOrUpdate(R newResource, R previousVersion) { diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java index 6ef19acd78..f8504babe5 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java @@ -659,7 +659,7 @@ void handleEventUpdatesIndexWhenDeletePropagatesFromTempCache() throws Exception informerEventSource.handleEvent(ResourceAction.DELETED, resource, null, false); verify(indexMock, times(1)).onDelete(resource); - verify(indexMock, never()).onAddOrUpdate(any()); + verify(indexMock, never()).onAddOrUpdate(any(), any()); verify(eventHandlerMock, times(1)).handleEvent(any()); } @@ -673,7 +673,7 @@ void handleEventDoesNotTouchIndexForNonDeleteAction() throws Exception { ResourceAction.UPDATED, testDeployment(), testDeployment(), null); verify(indexMock, never()).onDelete(any()); - verify(indexMock, never()).onAddOrUpdate(any()); + verify(indexMock, never()).onAddOrUpdate(any(), any()); verify(eventHandlerMock, times(1)).handleEvent(any()); } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndexTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndexTest.java index 9503085a03..4d2451b9f4 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndexTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndexTest.java @@ -15,6 +15,7 @@ */ package io.javaoperatorsdk.operator.processing.event.source.informer; +import java.util.Map; import java.util.Set; import org.junit.jupiter.api.BeforeEach; @@ -27,6 +28,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -46,8 +48,12 @@ class PrimaryToSecondaryIndexTest { @BeforeEach void setup() { - when(secondaryToPrimaryMapperMock.toPrimaryResourceIDs(any(), any())) + when(secondaryToPrimaryMapperMock.toPrimaryResourceIDs(any())) .thenReturn(Set.of(primaryID1, primaryID2)); + // mirror the real default: the two-argument variant delegates to the single-argument one, so + // tests only need to stub the single-argument method + when(secondaryToPrimaryMapperMock.toPrimaryResourceIDs(any(), any())) + .thenAnswer(inv -> secondaryToPrimaryMapperMock.toPrimaryResourceIDs(inv.getArgument(0))); } @Test @@ -58,7 +64,7 @@ void returnsEmptySetOnEmptyIndex() { @Test void indexesNewResources() { - primaryToSecondaryIndex.onAddOrUpdate(secondary1); + primaryToSecondaryIndex.onAddOrUpdate(secondary1, null); var secondaryResources1 = primaryToSecondaryIndex.getSecondaryResources(primaryID1); var secondaryResources2 = primaryToSecondaryIndex.getSecondaryResources(primaryID2); @@ -69,8 +75,8 @@ void indexesNewResources() { @Test void indexesAdditionalResources() { - primaryToSecondaryIndex.onAddOrUpdate(secondary1); - primaryToSecondaryIndex.onAddOrUpdate(secondary2); + primaryToSecondaryIndex.onAddOrUpdate(secondary1, null); + primaryToSecondaryIndex.onAddOrUpdate(secondary2, null); var secondaryResources1 = primaryToSecondaryIndex.getSecondaryResources(primaryID1); var secondaryResources2 = primaryToSecondaryIndex.getSecondaryResources(primaryID2); @@ -83,8 +89,8 @@ void indexesAdditionalResources() { @Test void removingResourceFromIndex() { - primaryToSecondaryIndex.onAddOrUpdate(secondary1); - primaryToSecondaryIndex.onAddOrUpdate(secondary2); + primaryToSecondaryIndex.onAddOrUpdate(secondary1, null); + primaryToSecondaryIndex.onAddOrUpdate(secondary2, null); primaryToSecondaryIndex.onDelete(secondary1); var secondaryResources1 = primaryToSecondaryIndex.getSecondaryResources(primaryID1); @@ -102,6 +108,74 @@ void removingResourceFromIndex() { assertThat(secondaryResources2).isEmpty(); } + @Test + void updateRemovesObsoletePrimaryWhenReferenceNarrows() { + // initial version references both primaries (default stub) + primaryToSecondaryIndex.onAddOrUpdate(secondary1, null); + + // updated version references only primaryID1 + var updated = updatedVersionOf("secondary1"); + when(secondaryToPrimaryMapperMock.toPrimaryResourceIDs(eq(updated))) + .thenReturn(Set.of(primaryID1)); + primaryToSecondaryIndex.onAddOrUpdate(updated, secondary1); + + assertThat(primaryToSecondaryIndex.getSecondaryResources(primaryID1)) + .containsOnly(ResourceID.fromResource(secondary1)); + // primaryID2 is no longer referenced, so its (now empty) entry is removed + assertThat(primaryToSecondaryIndex.getSecondaryResources(primaryID2)).isEmpty(); + } + + @Test + void updateMovesSecondaryBetweenPrimaries() { + // initial version references only primaryID1 + when(secondaryToPrimaryMapperMock.toPrimaryResourceIDs(eq(secondary1))) + .thenReturn(Set.of(primaryID1)); + primaryToSecondaryIndex.onAddOrUpdate(secondary1, null); + + // updated version moves the reference to primaryID2 + var updated = updatedVersionOf("secondary1"); + when(secondaryToPrimaryMapperMock.toPrimaryResourceIDs(eq(updated))) + .thenReturn(Set.of(primaryID2)); + primaryToSecondaryIndex.onAddOrUpdate(updated, secondary1); + + assertThat(primaryToSecondaryIndex.getSecondaryResources(primaryID1)).isEmpty(); + assertThat(primaryToSecondaryIndex.getSecondaryResources(primaryID2)) + .containsOnly(ResourceID.fromResource(secondary1)); + } + + @Test + void updateOnlyRemovesUpdatedSecondaryFromObsoletePrimary() { + // two secondaries, each referencing both primaries (default stub) + primaryToSecondaryIndex.onAddOrUpdate(secondary1, null); + primaryToSecondaryIndex.onAddOrUpdate(secondary2, null); + + // secondary1 stops referencing primaryID2 + var updated = updatedVersionOf("secondary1"); + when(secondaryToPrimaryMapperMock.toPrimaryResourceIDs(eq(updated))) + .thenReturn(Set.of(primaryID1)); + primaryToSecondaryIndex.onAddOrUpdate(updated, secondary1); + + assertThat(primaryToSecondaryIndex.getSecondaryResources(primaryID1)) + .containsOnly(ResourceID.fromResource(secondary1), ResourceID.fromResource(secondary2)); + // primaryID2 is still referenced by secondary2, so only secondary1 is removed from it + assertThat(primaryToSecondaryIndex.getSecondaryResources(primaryID2)) + .containsOnly(ResourceID.fromResource(secondary2)); + } + + @Test + void updateKeepsIndexUnchangedWhenReferencedPrimariesDoNotChange() { + primaryToSecondaryIndex.onAddOrUpdate(secondary1, null); + + // updated version still references both primaries (default stub applies to it as well) + var updated = updatedVersionOf("secondary1"); + primaryToSecondaryIndex.onAddOrUpdate(updated, secondary1); + + assertThat(primaryToSecondaryIndex.getSecondaryResources(primaryID1)) + .containsOnly(ResourceID.fromResource(secondary1)); + assertThat(primaryToSecondaryIndex.getSecondaryResources(primaryID2)) + .containsOnly(ResourceID.fromResource(secondary1)); + } + ConfigMap secondary(String name) { ConfigMap configMap = new ConfigMap(); configMap.setMetadata(new ObjectMeta()); @@ -109,4 +183,14 @@ ConfigMap secondary(String name) { configMap.getMetadata().setNamespace("default"); return configMap; } + + /** + * Returns a new version of a secondary with the same {@link ResourceID} but a different content, + * so it represents an updated resource that the mapper mock can be stubbed for independently. + */ + ConfigMap updatedVersionOf(String name) { + ConfigMap configMap = secondary(name); + configMap.getMetadata().setLabels(Map.of("version", "updated")); + return configMap; + } } From 122832468914aa1c7b2c1a029fdc46b76610d88f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 17 Jun 2026 15:18:31 +0200 Subject: [PATCH 09/21] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../TargetReconciler.java | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java index 8207d00d00..51cd0c3e73 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java @@ -17,6 +17,7 @@ import java.util.List; +import io.javaoperatorsdk.annotation.Sample; import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; @@ -26,15 +27,18 @@ import io.javaoperatorsdk.operator.processing.event.source.EventSource; import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; -/** - * Reconciles {@link TargetCustomResource}s. The desired status value comes from a {@link - * ConfigCustomResource} that references the target via {@code spec.targetName}; if no config - * references the target, {@link #DEFAULT_VALUE} is used. - * - *

{@link ConfigCustomResource}s are watched as secondary resources through an {@link - * InformerEventSource} configured with {@link ConfigToTargetMapper}, so changes to a config trigger - * reconciliation of the referenced target(s). - */ +@Sample( + tldr = "Reconciling a Primary Driven by a Referencing Secondary Custom Resource", + description = + """ + A configuration custom resource (the secondary) references a target custom resource (the \ + primary) through a spec field and acts as its input. This reconciler watches those config \ + resources with an InformerEventSource and, on each reconciliation, sets the target's value \ + from the config that currently references it, falling back to a default when none does. A \ + SecondaryToPrimaryMapper that overrides the two-argument variant ensures that when a \ + config's reference moves from one target to another, both the previously and the newly \ + referenced target are reconciled. + """) @ControllerConfiguration public class TargetReconciler implements Reconciler { @@ -58,13 +62,13 @@ public List> prepareEventSources( public UpdateControl reconcile( TargetCustomResource target, Context context) { - // There may be a stale secondary mapping after a reference change (the primary-to-secondary - // index does not remove the old association), so we also verify here that the config actually - // references this target. This makes reconciliation robust regardless of how the event arrived. + // The framework keeps the primary-to-secondary index up to date on reference changes, so a + // config is only associated with the target it currently references. We take the value from + // the referencing config, or fall back to the default when none references this target. var value = - context.getSecondaryResource(ConfigCustomResource.class).stream() + context + .getSecondaryResource(ConfigCustomResource.class) .map(config -> config.getSpec().getValue()) - .findFirst() .orElse(DEFAULT_VALUE); target.setStatus(new TargetStatus().setValue(value)); From 53ce6c6902b96f6894577563c423a18cd532373c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 17 Jun 2026 15:23:18 +0200 Subject: [PATCH 10/21] fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../event/source/informer/DefaultPrimaryToSecondaryIndex.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java index 502f92ed55..abc18b2271 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java @@ -38,7 +38,8 @@ public synchronized void onAddOrUpdate(R resource, R oldResource) { // (e.g. the previously referenced one) so that they get reconciled on a reference change; those // extra primaries are an event-propagation concern and must not be recorded as current // associations here. - Set primaryResources = secondaryToPrimaryMapper.toPrimaryResourceIDs(resource); + Set primaryResources = + secondaryToPrimaryMapper.toPrimaryResourceIDs(resource, oldResource); var secondaryId = ResourceID.fromResource(resource); From 5d80f99f5ff0c5ab9e39fa45217a29490635ba5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 17 Jun 2026 15:42:15 +0200 Subject: [PATCH 11/21] fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../DefaultPrimaryToSecondaryIndex.java | 12 ++++------- .../source/informer/InformerEventSource.java | 21 +++++++++++-------- .../informer/NOOPPrimaryToSecondaryIndex.java | 8 ++++--- .../informer/PrimaryToSecondaryIndex.java | 4 ++-- .../informer/InformerEventSourceTest.java | 7 +++++-- 5 files changed, 28 insertions(+), 24 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java index abc18b2271..fe80c9b37c 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java @@ -32,12 +32,7 @@ public DefaultPrimaryToSecondaryIndex(SecondaryToPrimaryMapper secondaryToPri } @Override - public synchronized void onAddOrUpdate(R resource, R oldResource) { - // The index reflects the *current* associations of the secondary, so it must use the - // single-argument mapper. The two-argument variant may legitimately return additional primaries - // (e.g. the previously referenced one) so that they get reconciled on a reference change; those - // extra primaries are an event-propagation concern and must not be recorded as current - // associations here. + public synchronized Set onAddOrUpdate(R resource, R oldResource) { Set primaryResources = secondaryToPrimaryMapper.toPrimaryResourceIDs(resource, oldResource); @@ -51,7 +46,6 @@ public synchronized void onAddOrUpdate(R resource, R oldResource) { }); if (oldResource != null) { - // copy into a mutable set: mappers may return an immutable Set (e.g. Set.of(...)) var obsoletePrimaries = new HashSet<>(secondaryToPrimaryMapper.toPrimaryResourceIDs(oldResource, null)); obsoletePrimaries.removeAll(primaryResources); @@ -64,10 +58,11 @@ public synchronized void onAddOrUpdate(R resource, R oldResource) { return currentSet.isEmpty() ? null : currentSet; })); } + return primaryResources; } @Override - public synchronized void onDelete(R resource) { + public synchronized Set onDelete(R resource) { Set primaryResources = secondaryToPrimaryMapper.toPrimaryResourceIDs(resource, null); primaryResources.forEach( @@ -83,6 +78,7 @@ public synchronized void onDelete(R resource) { } } }); + return primaryResources; } @Override diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java index fb432f31e7..7f3d97c047 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java @@ -127,10 +127,10 @@ public synchronized void onDelete(R resource, boolean deletedFinalStateUnknown) if (resultEvent.isEmpty()) { return; } - primaryToSecondaryIndex.onDelete(resource); + var primaryIds = primaryToSecondaryIndex.onDelete(resource); if (eventAcceptedByFilter( ResourceAction.DELETED, resource, null, deletedFinalStateUnknown)) { - propagateEvent(resource, null); + propagateEvent(resource, null, primaryIds); } }); } @@ -144,11 +144,12 @@ protected void handleEvent( // onAdd/onUpdate/onDelete watch paths. The index is updated for DELETED regardless of the // filter outcome — the resource is really gone, so leaving a tombstone in the index would // make getSecondaryResources keep returning a stale entry. + Set primaryIds = null; if (action == ResourceAction.DELETED) { log.debug( "handleEvent: removing from primaryToSecondaryIndex. id={}", ResourceID.fromResource(resource)); - primaryToSecondaryIndex.onDelete(resource); + primaryIds = primaryToSecondaryIndex.onDelete(resource); } if (!eventAcceptedByFilter(action, resource, oldResource, deletedFinalStateUnknown)) { if (log.isDebugEnabled()) { @@ -166,7 +167,7 @@ protected void handleEvent( action, resource.getMetadata().getResourceVersion()); } - propagateEvent(resource, oldResource); + propagateEvent(resource, oldResource, primaryIds); } @Override @@ -182,7 +183,7 @@ public synchronized void start() { @SuppressWarnings("unchecked") private synchronized void onAddOrUpdate(ResourceAction action, R newObject, R oldObject) { - primaryToSecondaryIndex.onAddOrUpdate(newObject, oldObject); + var primaryIds = primaryToSecondaryIndex.onAddOrUpdate(newObject, oldObject); var resourceID = ResourceID.fromResource(newObject); var resultEvent = temporaryResourceCache.onAddOrUpdateEvent(action, newObject, oldObject); @@ -194,15 +195,17 @@ private synchronized void onAddOrUpdate(ResourceAction action, R newObject, R ol "Propagating event for {}, resource with same version not result of a our update.", action); var event = resultEvent.get(); - propagateEvent((R) event.getResource().orElseThrow(), oldObject); + propagateEvent((R) event.getResource().orElseThrow(), oldObject, primaryIds); } else { log.debug("Event filtered out for operation: {}, resourceID: {}", action, resourceID); } } - void propagateEvent(R resource, R oldResource) { - var primaryResourceIdSet = - configuration().getSecondaryToPrimaryMapper().toPrimaryResourceIDs(resource, oldResource); + void propagateEvent(R resource, R oldResource, Set primaryResourceIdSet) { + if (primaryResourceIdSet == null) { + primaryResourceIdSet = + configuration().getSecondaryToPrimaryMapper().toPrimaryResourceIDs(resource, oldResource); + } if (primaryResourceIdSet.isEmpty()) { return; } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/NOOPPrimaryToSecondaryIndex.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/NOOPPrimaryToSecondaryIndex.java index 31c3c58470..4e2490273a 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/NOOPPrimaryToSecondaryIndex.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/NOOPPrimaryToSecondaryIndex.java @@ -15,6 +15,7 @@ */ package io.javaoperatorsdk.operator.processing.event.source.informer; +import java.util.Collections; import java.util.Set; import io.fabric8.kubernetes.api.model.HasMetadata; @@ -33,13 +34,14 @@ public static NOOPPrimaryToSecondaryIndex getInstance private NOOPPrimaryToSecondaryIndex() {} @Override - public void onAddOrUpdate(R resource, R oldResource) { - // empty method because of noop implementation + public Set onAddOrUpdate(R resource, R oldResource) { + return Collections.emptySet(); } @Override - public void onDelete(R resource) { + public Set onDelete(R resource) { // empty method because of noop implementation + return Collections.emptySet(); } @Override diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndex.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndex.java index 7f355964d9..65fd692b25 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndex.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndex.java @@ -22,9 +22,9 @@ public interface PrimaryToSecondaryIndex { - void onAddOrUpdate(R resource, R oldResource); + Set onAddOrUpdate(R resource, R oldResource); - void onDelete(R resource); + Set onDelete(R resource); Set getSecondaryResources(ResourceID primary); } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java index f8504babe5..8c5ed75f9e 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java @@ -655,6 +655,8 @@ void handleEventUpdatesIndexWhenDeletePropagatesFromTempCache() throws Exception // and getSecondaryResources keeps returning a tombstone. var indexMock = injectIndexMock(); var resource = testDeployment(); + // onDelete now returns the primaries to reconcile; propagateEvent uses that set directly + when(indexMock.onDelete(resource)).thenReturn(Set.of(ResourceID.fromResource(resource))); informerEventSource.handleEvent(ResourceAction.DELETED, resource, null, false); @@ -745,7 +747,8 @@ private void assertNoEventProduced() { await() .pollDelay(Duration.ofMillis(70)) .timeout(Duration.ofMillis(150)) - .untilAsserted(() -> verify(informerEventSource, never()).propagateEvent(any(), any())); + .untilAsserted( + () -> verify(informerEventSource, never()).propagateEvent(any(), any(), any())); } private void expectPropagateEvent(Deployment newResourceVersion) { @@ -754,7 +757,7 @@ private void expectPropagateEvent(Deployment newResourceVersion) { .untilAsserted( () -> verify(informerEventSource, times(1)) - .propagateEvent(eq(newResourceVersion), any())); + .propagateEvent(eq(newResourceVersion), any(), any())); } private void expectHandleUpdateEvent(int newResourceVersion, int oldResourceVersion) { From bd8c20422dceaea4056845ccbdd4363b773af049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 17 Jun 2026 16:42:34 +0200 Subject: [PATCH 12/21] refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../source/SecondaryToPrimaryMapper.java | 40 ++----------------- .../DefaultPrimaryToSecondaryIndex.java | 32 ++++++++------- .../source/informer/InformerEventSource.java | 10 ++++- .../informer/InformerEventSourceTest.java | 2 +- .../event/source/informer/MappersTest.java | 9 ++--- .../informer/PrimaryToSecondaryIndexTest.java | 4 -- .../ConfigToTargetMapper.java | 13 ------ 7 files changed, 34 insertions(+), 76 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java index 8424c423d5..d91572bef8 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java @@ -29,46 +29,12 @@ public interface SecondaryToPrimaryMapper { /** * Maps a secondary resource to the set of primary resources that should be reconciled in - * response. Implementing this single-argument form is sufficient for the vast majority of use - * cases — prefer it unless you specifically need access to the previous version of the secondary - * resource (see {@link #toPrimaryResourceIDs(Object, Object)}). + * response. * * @param resource the secondary resource for which an event was received * @return set of primary resource IDs to enqueue for reconciliation; an empty set means the event - * is irrelevant and no reconciliation is triggered + * is irrelevant and no reconciliation is triggered. This is called also the old and the new + * resources, in case of an update. */ Set toPrimaryResourceIDs(R resource); - - /** - * Variant invoked by the framework for every secondary resource event, providing both the new and - * the previous version of the resource (when available). The default implementation simply - * delegates to {@link #toPrimaryResourceIDs(Object)} and ignores {@code oldResource}, so existing - * mappers keep working unchanged. - * - *

Override this method only for edge cases where the set of primary resources to reconcile - * depends on what changed between the old and the new version of the secondary resource (for - * example, when a reference held by the secondary resource has moved from one primary to another - * and both primaries need to be reconciled). - * - *

Use with caution. {@code oldResource} is sourced from the informer cache - * and is therefore only populated for genuine update events observed while the controller is - * already running. In particular, when the controller starts up, the cache is empty and the - * initial events received for resources that already existed in the cluster are delivered as adds - * with {@code oldResource == null} (even if those resources had been updated previously). {@code - * oldResource} is also {@code null} for delete events and for events triggered through the - * primary-to-secondary index. - * - *

Implementations must therefore handle a {@code null} {@code oldResource} gracefully and not - * rely on it being present for correctness — overriding this method is intended for edge cases - * only. Genericly speaking controller should also handle such change checking during - * reconciliation, so when controller starts and event is missed it is properly reconiled. - * - * @param newResource the current version of the secondary resource - * @param oldResource the previous version of the secondary resource, or {@code null} if not - * available (see above) - * @return set of primary resource IDs to enqueue for reconciliation - */ - default Set toPrimaryResourceIDs(R newResource, R oldResource) { - return toPrimaryResourceIDs(newResource); - } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java index fe80c9b37c..2ec8aa9372 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java @@ -33,8 +33,8 @@ public DefaultPrimaryToSecondaryIndex(SecondaryToPrimaryMapper secondaryToPri @Override public synchronized Set onAddOrUpdate(R resource, R oldResource) { - Set primaryResources = - secondaryToPrimaryMapper.toPrimaryResourceIDs(resource, oldResource); + + Set primaryResources = secondaryToPrimaryMapper.toPrimaryResourceIDs(resource); var secondaryId = ResourceID.fromResource(resource); @@ -47,24 +47,28 @@ public synchronized Set onAddOrUpdate(R resource, R oldResource) { if (oldResource != null) { var obsoletePrimaries = - new HashSet<>(secondaryToPrimaryMapper.toPrimaryResourceIDs(oldResource, null)); - obsoletePrimaries.removeAll(primaryResources); - obsoletePrimaries.forEach( - p -> - index.computeIfPresent( - p, - (id, currentSet) -> { - currentSet.remove(secondaryId); - return currentSet.isEmpty() ? null : currentSet; - })); + new HashSet<>(secondaryToPrimaryMapper.toPrimaryResourceIDs(oldResource)); + if (!primaryResources.containsAll(obsoletePrimaries)) { + var result = new HashSet<>(primaryResources); + obsoletePrimaries.removeAll(primaryResources); + obsoletePrimaries.forEach( + p -> + index.computeIfPresent( + p, + (id, currentSet) -> { + currentSet.remove(secondaryId); + return currentSet.isEmpty() ? null : currentSet; + })); + result.addAll(obsoletePrimaries); + return result; + } } return primaryResources; } @Override public synchronized Set onDelete(R resource) { - Set primaryResources = - secondaryToPrimaryMapper.toPrimaryResourceIDs(resource, null); + Set primaryResources = secondaryToPrimaryMapper.toPrimaryResourceIDs(resource); primaryResources.forEach( primaryResource -> { var secondaryResources = index.get(primaryResource); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java index 7f3d97c047..719be47747 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java @@ -15,6 +15,7 @@ */ package io.javaoperatorsdk.operator.processing.event.source.informer; +import java.util.HashSet; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -203,8 +204,13 @@ private synchronized void onAddOrUpdate(ResourceAction action, R newObject, R ol void propagateEvent(R resource, R oldResource, Set primaryResourceIdSet) { if (primaryResourceIdSet == null) { - primaryResourceIdSet = - configuration().getSecondaryToPrimaryMapper().toPrimaryResourceIDs(resource, oldResource); + primaryResourceIdSet = new HashSet<>(); + primaryResourceIdSet.addAll( + configuration().getSecondaryToPrimaryMapper().toPrimaryResourceIDs(resource)); + if (oldResource != null) { + primaryResourceIdSet.addAll( + configuration().getSecondaryToPrimaryMapper().toPrimaryResourceIDs(oldResource)); + } } if (primaryResourceIdSet.isEmpty()) { return; diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java index 8c5ed75f9e..3d9e8cbc1d 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java @@ -85,7 +85,7 @@ void setup() { SecondaryToPrimaryMapper secondaryToPrimaryMapper = mock(SecondaryToPrimaryMapper.class); when(informerEventSourceConfiguration.getSecondaryToPrimaryMapper()) .thenReturn(secondaryToPrimaryMapper); - when(secondaryToPrimaryMapper.toPrimaryResourceIDs(any(), any())) + when(secondaryToPrimaryMapper.toPrimaryResourceIDs(any())) .thenReturn(Set.of(ResourceID.fromResource(testDeployment()))); when(informerEventSourceConfiguration.getInformerConfig()).thenReturn(informerConfig); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/MappersTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/MappersTest.java index 8b4a9fbff7..8d08c4e8a0 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/MappersTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/MappersTest.java @@ -40,8 +40,7 @@ void secondaryToPrimaryMapperFromOwnerReference() { var secondary = getConfigMap(primary); secondary.addOwnerReference(primary); - var res = - Mappers.fromOwnerReferences(TestCustomResource.class).toPrimaryResourceIDs(secondary, null); + var res = Mappers.fromOwnerReferences(TestCustomResource.class).toPrimaryResourceIDs(secondary); assertThat(res).contains(ResourceID.fromResource(primary)); } @@ -66,7 +65,7 @@ void secondaryToPrimaryMapperFromOwnerReferenceWhereGroupIdIsEmpty() { .build(); secondary.addOwnerReference(primary); - var res = Mappers.fromOwnerReferences(ConfigMap.class).toPrimaryResourceIDs(secondary, null); + var res = Mappers.fromOwnerReferences(ConfigMap.class).toPrimaryResourceIDs(secondary); assertThat(res).contains(ResourceID.fromResource(primary)); } @@ -80,7 +79,7 @@ void secondaryToPrimaryMapperFromOwnerReferenceFiltersByType() { var res = Mappers.fromOwnerReferences(TestCustomResourceOtherV1.class) - .toPrimaryResourceIDs(secondary, null); + .toPrimaryResourceIDs(secondary); assertThat(res).isEmpty(); } @@ -104,7 +103,7 @@ void fromOwnerReferenceIgnoresVersionFromApiVersion() { HasMetadata.getGroup(TestCustomResource.class) + "/v2", HasMetadata.getKind(TestCustomResource.class), false) - .toPrimaryResourceIDs(secondary, null); + .toPrimaryResourceIDs(secondary); assertThat(res).contains(ResourceID.fromResource(primary)); } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndexTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndexTest.java index 4d2451b9f4..d298bce8bb 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndexTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndexTest.java @@ -50,10 +50,6 @@ class PrimaryToSecondaryIndexTest { void setup() { when(secondaryToPrimaryMapperMock.toPrimaryResourceIDs(any())) .thenReturn(Set.of(primaryID1, primaryID2)); - // mirror the real default: the two-argument variant delegates to the single-argument one, so - // tests only need to stub the single-argument method - when(secondaryToPrimaryMapperMock.toPrimaryResourceIDs(any(), any())) - .thenAnswer(inv -> secondaryToPrimaryMapperMock.toPrimaryResourceIDs(inv.getArgument(0))); } @Test diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigToTargetMapper.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigToTargetMapper.java index 342e85fe7f..f2e9faf4a7 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigToTargetMapper.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigToTargetMapper.java @@ -15,7 +15,6 @@ */ package io.javaoperatorsdk.operator.baseapi.secondarytoprimaryreferencechange; -import java.util.HashSet; import java.util.Set; import io.javaoperatorsdk.operator.processing.event.ResourceID; @@ -42,16 +41,4 @@ public Set toPrimaryResourceIDs(ConfigCustomResource config) { } return Set.of(new ResourceID(targetName, config.getMetadata().getNamespace())); } - - @Override - public Set toPrimaryResourceIDs( - ConfigCustomResource newConfig, ConfigCustomResource oldConfig) { - var result = new HashSet<>(toPrimaryResourceIDs(newConfig)); - // oldConfig is only populated for genuine update events while the controller is running; for - // adds, deletes and startup it is null and there is no previous reference to reconcile. - if (oldConfig != null) { - result.addAll(toPrimaryResourceIDs(oldConfig)); - } - return result; - } } From 200c9644ae4d928e47b2afdf68cbfaa82f436b32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 17 Jun 2026 16:49:41 +0200 Subject: [PATCH 13/21] integration test updated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../ConfigSpec.java | 17 +++++--- .../ConfigToTargetMapper.java | 26 ++++++----- .../SecondaryToPrimaryReferenceChangeIT.java | 43 +++++++++++-------- .../TargetReconciler.java | 17 ++++---- 4 files changed, 59 insertions(+), 44 deletions(-) diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigSpec.java index eacd6f7843..9d2e5139e6 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigSpec.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigSpec.java @@ -15,22 +15,25 @@ */ package io.javaoperatorsdk.operator.baseapi.secondarytoprimaryreferencechange; +import java.util.List; + public class ConfigSpec { /** - * Name of the {@link TargetCustomResource} (in the same namespace) this config provides input. + * Names of the {@link TargetCustomResource}s (in the same namespace) this config provides input + * for. A single config can reference multiple targets. */ - private String targetName; + private List targetNames; - /** Value to be applied to the referenced target's status. */ + /** Value to be applied to the referenced targets' status. */ private String value; - public String getTargetName() { - return targetName; + public List getTargetNames() { + return targetNames; } - public ConfigSpec setTargetName(String targetName) { - this.targetName = targetName; + public ConfigSpec setTargetNames(List targetNames) { + this.targetNames = targetNames; return this; } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigToTargetMapper.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigToTargetMapper.java index f2e9faf4a7..72930261de 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigToTargetMapper.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigToTargetMapper.java @@ -16,29 +16,33 @@ package io.javaoperatorsdk.operator.baseapi.secondarytoprimaryreferencechange; import java.util.Set; +import java.util.stream.Collectors; import io.javaoperatorsdk.operator.processing.event.ResourceID; import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper; /** - * Maps a {@link ConfigCustomResource} (secondary) to the {@link TargetCustomResource} (primary) it - * references via {@code spec.targetName}. + * Maps a {@link ConfigCustomResource} (secondary) to the {@link TargetCustomResource}s (primaries) + * it references via {@code spec.targetNames}. A config can reference multiple targets. * - *

The interesting case is handling a reference change: when {@code spec.targetName} is - * edited to point from one target to another, both targets must be reconciled — the previously - * referenced one so it can fall back to its default value, and the newly referenced one so it can - * pick up the config's value. The single-argument mapper only knows about the new reference, so it - * would only enqueue the new target, leaving the old target with a stale value. By overriding the - * two-argument variant we additionally enqueue the old target whenever the reference moved. + *

The mapper only reports the current references. When the referenced set changes — for + * example when a subset of the targets is replaced — the framework's primary-to-secondary index + * reconciles both the newly referenced targets and the ones that are no longer referenced, so a + * dropped target reverts to its default value. The mapper therefore does not need to know about the + * previous version of the resource. */ public class ConfigToTargetMapper implements SecondaryToPrimaryMapper { @Override public Set toPrimaryResourceIDs(ConfigCustomResource config) { - var targetName = config.getSpec().getTargetName(); - if (targetName == null || targetName.isBlank()) { + var targetNames = config.getSpec().getTargetNames(); + if (targetNames == null || targetNames.isEmpty()) { return Set.of(); } - return Set.of(new ResourceID(targetName, config.getMetadata().getNamespace())); + var namespace = config.getMetadata().getNamespace(); + return targetNames.stream() + .filter(name -> name != null && !name.isBlank()) + .map(name -> new ResourceID(name, namespace)) + .collect(Collectors.toSet()); } } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/SecondaryToPrimaryReferenceChangeIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/SecondaryToPrimaryReferenceChangeIT.java index 288e6e6651..37ade3a174 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/SecondaryToPrimaryReferenceChangeIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/SecondaryToPrimaryReferenceChangeIT.java @@ -16,6 +16,7 @@ package io.javaoperatorsdk.operator.baseapi.secondarytoprimaryreferencechange; import java.time.Duration; +import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -29,21 +30,22 @@ import static org.awaitility.Awaitility.await; @Sample( - tldr = "Handling a Secondary Resource Whose Reference to a Primary Changes", + tldr = "Handling a Secondary Resource Whose References to Primaries Change", description = """ - Demonstrates a configuration custom resource (the secondary) that references a target \ - custom resource (the primary) via a spec field and serves as input for it. The target is \ - reconciled so that, if a config references it, it takes the value from that config; \ - otherwise it falls back to a default. The test shows how to handle the config's reference \ - changing from one target to another: a SecondaryToPrimaryMapper that overrides the \ - two-argument variant enqueues both the previously referenced target (so it reverts to the \ - default) and the newly referenced one (so it picks up the value). + Demonstrates a configuration custom resource (the secondary) that references multiple \ + target custom resources (the primaries) via a spec field and serves as their input. Each \ + target is reconciled so that, if a config references it, it takes the value from that \ + config; otherwise it falls back to a default. The test shows how to handle a change of the \ + referenced set where only a subset changes: a target that is dropped from the references \ + reverts to the default, a target that stays keeps the value, and a newly referenced target \ + picks it up. """) class SecondaryToPrimaryReferenceChangeIT { static final String TARGET_A = "target-a"; static final String TARGET_B = "target-b"; + static final String TARGET_C = "target-c"; static final String CONFIG_NAME = "config"; static final String CONFIG_VALUE = "value-from-config"; @@ -55,25 +57,30 @@ class SecondaryToPrimaryReferenceChangeIT { .build(); @Test - void targetTakesValueFromReferencingConfigAndHandlesReferenceChange() { + void targetsTakeValueFromReferencingConfigAndHandleSubsetReferenceChange() { operator.create(target(TARGET_A)); operator.create(target(TARGET_B)); + operator.create(target(TARGET_C)); - // With no config, both targets fall back to the default value. + // With no config, all targets fall back to the default value. awaitTargetValue(TARGET_A, DEFAULT_VALUE); awaitTargetValue(TARGET_B, DEFAULT_VALUE); + awaitTargetValue(TARGET_C, DEFAULT_VALUE); - // A config referencing target A makes A take the config's value; B stays on the default. - var config = operator.create(config(TARGET_A)); + // A config referencing targets A and B makes both take the config's value; C stays default. + var config = operator.create(config(TARGET_A, TARGET_B)); awaitTargetValue(TARGET_A, CONFIG_VALUE); - awaitTargetValue(TARGET_B, DEFAULT_VALUE); + awaitTargetValue(TARGET_B, CONFIG_VALUE); + awaitTargetValue(TARGET_C, DEFAULT_VALUE); - // Moving the reference from A to B reconciles both: A reverts to the default and B picks it up. - config.getSpec().setTargetName(TARGET_B); + // Change a subset of the references: drop A, keep B, add C. A reverts to the default, B keeps + // the value, and C now picks it up. + config.getSpec().setTargetNames(List.of(TARGET_B, TARGET_C)); operator.replace(config); - awaitTargetValue(TARGET_B, CONFIG_VALUE); + awaitTargetValue(TARGET_C, CONFIG_VALUE); awaitTargetValue(TARGET_A, DEFAULT_VALUE); + awaitTargetValue(TARGET_B, CONFIG_VALUE); } private void awaitTargetValue(String name, String expectedValue) { @@ -93,10 +100,10 @@ private TargetCustomResource target(String name) { return target; } - private ConfigCustomResource config(String targetName) { + private ConfigCustomResource config(String... targetNames) { var config = new ConfigCustomResource(); config.setMetadata(new ObjectMetaBuilder().withName(CONFIG_NAME).build()); - config.setSpec(new ConfigSpec().setTargetName(targetName).setValue(CONFIG_VALUE)); + config.setSpec(new ConfigSpec().setTargetNames(List.of(targetNames)).setValue(CONFIG_VALUE)); return config; } } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java index 51cd0c3e73..ee8d11e9d4 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java @@ -28,16 +28,17 @@ import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; @Sample( - tldr = "Reconciling a Primary Driven by a Referencing Secondary Custom Resource", + tldr = "Reconciling Primaries Driven by a Referencing Secondary Custom Resource", description = """ - A configuration custom resource (the secondary) references a target custom resource (the \ - primary) through a spec field and acts as its input. This reconciler watches those config \ - resources with an InformerEventSource and, on each reconciliation, sets the target's value \ - from the config that currently references it, falling back to a default when none does. A \ - SecondaryToPrimaryMapper that overrides the two-argument variant ensures that when a \ - config's reference moves from one target to another, both the previously and the newly \ - referenced target are reconciled. + A configuration custom resource (the secondary) references one or more target custom \ + resources (the primaries) through a spec field and acts as their input. This reconciler \ + watches those config resources with an InformerEventSource and, on each reconciliation, \ + sets the target's value from the config that currently references it, falling back to a \ + default when none does. When a config's set of references changes — including when only a \ + subset of the referenced targets is replaced — the framework's primary-to-secondary index \ + reconciles both the newly referenced targets and the ones that are no longer referenced, so \ + a dropped target reverts to its default. """) @ControllerConfiguration public class TargetReconciler implements Reconciler { From 9a8aeedf7b217146c0b5546d3099d85652947916 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 17 Jun 2026 17:11:26 +0200 Subject: [PATCH 14/21] docs update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../content/en/docs/documentation/eventing.md | 25 ++++++------------- 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/docs/content/en/docs/documentation/eventing.md b/docs/content/en/docs/documentation/eventing.md index 107a8a7db2..d2a104737b 100644 --- a/docs/content/en/docs/documentation/eventing.md +++ b/docs/content/en/docs/documentation/eventing.md @@ -139,24 +139,13 @@ rare corner cases. Returning an empty set means that the mapper considered the s resource event as irrelevant and the SDK will thus not trigger a reconciliation of the primary resource in that situation. -`SecondaryToPrimaryMapper` exposes two methods: - -- `toPrimaryResourceIDs(R resource)` — the original mapper. Implementing it is sufficient for - the vast majority of use cases. -- `toPrimaryResourceIDs(R newResource, R oldResource)` — a variant that is the one actually - invoked by the SDK on every secondary event. Its default implementation delegates to the - single-argument method, so existing mappers keep working unchanged. - -Override the two-argument variant only in edge cases where the set of primary resources to -reconcile depends on what changed between the previous and the new version of the secondary -resource (e.g. a reference that moved from one primary to another, where both primaries need -to be reconciled). **Use it with caution:** `oldResource` is sourced from the informer cache and -is only populated for genuine update events observed while the controller is already running. -On controller startup the cache is empty, so the initial events received for resources that -already exist in the cluster are delivered as adds with `oldResource == null` — even if those -resources had been updated before the operator came up. `oldResource` is also `null` for delete -events and for events triggered through the primary-to-secondary index. Implementations must -therefore handle a `null` `oldResource` gracefully. +On an update event, the SDK calls `toPrimaryResourceIDs` for **both the old and the new version** +of the secondary resource. This way it can reconcile not only the primaries that the secondary +currently maps to, but also those it previously mapped to and no longer does. So when a reference +changes — including when only a subset of the referenced primaries changes — both the newly +referenced and the dropped primaries are reconciled, and a dropped primary can revert to its +default state. Because the mapper can be invoked for an older version of a resource, keep your +implementation a pure function of the resource passed to it. Adding a `SecondaryToPrimaryMapper` is typically sufficient when there is a one-to-many relationship between primary and secondary resources. The secondary resources can be mapped to its primary From 4ec2a753e5f0792e7e8bf017bb3dff4ffdc6d737 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 17 Jun 2026 17:21:14 +0200 Subject: [PATCH 15/21] Potential fix for pull request finding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Attila Mészáros --- .../ConfigCustomResource.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigCustomResource.java index 5216547c3e..58f739f19f 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigCustomResource.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigCustomResource.java @@ -23,8 +23,8 @@ import io.fabric8.kubernetes.model.annotation.Version; /** - * Secondary resource that references a {@link TargetCustomResource} via {@code spec.targetName} and - * serves as input for it. + * Secondary resource that references one or more {@link TargetCustomResource}s via + * {@code spec.targetNames} and serves as input for them. */ @Group("sample.javaoperatorsdk") @Version("v1") From e0fdd26f9ce4edde59fa42bfe69f31bd2ca11c68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 17 Jun 2026 17:33:30 +0200 Subject: [PATCH 16/21] format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../ConfigCustomResource.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigCustomResource.java index 58f739f19f..f598017c4c 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigCustomResource.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigCustomResource.java @@ -23,8 +23,8 @@ import io.fabric8.kubernetes.model.annotation.Version; /** - * Secondary resource that references one or more {@link TargetCustomResource}s via - * {@code spec.targetNames} and serves as input for them. + * Secondary resource that references one or more {@link TargetCustomResource}s via {@code + * spec.targetNames} and serves as input for them. */ @Group("sample.javaoperatorsdk") @Version("v1") From a9d6acfcc8315ab95b2cf5d0cee666b68166b2ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 17 Jun 2026 19:08:03 +0200 Subject: [PATCH 17/21] temp fix noop primary to secondary index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../event/source/informer/NOOPPrimaryToSecondaryIndex.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/NOOPPrimaryToSecondaryIndex.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/NOOPPrimaryToSecondaryIndex.java index 4e2490273a..b22b958fc2 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/NOOPPrimaryToSecondaryIndex.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/NOOPPrimaryToSecondaryIndex.java @@ -15,7 +15,6 @@ */ package io.javaoperatorsdk.operator.processing.event.source.informer; -import java.util.Collections; import java.util.Set; import io.fabric8.kubernetes.api.model.HasMetadata; @@ -35,13 +34,13 @@ private NOOPPrimaryToSecondaryIndex() {} @Override public Set onAddOrUpdate(R resource, R oldResource) { - return Collections.emptySet(); + return null; } @Override public Set onDelete(R resource) { // empty method because of noop implementation - return Collections.emptySet(); + return null; } @Override From a08970a07ee3d54010b4b23001389e08524da4d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 17 Jun 2026 19:42:36 +0200 Subject: [PATCH 18/21] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../controller/ControllerEventSource.java | 4 +- .../source/informer/InformerEventSource.java | 2 +- .../informer/ManagedInformerEventSource.java | 4 +- .../informer/TemporaryResourceCache.java | 3 +- .../controller/ControllerEventSourceTest.java | 40 +++++++++---------- .../informer/InformerEventSourceTest.java | 30 +++++++------- .../informer/TemporaryResourceCacheTest.java | 4 +- 7 files changed, 44 insertions(+), 43 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java index dfa94577f7..b4c5f63382 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java @@ -79,7 +79,7 @@ public synchronized void start() { } @Override - protected synchronized void handleEvent( + protected synchronized void handleSyntEvent( ResourceAction action, T resource, T oldResource, Boolean deletedFinalStateUnknown) { try { if (log.isDebugEnabled()) { @@ -152,7 +152,7 @@ private void handleOnAddOrUpdate( @SuppressWarnings("unchecked") private void handleEvent(ExtendedResourceEvent r) { - handleEvent( + handleSyntEvent( r.getAction(), (T) r.getResource().orElseThrow(), (T) r.getPreviousResource().orElse(null), diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java index 719be47747..b6873fee9e 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java @@ -137,7 +137,7 @@ public synchronized void onDelete(R resource, boolean deletedFinalStateUnknown) } @Override - protected void handleEvent( + protected void handleSyntEvent( ResourceAction action, R resource, R oldResource, Boolean deletedFinalStateUnknown) { // Called from ManagedInformerEventSource#eventFilteringUpdateAndCacheResource after the temp // cache decided to surface a (possibly synthesized) event. The user-level filters diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java index a9c6818565..1e7c7c174c 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java @@ -117,7 +117,7 @@ public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator< r.getResource() .map(rr -> rr.getMetadata().getResourceVersion()) .orElse("[not set]")); - handleEvent( + handleSyntEvent( r.getAction(), (R) r.getResource().orElseThrow(), (R) r.getPreviousResource().orElse(null), @@ -127,7 +127,7 @@ public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator< } } - protected abstract void handleEvent( + protected abstract void handleSyntEvent( ResourceAction action, R resource, R oldResource, Boolean deletedFinalStateUnknown); @SuppressWarnings("unchecked") diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java index 8879493a2a..7d847d8671 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java @@ -244,7 +244,8 @@ public synchronized void checkGhostResources() { log.debug("Removing ghost resource with ID: {}", e.getKey()); iterator.remove(); eventFilteringSupport.handleGhostResourceRemoval(e.getKey()); - managedInformerEventSource.handleEvent(ResourceAction.DELETED, e.getValue(), null, true); + managedInformerEventSource.handleSyntEvent( + ResourceAction.DELETED, e.getValue(), null, true); } } } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java index f8cb54f68e..315d8e8d30 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java @@ -75,10 +75,10 @@ void skipsEventHandlingIfGenerationNotIncreased() { TestCustomResource oldCustomResource = TestUtils.testCustomResource(); oldCustomResource.getMetadata().setFinalizers(List.of(FINALIZER)); - source.handleEvent(ResourceAction.UPDATED, customResource, oldCustomResource, null); + source.handleSyntEvent(ResourceAction.UPDATED, customResource, oldCustomResource, null); verify(eventHandler, times(1)).handleEvent(any()); - source.handleEvent(ResourceAction.UPDATED, customResource, customResource, null); + source.handleSyntEvent(ResourceAction.UPDATED, customResource, customResource, null); verify(eventHandler, times(1)).handleEvent(any()); } @@ -86,11 +86,11 @@ void skipsEventHandlingIfGenerationNotIncreased() { void dontSkipEventHandlingIfMarkedForDeletion() { TestCustomResource customResource1 = TestUtils.testCustomResource(); - source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleSyntEvent(ResourceAction.UPDATED, customResource1, customResource1, null); verify(eventHandler, times(1)).handleEvent(any()); customResource1.getMetadata().setDeletionTimestamp(LocalDateTime.now().toString()); - source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleSyntEvent(ResourceAction.UPDATED, customResource1, customResource1, null); verify(eventHandler, times(2)).handleEvent(any()); } @@ -98,11 +98,11 @@ void dontSkipEventHandlingIfMarkedForDeletion() { void normalExecutionIfGenerationChanges() { TestCustomResource customResource1 = TestUtils.testCustomResource(); - source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleSyntEvent(ResourceAction.UPDATED, customResource1, customResource1, null); verify(eventHandler, times(1)).handleEvent(any()); customResource1.getMetadata().setGeneration(2L); - source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleSyntEvent(ResourceAction.UPDATED, customResource1, customResource1, null); verify(eventHandler, times(2)).handleEvent(any()); } @@ -113,10 +113,10 @@ void handlesAllEventIfNotGenerationAware() { TestCustomResource customResource1 = TestUtils.testCustomResource(); - source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleSyntEvent(ResourceAction.UPDATED, customResource1, customResource1, null); verify(eventHandler, times(1)).handleEvent(any()); - source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleSyntEvent(ResourceAction.UPDATED, customResource1, customResource1, null); verify(eventHandler, times(2)).handleEvent(any()); } @@ -124,7 +124,7 @@ void handlesAllEventIfNotGenerationAware() { void eventWithNoGenerationProcessedIfNoFinalizer() { TestCustomResource customResource1 = TestUtils.testCustomResource(); - source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleSyntEvent(ResourceAction.UPDATED, customResource1, customResource1, null); verify(eventHandler, times(1)).handleEvent(any()); } @@ -133,7 +133,7 @@ void eventWithNoGenerationProcessedIfNoFinalizer() { void callsBroadcastsOnResourceEvents() { TestCustomResource customResource1 = TestUtils.testCustomResource(); - source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleSyntEvent(ResourceAction.UPDATED, customResource1, customResource1, null); verify(testController.getEventSourceManager(), times(1)) .broadcastOnResourceEvent( @@ -149,8 +149,8 @@ void filtersOutEventsOnAddAndUpdate() { source = new ControllerEventSource<>(new TestController(onAddFilter, onUpdatePredicate, null)); setUpSource(source, true, controllerConfig); - source.handleEvent(ResourceAction.ADDED, cr, null, null); - source.handleEvent(ResourceAction.UPDATED, cr, cr, null); + source.handleSyntEvent(ResourceAction.ADDED, cr, null, null); + source.handleSyntEvent(ResourceAction.UPDATED, cr, cr, null); verify(eventHandler, never()).handleEvent(any()); } @@ -162,9 +162,9 @@ void genericFilterFiltersOutAddUpdateAndDeleteEvents() { source = new ControllerEventSource<>(new TestController(null, null, res -> false)); setUpSource(source, true, controllerConfig); - source.handleEvent(ResourceAction.ADDED, cr, null, null); - source.handleEvent(ResourceAction.UPDATED, cr, cr, null); - source.handleEvent(ResourceAction.DELETED, cr, cr, true); + source.handleSyntEvent(ResourceAction.ADDED, cr, null, null); + source.handleSyntEvent(ResourceAction.UPDATED, cr, cr, null); + source.handleSyntEvent(ResourceAction.DELETED, cr, cr, true); verify(eventHandler, never()).handleEvent(any()); } @@ -196,7 +196,7 @@ void foreignUpdateDuringFilteringPropagatesAsUpdate() { source.onUpdate(testResourceWithVersion(2), testResourceWithVersion(3)); latch.countDown(); - await().untilAsserted(() -> expectHandleEvent(3, 2)); + await().untilAsserted(() -> expectHandleSyntEvent(3, 2)); } @Test @@ -215,7 +215,7 @@ void deleteEventDuringFilteringPropagatesAsDelete() { () -> { verify(eventHandler, atLeastOnce()).handleEvent(any()); verify(source, atLeastOnce()) - .handleEvent(eq(ResourceAction.DELETED), any(), any(), any()); + .handleSyntEvent(eq(ResourceAction.DELETED), any(), any(), any()); }); } @@ -231,13 +231,13 @@ void multipleForeignEventsDuringFilteringMergeIntoSingleEvent() { source.onUpdate(testResourceWithVersion(3), testResourceWithVersion(4)); latch.countDown(); - await().untilAsserted(() -> expectHandleEvent(4, 2)); + await().untilAsserted(() -> expectHandleSyntEvent(4, 2)); } - private void expectHandleEvent(int newResourceVersion, int oldResourceVersion) { + private void expectHandleSyntEvent(int newResourceVersion, int oldResourceVersion) { verify(eventHandler, times(1)).handleEvent(any()); verify(source, times(1)) - .handleEvent( + .handleSyntEvent( eq(ResourceAction.UPDATED), argThat(r -> ("" + newResourceVersion).equals(r.getMetadata().getResourceVersion())), argThat(r -> ("" + oldResourceVersion).equals(r.getMetadata().getResourceVersion())), diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java index 3d9e8cbc1d..b8eb250f92 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java @@ -648,7 +648,7 @@ void deleteEventDuringOwnUpdateIsPropagated() { } @Test - void handleEventUpdatesIndexWhenDeletePropagatesFromTempCache() throws Exception { + void handleSyntEventUpdatesIndexWhenDeletePropagatesFromTempCache() throws Exception { // handleEvent is invoked from ManagedInformerEventSource#eventFilteringUpdateAndCacheResource // only after the temp cache decided to surface the event. For a DELETE that means the resource // is really gone and the secondary→primary index must drop it; otherwise stale entries linger @@ -658,7 +658,7 @@ void handleEventUpdatesIndexWhenDeletePropagatesFromTempCache() throws Exception // onDelete now returns the primaries to reconcile; propagateEvent uses that set directly when(indexMock.onDelete(resource)).thenReturn(Set.of(ResourceID.fromResource(resource))); - informerEventSource.handleEvent(ResourceAction.DELETED, resource, null, false); + informerEventSource.handleSyntEvent(ResourceAction.DELETED, resource, null, false); verify(indexMock, times(1)).onDelete(resource); verify(indexMock, never()).onAddOrUpdate(any(), any()); @@ -666,12 +666,12 @@ void handleEventUpdatesIndexWhenDeletePropagatesFromTempCache() throws Exception } @Test - void handleEventDoesNotTouchIndexForNonDeleteAction() throws Exception { + void handleSyntEventDoesNotTouchIndexForNonDeleteAction() throws Exception { // The onAdd/onUpdate path maintains the index in onAddOrUpdate(); handleEvent must not // double-update it for non-DELETE actions, otherwise we'd index resources twice. var indexMock = injectIndexMock(); - informerEventSource.handleEvent( + informerEventSource.handleSyntEvent( ResourceAction.UPDATED, testDeployment(), testDeployment(), null); verify(indexMock, never()).onDelete(any()); @@ -680,7 +680,7 @@ void handleEventDoesNotTouchIndexForNonDeleteAction() throws Exception { } @Test - void handleEventRespectsOnDeleteFilter() throws Exception { + void handleSyntEventRespectsOnDeleteFilter() throws Exception { // The temp-cache pipeline must honor user-level filters: if onDeleteFilter rejects, the // synthesized DELETE must not be surfaced. The index, however, is still updated because the // resource is really gone — same semantics as the direct onDelete watch path. @@ -688,18 +688,18 @@ void handleEventRespectsOnDeleteFilter() throws Exception { informerEventSource.setOnDeleteFilter((r, b) -> false); var resource = testDeployment(); - informerEventSource.handleEvent(ResourceAction.DELETED, resource, null, false); + informerEventSource.handleSyntEvent(ResourceAction.DELETED, resource, null, false); verify(indexMock, times(1)).onDelete(resource); verify(eventHandlerMock, never()).handleEvent(any()); } @Test - void handleEventRespectsOnUpdateFilter() throws Exception { + void handleSyntEventRespectsOnUpdateFilter() throws Exception { var indexMock = injectIndexMock(); informerEventSource.setOnUpdateFilter((n, o) -> false); - informerEventSource.handleEvent( + informerEventSource.handleSyntEvent( ResourceAction.UPDATED, testDeployment(), testDeployment(), null); verify(indexMock, never()).onDelete(any()); @@ -707,18 +707,18 @@ void handleEventRespectsOnUpdateFilter() throws Exception { } @Test - void handleEventRespectsOnAddFilter() throws Exception { + void handleSyntEventRespectsOnAddFilter() throws Exception { var indexMock = injectIndexMock(); informerEventSource.setOnAddFilter(r -> false); - informerEventSource.handleEvent(ResourceAction.ADDED, testDeployment(), null, null); + informerEventSource.handleSyntEvent(ResourceAction.ADDED, testDeployment(), null, null); verify(indexMock, never()).onDelete(any()); verify(eventHandlerMock, never()).handleEvent(any()); } @Test - void handleEventRespectsGenericFilter() throws Exception { + void handleSyntEventRespectsGenericFilter() throws Exception { // The generic filter applies regardless of action and short-circuits per-action filters. // For DELETE the index is still updated (resource really gone), but no event is propagated // for any action. @@ -726,9 +726,9 @@ void handleEventRespectsGenericFilter() throws Exception { informerEventSource.setGenericFilter(r -> false); var resource = testDeployment(); - informerEventSource.handleEvent(ResourceAction.DELETED, resource, null, true); - informerEventSource.handleEvent(ResourceAction.UPDATED, resource, resource, null); - informerEventSource.handleEvent(ResourceAction.ADDED, resource, null, null); + informerEventSource.handleSyntEvent(ResourceAction.DELETED, resource, null, true); + informerEventSource.handleSyntEvent(ResourceAction.UPDATED, resource, resource, null); + informerEventSource.handleSyntEvent(ResourceAction.ADDED, resource, null, null); verify(indexMock, times(1)).onDelete(resource); verify(eventHandlerMock, never()).handleEvent(any()); @@ -766,7 +766,7 @@ private void expectHandleUpdateEvent(int newResourceVersion, int oldResourceVers .untilAsserted( () -> verify(informerEventSource, times(1)) - .handleEvent( + .handleSyntEvent( eq(ResourceAction.UPDATED), argThat( r -> diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCacheTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCacheTest.java index 0baef35e82..3090c83470 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCacheTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCacheTest.java @@ -319,7 +319,7 @@ void removalOfGhostResources() { temporaryResourceCache.checkGhostResources(); assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(tr))).isEmpty(); verify(managedInformerEventSource, times(1)) - .handleEvent(eq(ResourceAction.DELETED), eq(tr), isNull(), eq(true)); + .handleSyntEvent(eq(ResourceAction.DELETED), eq(tr), isNull(), eq(true)); } @Test @@ -349,7 +349,7 @@ void ghostRemovalRemovesResourcesOnNotFollowedNamespaces() { // no delete event should be fired for resources removed due to namespace change verify(managedInformerEventSource, times(0)) - .handleEvent(any(), any(), any(), any(Boolean.class)); + .handleSyntEvent(any(), any(), any(), any(Boolean.class)); } @Test From 22e4a58cfef4f8ba2ce1c96480def53e3893bffe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 18 Jun 2026 08:49:13 +0200 Subject: [PATCH 19/21] Potential fix for pull request finding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Attila Mészáros --- .../processing/event/source/SecondaryToPrimaryMapper.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java index d91572bef8..d1f79a7981 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java @@ -33,8 +33,8 @@ public interface SecondaryToPrimaryMapper { * * @param resource the secondary resource for which an event was received * @return set of primary resource IDs to enqueue for reconciliation; an empty set means the event - * is irrelevant and no reconciliation is triggered. This is called also the old and the new - * resources, in case of an update. + * is irrelevant and no reconciliation is triggered. On update events, this method is invoked + * for both the old and the new versions of the resource. */ Set toPrimaryResourceIDs(R resource); } From 53eaf0bd8e37c09396dba631cbe86e9032bfa56a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 18 Jun 2026 10:22:33 +0200 Subject: [PATCH 20/21] revert internal method name change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../controller/ControllerEventSource.java | 4 +- .../source/informer/InformerEventSource.java | 2 +- .../informer/ManagedInformerEventSource.java | 4 +- .../informer/TemporaryResourceCache.java | 3 +- .../controller/ControllerEventSourceTest.java | 40 +++++++++---------- .../informer/InformerEventSourceTest.java | 30 +++++++------- .../informer/TemporaryResourceCacheTest.java | 4 +- 7 files changed, 43 insertions(+), 44 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java index b4c5f63382..dfa94577f7 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java @@ -79,7 +79,7 @@ public synchronized void start() { } @Override - protected synchronized void handleSyntEvent( + protected synchronized void handleEvent( ResourceAction action, T resource, T oldResource, Boolean deletedFinalStateUnknown) { try { if (log.isDebugEnabled()) { @@ -152,7 +152,7 @@ private void handleOnAddOrUpdate( @SuppressWarnings("unchecked") private void handleEvent(ExtendedResourceEvent r) { - handleSyntEvent( + handleEvent( r.getAction(), (T) r.getResource().orElseThrow(), (T) r.getPreviousResource().orElse(null), diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java index b6873fee9e..719be47747 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java @@ -137,7 +137,7 @@ public synchronized void onDelete(R resource, boolean deletedFinalStateUnknown) } @Override - protected void handleSyntEvent( + protected void handleEvent( ResourceAction action, R resource, R oldResource, Boolean deletedFinalStateUnknown) { // Called from ManagedInformerEventSource#eventFilteringUpdateAndCacheResource after the temp // cache decided to surface a (possibly synthesized) event. The user-level filters diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java index 1e7c7c174c..a9c6818565 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ManagedInformerEventSource.java @@ -117,7 +117,7 @@ public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator< r.getResource() .map(rr -> rr.getMetadata().getResourceVersion()) .orElse("[not set]")); - handleSyntEvent( + handleEvent( r.getAction(), (R) r.getResource().orElseThrow(), (R) r.getPreviousResource().orElse(null), @@ -127,7 +127,7 @@ public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator< } } - protected abstract void handleSyntEvent( + protected abstract void handleEvent( ResourceAction action, R resource, R oldResource, Boolean deletedFinalStateUnknown); @SuppressWarnings("unchecked") diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java index 7d847d8671..8879493a2a 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java @@ -244,8 +244,7 @@ public synchronized void checkGhostResources() { log.debug("Removing ghost resource with ID: {}", e.getKey()); iterator.remove(); eventFilteringSupport.handleGhostResourceRemoval(e.getKey()); - managedInformerEventSource.handleSyntEvent( - ResourceAction.DELETED, e.getValue(), null, true); + managedInformerEventSource.handleEvent(ResourceAction.DELETED, e.getValue(), null, true); } } } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java index 315d8e8d30..f8cb54f68e 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java @@ -75,10 +75,10 @@ void skipsEventHandlingIfGenerationNotIncreased() { TestCustomResource oldCustomResource = TestUtils.testCustomResource(); oldCustomResource.getMetadata().setFinalizers(List.of(FINALIZER)); - source.handleSyntEvent(ResourceAction.UPDATED, customResource, oldCustomResource, null); + source.handleEvent(ResourceAction.UPDATED, customResource, oldCustomResource, null); verify(eventHandler, times(1)).handleEvent(any()); - source.handleSyntEvent(ResourceAction.UPDATED, customResource, customResource, null); + source.handleEvent(ResourceAction.UPDATED, customResource, customResource, null); verify(eventHandler, times(1)).handleEvent(any()); } @@ -86,11 +86,11 @@ void skipsEventHandlingIfGenerationNotIncreased() { void dontSkipEventHandlingIfMarkedForDeletion() { TestCustomResource customResource1 = TestUtils.testCustomResource(); - source.handleSyntEvent(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); verify(eventHandler, times(1)).handleEvent(any()); customResource1.getMetadata().setDeletionTimestamp(LocalDateTime.now().toString()); - source.handleSyntEvent(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); verify(eventHandler, times(2)).handleEvent(any()); } @@ -98,11 +98,11 @@ void dontSkipEventHandlingIfMarkedForDeletion() { void normalExecutionIfGenerationChanges() { TestCustomResource customResource1 = TestUtils.testCustomResource(); - source.handleSyntEvent(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); verify(eventHandler, times(1)).handleEvent(any()); customResource1.getMetadata().setGeneration(2L); - source.handleSyntEvent(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); verify(eventHandler, times(2)).handleEvent(any()); } @@ -113,10 +113,10 @@ void handlesAllEventIfNotGenerationAware() { TestCustomResource customResource1 = TestUtils.testCustomResource(); - source.handleSyntEvent(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); verify(eventHandler, times(1)).handleEvent(any()); - source.handleSyntEvent(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); verify(eventHandler, times(2)).handleEvent(any()); } @@ -124,7 +124,7 @@ void handlesAllEventIfNotGenerationAware() { void eventWithNoGenerationProcessedIfNoFinalizer() { TestCustomResource customResource1 = TestUtils.testCustomResource(); - source.handleSyntEvent(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); verify(eventHandler, times(1)).handleEvent(any()); } @@ -133,7 +133,7 @@ void eventWithNoGenerationProcessedIfNoFinalizer() { void callsBroadcastsOnResourceEvents() { TestCustomResource customResource1 = TestUtils.testCustomResource(); - source.handleSyntEvent(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); verify(testController.getEventSourceManager(), times(1)) .broadcastOnResourceEvent( @@ -149,8 +149,8 @@ void filtersOutEventsOnAddAndUpdate() { source = new ControllerEventSource<>(new TestController(onAddFilter, onUpdatePredicate, null)); setUpSource(source, true, controllerConfig); - source.handleSyntEvent(ResourceAction.ADDED, cr, null, null); - source.handleSyntEvent(ResourceAction.UPDATED, cr, cr, null); + source.handleEvent(ResourceAction.ADDED, cr, null, null); + source.handleEvent(ResourceAction.UPDATED, cr, cr, null); verify(eventHandler, never()).handleEvent(any()); } @@ -162,9 +162,9 @@ void genericFilterFiltersOutAddUpdateAndDeleteEvents() { source = new ControllerEventSource<>(new TestController(null, null, res -> false)); setUpSource(source, true, controllerConfig); - source.handleSyntEvent(ResourceAction.ADDED, cr, null, null); - source.handleSyntEvent(ResourceAction.UPDATED, cr, cr, null); - source.handleSyntEvent(ResourceAction.DELETED, cr, cr, true); + source.handleEvent(ResourceAction.ADDED, cr, null, null); + source.handleEvent(ResourceAction.UPDATED, cr, cr, null); + source.handleEvent(ResourceAction.DELETED, cr, cr, true); verify(eventHandler, never()).handleEvent(any()); } @@ -196,7 +196,7 @@ void foreignUpdateDuringFilteringPropagatesAsUpdate() { source.onUpdate(testResourceWithVersion(2), testResourceWithVersion(3)); latch.countDown(); - await().untilAsserted(() -> expectHandleSyntEvent(3, 2)); + await().untilAsserted(() -> expectHandleEvent(3, 2)); } @Test @@ -215,7 +215,7 @@ void deleteEventDuringFilteringPropagatesAsDelete() { () -> { verify(eventHandler, atLeastOnce()).handleEvent(any()); verify(source, atLeastOnce()) - .handleSyntEvent(eq(ResourceAction.DELETED), any(), any(), any()); + .handleEvent(eq(ResourceAction.DELETED), any(), any(), any()); }); } @@ -231,13 +231,13 @@ void multipleForeignEventsDuringFilteringMergeIntoSingleEvent() { source.onUpdate(testResourceWithVersion(3), testResourceWithVersion(4)); latch.countDown(); - await().untilAsserted(() -> expectHandleSyntEvent(4, 2)); + await().untilAsserted(() -> expectHandleEvent(4, 2)); } - private void expectHandleSyntEvent(int newResourceVersion, int oldResourceVersion) { + private void expectHandleEvent(int newResourceVersion, int oldResourceVersion) { verify(eventHandler, times(1)).handleEvent(any()); verify(source, times(1)) - .handleSyntEvent( + .handleEvent( eq(ResourceAction.UPDATED), argThat(r -> ("" + newResourceVersion).equals(r.getMetadata().getResourceVersion())), argThat(r -> ("" + oldResourceVersion).equals(r.getMetadata().getResourceVersion())), diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java index b8eb250f92..3d9e8cbc1d 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java @@ -648,7 +648,7 @@ void deleteEventDuringOwnUpdateIsPropagated() { } @Test - void handleSyntEventUpdatesIndexWhenDeletePropagatesFromTempCache() throws Exception { + void handleEventUpdatesIndexWhenDeletePropagatesFromTempCache() throws Exception { // handleEvent is invoked from ManagedInformerEventSource#eventFilteringUpdateAndCacheResource // only after the temp cache decided to surface the event. For a DELETE that means the resource // is really gone and the secondary→primary index must drop it; otherwise stale entries linger @@ -658,7 +658,7 @@ void handleSyntEventUpdatesIndexWhenDeletePropagatesFromTempCache() throws Excep // onDelete now returns the primaries to reconcile; propagateEvent uses that set directly when(indexMock.onDelete(resource)).thenReturn(Set.of(ResourceID.fromResource(resource))); - informerEventSource.handleSyntEvent(ResourceAction.DELETED, resource, null, false); + informerEventSource.handleEvent(ResourceAction.DELETED, resource, null, false); verify(indexMock, times(1)).onDelete(resource); verify(indexMock, never()).onAddOrUpdate(any(), any()); @@ -666,12 +666,12 @@ void handleSyntEventUpdatesIndexWhenDeletePropagatesFromTempCache() throws Excep } @Test - void handleSyntEventDoesNotTouchIndexForNonDeleteAction() throws Exception { + void handleEventDoesNotTouchIndexForNonDeleteAction() throws Exception { // The onAdd/onUpdate path maintains the index in onAddOrUpdate(); handleEvent must not // double-update it for non-DELETE actions, otherwise we'd index resources twice. var indexMock = injectIndexMock(); - informerEventSource.handleSyntEvent( + informerEventSource.handleEvent( ResourceAction.UPDATED, testDeployment(), testDeployment(), null); verify(indexMock, never()).onDelete(any()); @@ -680,7 +680,7 @@ void handleSyntEventDoesNotTouchIndexForNonDeleteAction() throws Exception { } @Test - void handleSyntEventRespectsOnDeleteFilter() throws Exception { + void handleEventRespectsOnDeleteFilter() throws Exception { // The temp-cache pipeline must honor user-level filters: if onDeleteFilter rejects, the // synthesized DELETE must not be surfaced. The index, however, is still updated because the // resource is really gone — same semantics as the direct onDelete watch path. @@ -688,18 +688,18 @@ void handleSyntEventRespectsOnDeleteFilter() throws Exception { informerEventSource.setOnDeleteFilter((r, b) -> false); var resource = testDeployment(); - informerEventSource.handleSyntEvent(ResourceAction.DELETED, resource, null, false); + informerEventSource.handleEvent(ResourceAction.DELETED, resource, null, false); verify(indexMock, times(1)).onDelete(resource); verify(eventHandlerMock, never()).handleEvent(any()); } @Test - void handleSyntEventRespectsOnUpdateFilter() throws Exception { + void handleEventRespectsOnUpdateFilter() throws Exception { var indexMock = injectIndexMock(); informerEventSource.setOnUpdateFilter((n, o) -> false); - informerEventSource.handleSyntEvent( + informerEventSource.handleEvent( ResourceAction.UPDATED, testDeployment(), testDeployment(), null); verify(indexMock, never()).onDelete(any()); @@ -707,18 +707,18 @@ void handleSyntEventRespectsOnUpdateFilter() throws Exception { } @Test - void handleSyntEventRespectsOnAddFilter() throws Exception { + void handleEventRespectsOnAddFilter() throws Exception { var indexMock = injectIndexMock(); informerEventSource.setOnAddFilter(r -> false); - informerEventSource.handleSyntEvent(ResourceAction.ADDED, testDeployment(), null, null); + informerEventSource.handleEvent(ResourceAction.ADDED, testDeployment(), null, null); verify(indexMock, never()).onDelete(any()); verify(eventHandlerMock, never()).handleEvent(any()); } @Test - void handleSyntEventRespectsGenericFilter() throws Exception { + void handleEventRespectsGenericFilter() throws Exception { // The generic filter applies regardless of action and short-circuits per-action filters. // For DELETE the index is still updated (resource really gone), but no event is propagated // for any action. @@ -726,9 +726,9 @@ void handleSyntEventRespectsGenericFilter() throws Exception { informerEventSource.setGenericFilter(r -> false); var resource = testDeployment(); - informerEventSource.handleSyntEvent(ResourceAction.DELETED, resource, null, true); - informerEventSource.handleSyntEvent(ResourceAction.UPDATED, resource, resource, null); - informerEventSource.handleSyntEvent(ResourceAction.ADDED, resource, null, null); + informerEventSource.handleEvent(ResourceAction.DELETED, resource, null, true); + informerEventSource.handleEvent(ResourceAction.UPDATED, resource, resource, null); + informerEventSource.handleEvent(ResourceAction.ADDED, resource, null, null); verify(indexMock, times(1)).onDelete(resource); verify(eventHandlerMock, never()).handleEvent(any()); @@ -766,7 +766,7 @@ private void expectHandleUpdateEvent(int newResourceVersion, int oldResourceVers .untilAsserted( () -> verify(informerEventSource, times(1)) - .handleSyntEvent( + .handleEvent( eq(ResourceAction.UPDATED), argThat( r -> diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCacheTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCacheTest.java index 3090c83470..0baef35e82 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCacheTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCacheTest.java @@ -319,7 +319,7 @@ void removalOfGhostResources() { temporaryResourceCache.checkGhostResources(); assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(tr))).isEmpty(); verify(managedInformerEventSource, times(1)) - .handleSyntEvent(eq(ResourceAction.DELETED), eq(tr), isNull(), eq(true)); + .handleEvent(eq(ResourceAction.DELETED), eq(tr), isNull(), eq(true)); } @Test @@ -349,7 +349,7 @@ void ghostRemovalRemovesResourcesOnNotFollowedNamespaces() { // no delete event should be fired for resources removed due to namespace change verify(managedInformerEventSource, times(0)) - .handleSyntEvent(any(), any(), any(), any(Boolean.class)); + .handleEvent(any(), any(), any(), any(Boolean.class)); } @Test From 02688651442536123bf5c5be8f12ab005756dc58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 18 Jun 2026 10:24:29 +0200 Subject: [PATCH 21/21] visibility change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../processing/event/source/informer/InformerEventSource.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java index 719be47747..30fd6a7fb5 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java @@ -202,7 +202,7 @@ private synchronized void onAddOrUpdate(ResourceAction action, R newObject, R ol } } - void propagateEvent(R resource, R oldResource, Set primaryResourceIdSet) { + protected void propagateEvent(R resource, R oldResource, Set primaryResourceIdSet) { if (primaryResourceIdSet == null) { primaryResourceIdSet = new HashSet<>(); primaryResourceIdSet.addAll(