From 0f8f89fbce9f684f279e9d2b00b70b599d7a628c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 9 Jul 2026 14:52:45 +0200 Subject: [PATCH 01/12] test: cocurrent non changing resource reproducer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- ...ChangeDuringStatusPatchCustomResource.java | 29 +++++ .../SpecChangeDuringStatusPatchIT.java | 107 ++++++++++++++++++ ...SpecChangeDuringStatusPatchReconciler.java | 72 ++++++++++++ .../SpecChangeDuringStatusPatchSpec.java | 30 +++++ .../SpecChangeDuringStatusPatchStatus.java | 30 +++++ 5 files changed, 268 insertions(+) create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchCustomResource.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchReconciler.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchSpec.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchStatus.java diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchCustomResource.java new file mode 100644 index 0000000000..dff33b454d --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchCustomResource.java @@ -0,0 +1,29 @@ +/* + * 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.readcacheafterwrite.specchangeduringstatuspatch; + +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.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +@Group("sample.javaoperatorsdk") +@Version("v1") +@ShortNames("scdsp") +public class SpecChangeDuringStatusPatchCustomResource + extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchIT.java new file mode 100644 index 0000000000..93caa2fad1 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchIT.java @@ -0,0 +1,107 @@ +/* + * 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.readcacheafterwrite.specchangeduringstatuspatch; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.client.dsl.base.PatchContext; +import io.fabric8.kubernetes.client.dsl.base.PatchType; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.baseapi.readcacheafterwrite.specchangeduringstatuspatch.SpecChangeDuringStatusPatchReconciler.STATUS_VALUE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * Reproduces a concurrent spec change happening while the controller patches its own status. When + * the reconciler patches the status it opens an event filtering window so it does not re-trigger + * itself. This test changes the spec on the cluster while that window is open and verifies that the + * spec change is still reconciled - it must not be silently absorbed together with the controller's + * own status update. + */ +class SpecChangeDuringStatusPatchIT { + + static final String RESOURCE_NAME = "test-resource"; + static final String SPEC_VALUE = "initial"; + public static final String UPDATED_SPEC_VALUE = "updated-val"; + + SpecChangeDuringStatusPatchReconciler reconciler = new SpecChangeDuringStatusPatchReconciler(); + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder().withReconciler(reconciler).build(); + + @RepeatedTest(10) + void specChangeDuringStatusPatchIsReconciled() throws InterruptedException { + var res = extension.create(testResource()); + var statusRes = testResource(); + statusRes.getMetadata().setNamespace(res.getMetadata().getNamespace()); + extension + .getKubernetesClient() + .resource(statusRes) + .status() + .patch( + new PatchContext.Builder() + .withForce(true) + .withFieldManager( + SpecChangeDuringStatusPatchReconciler.class.getSimpleName().toLowerCase()) + .withPatchType(PatchType.SERVER_SIDE_APPLY) + .build()); + + // wait until the reconciler is inside its own status patch, holding the filtering window open + assertThat(reconciler.statusPatchStartedLatch.await(30, TimeUnit.SECONDS)) + .as("reconciler should enter its own status patch operation") + .isTrue(); + + // change the spec on the cluster while the controller's status patch is still in flight + var current = extension.get(SpecChangeDuringStatusPatchCustomResource.class, RESOURCE_NAME); + current.getSpec().setValue(UPDATED_SPEC_VALUE); + extension.replace(current); + + // let the reconciler finish its own status patch + reconciler.specChangeDoneLatch.countDown(); + + // the spec change must be picked up by a fresh reconciliation and not lost with the own update + await() + .atMost(Duration.ofSeconds(5)) + .untilAsserted( + () -> { + assertThat(reconciler.numberOfExecutions.get()).isGreaterThanOrEqualTo(2); + assertThat(reconciler.lastObservedSpecValue.get()) + .as("a later reconciliation must observe the externally-applied spec change") + .isEqualTo(UPDATED_SPEC_VALUE); + }); + + // sanity check: the status the controller set is still present after the concurrent spec change + var updated = extension.get(SpecChangeDuringStatusPatchCustomResource.class, RESOURCE_NAME); + assertThat(updated.getSpec().getValue()).isEqualTo(UPDATED_SPEC_VALUE); + assertThat(updated.getStatus()).isNotNull(); + assertThat(updated.getStatus().getValue()).isEqualTo(STATUS_VALUE); + } + + SpecChangeDuringStatusPatchCustomResource testResource() { + var r = new SpecChangeDuringStatusPatchCustomResource(); + r.setMetadata(new ObjectMetaBuilder().withName(RESOURCE_NAME).build()); + r.setSpec(new SpecChangeDuringStatusPatchSpec().setValue(SPEC_VALUE)); + r.setStatus(new SpecChangeDuringStatusPatchStatus().setValue(STATUS_VALUE)); + return r; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchReconciler.java new file mode 100644 index 0000000000..33d809046b --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchReconciler.java @@ -0,0 +1,72 @@ +/* + * 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.readcacheafterwrite.specchangeduringstatuspatch; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; + +/** + * On the first reconciliation the reconciler patches its own status, but keeps the event filtering + * window (opened by {@link + * io.javaoperatorsdk.operator.api.reconciler.ResourceOperations#resourcePatch}) open until the test + * signals that it has changed the spec on the cluster. This reproduces the race where a spec change + * lands while the controller's own status patch is in flight: the spec change event must still + * propagate as a fresh reconciliation, it must not be absorbed as if it were our own status update. + */ +@ControllerConfiguration +public class SpecChangeDuringStatusPatchReconciler + implements Reconciler { + + static final String STATUS_VALUE = "reconciled"; + + final AtomicInteger numberOfExecutions = new AtomicInteger(); + final CountDownLatch statusPatchStartedLatch = new CountDownLatch(1); + final CountDownLatch specChangeDoneLatch = new CountDownLatch(1); + final AtomicReference lastObservedSpecValue = new AtomicReference<>(); + + @Override + public UpdateControl reconcile( + SpecChangeDuringStatusPatchCustomResource resource, + Context context) { + int execution = numberOfExecutions.incrementAndGet(); + lastObservedSpecValue.set(resource.getSpec().getValue()); + + if (execution == 1) { + resource.setStatus(new SpecChangeDuringStatusPatchStatus().setValue(STATUS_VALUE)); + resource.getMetadata().setResourceVersion(null); + // Patch our own status, but hold the filtering window open with a hook that lets the test + // change the spec on the cluster WHILE the status patch is still in flight. + statusPatchStartedLatch.countDown(); + try { + if (!specChangeDoneLatch.await(30, TimeUnit.SECONDS)) { + throw new IllegalStateException("timed out waiting for external spec change"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + return UpdateControl.patchStatus(resource); + } + return UpdateControl.noUpdate(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchSpec.java new file mode 100644 index 0000000000..64a6e7f0ef --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchSpec.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.readcacheafterwrite.specchangeduringstatuspatch; + +public class SpecChangeDuringStatusPatchSpec { + + private String value; + + public String getValue() { + return value; + } + + public SpecChangeDuringStatusPatchSpec setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchStatus.java new file mode 100644 index 0000000000..4cb857a4cc --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/specchangeduringstatuspatch/SpecChangeDuringStatusPatchStatus.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.readcacheafterwrite.specchangeduringstatuspatch; + +public class SpecChangeDuringStatusPatchStatus { + + private String value; + + public String getValue() { + return value; + } + + public SpecChangeDuringStatusPatchStatus setValue(String value) { + this.value = value; + return this; + } +} From 794966ed3da8d5879da5aa50746ec5056ad9ddb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 10 Jul 2026 12:01:39 +0200 Subject: [PATCH 02/12] fix: event filtering edge case with no-op updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../informer/ManagedInformerEventSource.java | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) 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 5a239a7377..58d04de0c2 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 @@ -94,14 +94,20 @@ public void changeNamespaces(Set namespaces) { * reconciliation. */ @SuppressWarnings("unchecked") - public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator updateMethod) { + public R eventFilteringUpdateAndCacheResource( + R resourceToUpdate, UnaryOperator updateOperation) { + if (resourceToUpdate.getMetadata().getResourceVersion() == null) { + log.debug("No resourceVersion set. Skipping event filtering."); + return updateAndCacheResource(resourceToUpdate, updateOperation); + } + ResourceID id = ResourceID.fromResource(resourceToUpdate); log.debug("Starting event filtering and caching update for id={}", id); R updatedResource = null; Set relatedPrimaryIds = null; try { temporaryResourceCache.startEventFilteringModify(id); - updatedResource = updateMethod.apply(resourceToUpdate); + updatedResource = updateOperation.apply(resourceToUpdate); relatedPrimaryIds = cacheUpdateAndGetRelatedPrimaryIDs(updatedResource, resourceToUpdate); log.debug( "Caching resource update successful. id={}, rv={}", @@ -134,6 +140,12 @@ public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator< } } + private R updateAndCacheResource(R resourceToUpdate, UnaryOperator updateOperation) { + var result = updateOperation.apply(resourceToUpdate); + handleRecentResourceUpdate(ResourceID.fromResource(resourceToUpdate), result, resourceToUpdate); + return result; + } + protected abstract void handleEvent( ResourceAction action, R resource, From efc19850c9c02b1aa405a46e30a579ff396945ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 10 Jul 2026 13:08:28 +0200 Subject: [PATCH 03/12] unit test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../informer/InformerEventSourceTest.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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 c62a1d1a3a..026559b593 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 @@ -780,6 +780,25 @@ void filteringUpdateFallsBackToMapperWhenNoPrimaryToSecondaryIndex() { verify(eventHandlerMock, times(1)).handleEvent(any()); } + @Test + void skipsEventFilteringWhenResourceVersionIsNull() { + // Without a resourceVersion there is nothing to correlate own-write echoes against, so + // event filtering is bypassed: the update is applied and cached directly, no filter window + // is opened and no event is propagated through handleEvent. + var resourceToUpdate = testDeployment(); + resourceToUpdate.getMetadata().setResourceVersion(null); + var updated = deploymentWithResourceVersion(3); + + var result = + informerEventSource.eventFilteringUpdateAndCacheResource(resourceToUpdate, r -> updated); + + assertThat(result).isSameAs(updated); + verify(temporaryResourceCache, times(1)).putResource(updated); + verify(temporaryResourceCache, never()).startEventFilteringModify(any()); + verify(temporaryResourceCache, never()).doneEventFilterModify(any()); + verify(eventHandlerMock, never()).handleEvent(any()); + } + private PrimaryToSecondaryIndex injectIndexMock() throws Exception { @SuppressWarnings("unchecked") PrimaryToSecondaryIndex indexMock = mock(PrimaryToSecondaryIndex.class); From 84e9f5993d6afe7711a7b7dcba3cceb3249b2209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 10 Jul 2026 13:30:31 +0200 Subject: [PATCH 04/12] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/processing/event/ReconciliationDispatcher.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java index 6e7ace0447..18eb986efe 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/ReconciliationDispatcher.java @@ -138,8 +138,7 @@ private PostExecutionControl

handleReconcile( } else { updatedResource = context.resourceOperations().addFinalizer(); } - return PostExecutionControl.onlyFinalizerAdded(updatedResource) - .withReSchedule(BaseControl.INSTANT_RESCHEDULE); + return PostExecutionControl.onlyFinalizerAdded(updatedResource); } else { try { return reconcileExecution(executionScope, resourceForExecution, originalResource, context); From 84228bf43879971101d6646830d0506a250177f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 10 Jul 2026 13:31:49 +0200 Subject: [PATCH 05/12] wip 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/ManagedInformerEventSource.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 58d04de0c2..c15cb76989 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 @@ -95,10 +95,10 @@ public void changeNamespaces(Set namespaces) { */ @SuppressWarnings("unchecked") public R eventFilteringUpdateAndCacheResource( - R resourceToUpdate, UnaryOperator updateOperation) { + R resourceToUpdate, UnaryOperator updateMethod) { if (resourceToUpdate.getMetadata().getResourceVersion() == null) { log.debug("No resourceVersion set. Skipping event filtering."); - return updateAndCacheResource(resourceToUpdate, updateOperation); + return updateAndCacheResource(resourceToUpdate, updateMethod); } ResourceID id = ResourceID.fromResource(resourceToUpdate); @@ -107,7 +107,7 @@ public R eventFilteringUpdateAndCacheResource( Set relatedPrimaryIds = null; try { temporaryResourceCache.startEventFilteringModify(id); - updatedResource = updateOperation.apply(resourceToUpdate); + updatedResource = updateMethod.apply(resourceToUpdate); relatedPrimaryIds = cacheUpdateAndGetRelatedPrimaryIDs(updatedResource, resourceToUpdate); log.debug( "Caching resource update successful. id={}, rv={}", From b813247f228286fab330ad7e297900326e8a10ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 10 Jul 2026 13:32:11 +0200 Subject: [PATCH 06/12] wip 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/ManagedInformerEventSource.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 c15cb76989..ebc5187bd5 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 @@ -94,8 +94,7 @@ public void changeNamespaces(Set namespaces) { * reconciliation. */ @SuppressWarnings("unchecked") - public R eventFilteringUpdateAndCacheResource( - R resourceToUpdate, UnaryOperator updateMethod) { + public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator updateMethod) { if (resourceToUpdate.getMetadata().getResourceVersion() == null) { log.debug("No resourceVersion set. Skipping event filtering."); return updateAndCacheResource(resourceToUpdate, updateMethod); From a17da69073d8d9e3b22f3bf1847737881418710f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 10 Jul 2026 14:48:16 +0200 Subject: [PATCH 07/12] add force filtering option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../api/reconciler/ResourceOperations.java | 68 ++++++++++++++++--- .../KubernetesDependentResource.java | 7 +- .../informer/ManagedInformerEventSource.java | 14 +++- .../reconciler/ResourceOperationsTest.java | 31 +++++---- .../informer/InformerEventSourceTest.java | 22 ++++++ 5 files changed, 112 insertions(+), 30 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index b9ef475509..8c384fedc4 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -68,6 +68,10 @@ public ResourceOperations(Context

context) { * @param resource type */ public R serverSideApply(R resource) { + return serverSideApply(resource, (Options) null); + } + + public R serverSideApply(R resource, Options options) { return resourcePatch( resource, r -> @@ -79,7 +83,13 @@ public R serverSideApply(R resource) { .withForce(true) .withFieldManager(context.getControllerConfiguration().fieldManager()) .withPatchType(PatchType.SERVER_SIDE_APPLY) - .build())); + .build()), + options); + } + + public R serverSideApply( + R resource, InformerEventSource informerEventSource) { + return serverSideApply(resource, informerEventSource, null); } /** @@ -97,7 +107,7 @@ public R serverSideApply(R resource) { * @param resource type */ public R serverSideApply( - R resource, InformerEventSource informerEventSource) { + R resource, InformerEventSource informerEventSource, Options options) { if (informerEventSource == null) { return serverSideApply(resource); } @@ -208,6 +218,10 @@ public P serverSideApplyPrimaryStatus(P resource) { context.eventSourceRetriever().getControllerEventSource()); } + public R update(R resource) { + return update(resource, (Options) null); + } + /** * Updates the resource and caches the response if needed, thus making sure that next * reconciliation will see to updated resource - or more recent one if additional update happened @@ -221,8 +235,13 @@ public P serverSideApplyPrimaryStatus(P resource) { * @return updated resource * @param resource type */ - public R update(R resource) { - return resourcePatch(resource, r -> context.getClient().resource(r).update()); + public R update(R resource, Options options) { + return resourcePatch(resource, r -> context.getClient().resource(r).update(), options); + } + + public R update( + R resource, InformerEventSource informerEventSource) { + return update(resource, informerEventSource, null); } /** @@ -240,12 +259,12 @@ public R update(R resource) { * @param resource type */ public R update( - R resource, InformerEventSource informerEventSource) { + R resource, InformerEventSource informerEventSource, Options options) { if (informerEventSource == null) { return update(resource); } return resourcePatch( - resource, r -> context.getClient().resource(r).update(), informerEventSource); + resource, r -> context.getClient().resource(r).update(), informerEventSource, options); } /** @@ -262,7 +281,8 @@ public R update( * @param resource type */ public R create(R resource) { - return resourcePatch(resource, r -> context.getClient().resource(r).create()); + return resourcePatch( + resource, r -> context.getClient().resource(r).create(), new Options(true)); } /** @@ -284,8 +304,12 @@ public R create( if (informerEventSource == null) { return create(resource); } + // it is safe to do event filtering for create since check if the resource already exists. return resourcePatch( - resource, r -> context.getClient().resource(r).create(), informerEventSource); + resource, + r -> context.getClient().resource(r).create(), + informerEventSource, + new Options(true)); } /** @@ -518,6 +542,10 @@ public P jsonMergePatchPrimaryStatus(P resource) { context.eventSourceRetriever().getControllerEventSource()); } + public R resourcePatch(R resource, UnaryOperator updateOperation) { + return resourcePatch(resource, updateOperation, (Options) null); + } + /** * Utility method to patch a resource and cache the result. Automatically discovers the event * source for the resource type and delegates to {@link #resourcePatch(HasMetadata, UnaryOperator, @@ -530,7 +558,8 @@ public P jsonMergePatchPrimaryStatus(P resource) { * @throws IllegalStateException if no event source or multiple event sources are found */ @SuppressWarnings({"rawtypes", "unchecked"}) - public R resourcePatch(R resource, UnaryOperator updateOperation) { + public R resourcePatch( + R resource, UnaryOperator updateOperation, Options options) { var esList = context.eventSourceRetriever().getEventSourcesFor(resource.getClass()); if (esList.isEmpty()) { @@ -544,7 +573,8 @@ public R resourcePatch(R resource, UnaryOperator upda es.name()); } if (es instanceof ManagedInformerEventSource mes) { - return resourcePatch(resource, updateOperation, (ManagedInformerEventSource) mes); + return resourcePatch( + resource, updateOperation, (ManagedInformerEventSource) mes, options); } else { throw new IllegalStateException( "Target event source must be a subclass off " @@ -565,7 +595,16 @@ public R resourcePatch(R resource, UnaryOperator upda */ public R resourcePatch( R resource, UnaryOperator updateOperation, ManagedInformerEventSource ies) { - return ies.eventFilteringUpdateAndCacheResource(resource, updateOperation); + return resourcePatch(resource, updateOperation, ies, (Options) null); + } + + public R resourcePatch( + R resource, + UnaryOperator updateOperation, + ManagedInformerEventSource ies, + Options options) { + return ies.eventFilteringUpdateAndCacheResource( + resource, updateOperation, options != null && options.forceEventFiltering()); } /** @@ -754,4 +793,11 @@ public P addFinalizerWithSSA(String finalizerName) { e); } } + + @Experimental("This API might change") + /** + * Force filtering only if it is made sure that the update not results on a no-op change. See + * details here: https://github.com/operator-framework/java-operator-sdk/pull/3484 + */ + public record Options(boolean forceEventFiltering) {} } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java index f8d7c07b01..b69cf933a4 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java @@ -30,6 +30,7 @@ import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; import io.javaoperatorsdk.operator.api.reconciler.Ignore; +import io.javaoperatorsdk.operator.api.reconciler.ResourceOperations; import io.javaoperatorsdk.operator.api.reconciler.dependent.GarbageCollected; import io.javaoperatorsdk.operator.api.reconciler.dependent.managed.ConfiguredDependentResource; import io.javaoperatorsdk.operator.processing.GroupVersionKind; @@ -90,7 +91,6 @@ public R create(R desired, P primary, Context

context) { desired.getClass(), ResourceID.fromResource(desired), ssa); - return ssa ? context.resourceOperations().serverSideApply(desired, eventSource().orElse(null)) : context.resourceOperations().create(desired, eventSource().orElse(null)); @@ -114,7 +114,10 @@ public R update(R actual, R desired, P primary, Context

context) { ssa); if (ssa) { updatedResource = - context.resourceOperations().serverSideApply(desired, eventSource().orElse(null)); + context + .resourceOperations() + .serverSideApply( + desired, eventSource().orElse(null), new ResourceOperations.Options(true)); } else { var updatedActual = GenericResourceUpdater.updateResource(actual, desired, context); updatedResource = 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 ebc5187bd5..9739a6d163 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 @@ -39,6 +39,7 @@ import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; import io.javaoperatorsdk.operator.api.config.Informable; import io.javaoperatorsdk.operator.api.config.NamespaceChangeable; +import io.javaoperatorsdk.operator.api.reconciler.Experimental; import io.javaoperatorsdk.operator.api.reconciler.dependent.RecentOperationCacheFiller; import io.javaoperatorsdk.operator.health.InformerHealthIndicator; import io.javaoperatorsdk.operator.health.InformerWrappingEventSourceHealthIndicator; @@ -88,14 +89,23 @@ public void changeNamespaces(Set namespaces) { } } + @Experimental( + "Internal API. Use ResourceOperation not this directly, this API might change in the future") + public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator updateMethod) { + return eventFilteringUpdateAndCacheResource(resourceToUpdate, updateMethod, false); + } + /** * Updates the resource and makes sure that the response is available for the next reconciliation. * Also makes sure that the even produced by this update is filtered, thus does not trigger the * reconciliation. */ + @Experimental( + "Internal API. Use ResourceOperation not this directly, this API might change in the future") @SuppressWarnings("unchecked") - public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator updateMethod) { - if (resourceToUpdate.getMetadata().getResourceVersion() == null) { + public R eventFilteringUpdateAndCacheResource( + R resourceToUpdate, UnaryOperator updateMethod, boolean forceUpdateFilter) { + if (resourceToUpdate.getMetadata().getResourceVersion() == null && !forceUpdateFilter) { log.debug("No resourceVersion set. Skipping event filtering."); return updateAndCacheResource(resourceToUpdate, updateMethod); } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java index 8d0176cd4a..abc4326dd1 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperationsTest.java @@ -84,7 +84,7 @@ void addsFinalizer() { // Mock successful finalizer addition when(controllerEventSource.eventFilteringUpdateAndCacheResource( - any(), any(UnaryOperator.class))) + any(), any(UnaryOperator.class), anyBoolean())) .thenAnswer( invocation -> { var res = TestUtils.testCustomResource1(); @@ -99,7 +99,7 @@ void addsFinalizer() { assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); verify(controllerEventSource, times(1)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class), anyBoolean()); } @Test @@ -111,7 +111,7 @@ void addsFinalizerWithSSA() { // Mock successful SSA finalizer addition when(controllerEventSource.eventFilteringUpdateAndCacheResource( - any(), any(UnaryOperator.class))) + any(), any(UnaryOperator.class), anyBoolean())) .thenAnswer( invocation -> { var res = TestUtils.testCustomResource1(); @@ -126,7 +126,7 @@ void addsFinalizerWithSSA() { assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); verify(controllerEventSource, times(1)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class), anyBoolean()); } @Test @@ -139,7 +139,7 @@ void removesFinalizer() { // Mock successful finalizer removal when(controllerEventSource.eventFilteringUpdateAndCacheResource( - any(), any(UnaryOperator.class))) + any(), any(UnaryOperator.class), anyBoolean())) .thenAnswer( invocation -> { var res = TestUtils.testCustomResource1(); @@ -154,7 +154,7 @@ void removesFinalizer() { assertThat(result.hasFinalizer(FINALIZER_NAME)).isFalse(); assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); verify(controllerEventSource, times(1)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class), anyBoolean()); } @Test @@ -166,7 +166,7 @@ void retriesAddingFinalizerWithoutSSA() { // First call throws conflict, second succeeds when(controllerEventSource.eventFilteringUpdateAndCacheResource( - any(), any(UnaryOperator.class))) + any(), any(UnaryOperator.class), anyBoolean())) .thenThrow(new KubernetesClientException("Conflict", 409, null)) .thenAnswer( invocation -> { @@ -184,7 +184,7 @@ void retriesAddingFinalizerWithoutSSA() { assertThat(result).isNotNull(); assertThat(result.hasFinalizer(FINALIZER_NAME)).isTrue(); verify(controllerEventSource, times(2)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class), anyBoolean()); verify(resourceOp, times(1)).get(); } @@ -198,7 +198,7 @@ void nullResourceIsGracefullyHandledOnFinalizerRemovalRetry() { // First call throws conflict when(controllerEventSource.eventFilteringUpdateAndCacheResource( - any(), any(UnaryOperator.class))) + any(), any(UnaryOperator.class), anyBoolean())) .thenThrow(new KubernetesClientException("Conflict", 409, null)); // Return null on retry (resource was deleted) @@ -207,7 +207,7 @@ void nullResourceIsGracefullyHandledOnFinalizerRemovalRetry() { resourceOperations.removeFinalizer(FINALIZER_NAME); verify(controllerEventSource, times(1)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class), anyBoolean()); verify(resourceOp, times(1)).get(); } @@ -221,7 +221,7 @@ void retriesFinalizerRemovalWithFreshResource() { // First call throws unprocessable (422), second succeeds when(controllerEventSource.eventFilteringUpdateAndCacheResource( - any(), any(UnaryOperator.class))) + any(), any(UnaryOperator.class), anyBoolean())) .thenThrow(new KubernetesClientException("Unprocessable", 422, null)) .thenAnswer( invocation -> { @@ -243,7 +243,7 @@ void retriesFinalizerRemovalWithFreshResource() { assertThat(result.getMetadata().getResourceVersion()).isEqualTo("3"); assertThat(result.hasFinalizer(FINALIZER_NAME)).isFalse(); verify(controllerEventSource, times(2)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class), anyBoolean()); verify(resourceOp, times(1)).get(); } @@ -261,7 +261,8 @@ void resourcePatchWithSingleEventSource() { when(context.eventSourceRetriever()).thenReturn(eventSourceRetriever); when(eventSourceRetriever.getEventSourcesFor(TestCustomResource.class)) .thenReturn(List.of(managedEventSource)); - when(managedEventSource.eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class))) + when(managedEventSource.eventFilteringUpdateAndCacheResource( + any(), any(UnaryOperator.class), anyBoolean())) .thenReturn(updatedResource); var result = resourceOperations.resourcePatch(resource, UnaryOperator.identity()); @@ -269,7 +270,7 @@ void resourcePatchWithSingleEventSource() { assertThat(result).isNotNull(); assertThat(result.getMetadata().getResourceVersion()).isEqualTo("2"); verify(managedEventSource, times(1)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class), anyBoolean()); } @Test @@ -303,7 +304,7 @@ void resourcePatchUsesFirstEventSourceIfMultipleEventSourcesPresent() { resourceOperations.resourcePatch(resource, UnaryOperator.identity()); verify(eventSource1, times(1)) - .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class)); + .eventFilteringUpdateAndCacheResource(any(), any(UnaryOperator.class), anyBoolean()); } @Test 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 026559b593..31cbc26d11 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 @@ -799,6 +799,28 @@ void skipsEventFilteringWhenResourceVersionIsNull() { verify(eventHandlerMock, never()).handleEvent(any()); } + @Test + void forceUpdateFilterOpensFilterWindowEvenWhenResourceVersionIsNull() { + // A write without a resourceVersion (e.g. an SSA finalizer add, which uses no optimistic + // locking) would normally skip event filtering. forceUpdateFilter=true forces filtering + // anyway so the resulting own event is correlated and does not trigger a spurious + // reconciliation. + var resourceToUpdate = testDeployment(); + resourceToUpdate.getMetadata().setResourceVersion(null); + var updated = deploymentWithResourceVersion(3); + when(temporaryResourceCache.doneEventFilterModify(any())).thenReturn(Optional.empty()); + + var result = + informerEventSource.eventFilteringUpdateAndCacheResource( + resourceToUpdate, r -> updated, true); + + assertThat(result).isSameAs(updated); + verify(temporaryResourceCache, times(1)).startEventFilteringModify(any()); + verify(temporaryResourceCache, times(1)).doneEventFilterModify(any()); + verify(temporaryResourceCache, times(1)).putResource(updated); + verify(eventHandlerMock, never()).handleEvent(any()); + } + private PrimaryToSecondaryIndex injectIndexMock() throws Exception { @SuppressWarnings("unchecked") PrimaryToSecondaryIndex indexMock = mock(PrimaryToSecondaryIndex.class); From bc29dc16d1c7ed611f4ebdc5096cc67f4f5c77c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 10 Jul 2026 14:59:19 +0200 Subject: [PATCH 08/12] test fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../api/reconciler/ResourceOperations.java | 14 +++++++++++--- .../onrelistfilter/OnRelistFilterReconciler.java | 13 ++++++++++--- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 8c384fedc4..9fdba153ea 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -31,6 +31,7 @@ import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; import io.javaoperatorsdk.operator.processing.event.source.informer.ManagedInformerEventSource; +import static io.javaoperatorsdk.operator.api.reconciler.Experimental.API_MIGHT_CHANGE; import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getUID; import static io.javaoperatorsdk.operator.processing.KubernetesResourceUtils.getVersion; @@ -71,6 +72,7 @@ public R serverSideApply(R resource) { return serverSideApply(resource, (Options) null); } + @Experimental(API_MIGHT_CHANGE) public R serverSideApply(R resource, Options options) { return resourcePatch( resource, @@ -106,6 +108,7 @@ public R serverSideApply( * @param informerEventSource InformerEventSource to use for resource caching and filtering * @param resource type */ + @Experimental(API_MIGHT_CHANGE) public R serverSideApply( R resource, InformerEventSource informerEventSource, Options options) { if (informerEventSource == null) { @@ -123,7 +126,8 @@ public R serverSideApply( .withFieldManager(context.getControllerConfiguration().fieldManager()) .withPatchType(PatchType.SERVER_SIDE_APPLY) .build()), - informerEventSource); + informerEventSource, + options); } /** @@ -235,6 +239,7 @@ public R update(R resource) { * @return updated resource * @param resource type */ + @Experimental(API_MIGHT_CHANGE) public R update(R resource, Options options) { return resourcePatch(resource, r -> context.getClient().resource(r).update(), options); } @@ -258,6 +263,7 @@ public R update( * @param informerEventSource InformerEventSource to use for resource caching and filtering * @param resource type */ + @Experimental(API_MIGHT_CHANGE) public R update( R resource, InformerEventSource informerEventSource, Options options) { if (informerEventSource == null) { @@ -557,6 +563,7 @@ public R resourcePatch(R resource, UnaryOperator upda * @param resource type * @throws IllegalStateException if no event source or multiple event sources are found */ + @Experimental(API_MIGHT_CHANGE) @SuppressWarnings({"rawtypes", "unchecked"}) public R resourcePatch( R resource, UnaryOperator updateOperation, Options options) { @@ -794,10 +801,11 @@ public P addFinalizerWithSSA(String finalizerName) { } } - @Experimental("This API might change") /** * Force filtering only if it is made sure that the update not results on a no-op change. See - * details here: https://github.com/operator-framework/java-operator-sdk/pull/3484 + * details here: PR + * 3484 */ + @Experimental("This API might change") public record Options(boolean forceEventFiltering) {} } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java index 13e8e72d74..d18ed0ef27 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java @@ -34,6 +34,7 @@ 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.ResourceOperations; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; import io.javaoperatorsdk.operator.processing.event.ResourceID; import io.javaoperatorsdk.operator.processing.event.source.EventSource; @@ -79,7 +80,10 @@ public UpdateControl reconcile( if (execution == 1) { var cm = prepareConfigMap(resource); switch (mode.get()) { - case NO_RELIST -> context.resourceOperations().serverSideApply(cm, configMapEventSource); + case NO_RELIST -> + context + .resourceOperations() + .serverSideApply(cm, configMapEventSource, new ResourceOperations.Options(true)); case RELIST_AROUND_UPDATE -> { configMapEventSource.simulateOnBeforeList(); var applied = context.resourceOperations().serverSideApply(cm, configMapEventSource); @@ -94,7 +98,9 @@ public UpdateControl reconcile( case RELIST_COMPLETES_BEFORE_UPDATE -> { configMapEventSource.simulateOnBeforeList(); configMapEventSource.simulateOnList(); - context.resourceOperations().serverSideApply(cm, configMapEventSource); + context + .resourceOperations() + .serverSideApply(cm, configMapEventSource, new ResourceOperations.Options(true)); } case RELIST_STARTS_DURING_UPDATE -> { // Drive the event-filtering update path manually so we can fire onBeforeList AFTER the @@ -114,7 +120,8 @@ public UpdateControl reconcile( .withFieldManager(fieldManager) .withPatchType(PatchType.SERVER_SIDE_APPLY) .build()); - }); + }, + true); // See RELIST_AROUND_UPDATE: wait for the own-write event to be buffered while the // re-list is still in progress, so it is tagged as part of the re-list and propagated. configMapEventSource.awaitWatchEventReceived(applied); From ba7a7fa4205baf50bec3200239ec5d00f17f3cb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 10 Jul 2026 17:38:56 +0200 Subject: [PATCH 09/12] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../api/reconciler/ResourceOperations.java | 42 +++++++++++++++---- .../ChangeNamespaceTestReconciler.java | 4 +- .../OwnSecondaryUpdateReconciler.java | 6 ++- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 9fdba153ea..205d389a95 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -145,7 +145,7 @@ public R serverSideApply( * @return updated resource * @param resource type */ - public R serverSideApplyStatus(R resource) { + public R serverSideApplyStatus(R resource, Options options) { return resourcePatch( resource, r -> @@ -158,7 +158,12 @@ public R serverSideApplyStatus(R resource) { .withForce(true) .withFieldManager(context.getControllerConfiguration().fieldManager()) .withPatchType(PatchType.SERVER_SIDE_APPLY) - .build())); + .build()), + options); + } + + public R serverSideApplyStatus(R resource) { + return serverSideApplyStatus(resource, null); } /** @@ -246,7 +251,7 @@ public R update(R resource, Options options) { public R update( R resource, InformerEventSource informerEventSource) { - return update(resource, informerEventSource, null); + return update(resource, informerEventSource, new Options(true)); } /** @@ -334,7 +339,8 @@ public R create( * @param resource type */ public R updateStatus(R resource) { - return resourcePatch(resource, r -> context.getClient().resource(r).updateStatus()); + return resourcePatch( + resource, r -> context.getClient().resource(r).updateStatus(), new Options(true)); } /** @@ -397,7 +403,13 @@ public P updatePrimaryStatus(P resource) { * @param resource type */ public R jsonPatch(R resource, UnaryOperator unaryOperator) { - return resourcePatch(resource, r -> context.getClient().resource(r).edit(unaryOperator)); + return jsonPatch(resource, unaryOperator, null); + } + + public R jsonPatch( + R resource, UnaryOperator unaryOperator, Options options) { + return resourcePatch( + resource, r -> context.getClient().resource(r).edit(unaryOperator), options); } /** @@ -417,8 +429,14 @@ public R jsonPatch(R resource, UnaryOperator unaryOpe * @return updated resource * @param resource type */ + public R jsonPatchStatus( + R resource, UnaryOperator unaryOperator, Options options) { + return resourcePatch( + resource, r -> context.getClient().resource(r).editStatus(unaryOperator), options); + } + public R jsonPatchStatus(R resource, UnaryOperator unaryOperator) { - return resourcePatch(resource, r -> context.getClient().resource(r).editStatus(unaryOperator)); + return jsonPatchStatus(resource, unaryOperator, null); } /** @@ -482,7 +500,11 @@ public P jsonPatchPrimaryStatus(P resource, UnaryOperator

unaryOperator) { * @param resource type */ public R jsonMergePatch(R resource) { - return resourcePatch(resource, r -> context.getClient().resource(r).patch()); + return jsonMergePatch(resource, null); + } + + public R jsonMergePatch(R resource, Options options) { + return resourcePatch(resource, r -> context.getClient().resource(r).patch(), options); } /** @@ -501,7 +523,11 @@ public R jsonMergePatch(R resource) { * @param resource type */ public R jsonMergePatchStatus(R resource) { - return resourcePatch(resource, r -> context.getClient().resource(r).patchStatus()); + return jsonMergePatchStatus(resource, null); + } + + public R jsonMergePatchStatus(R resource, Options options) { + return resourcePatch(resource, r -> context.getClient().resource(r).patchStatus(), options); } /** diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java index d05364fc44..16cec09eee 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java @@ -53,7 +53,9 @@ public UpdateControl reconcile( ChangeNamespaceTestCustomResource primary, Context context) { - context.resourceOperations().serverSideApply(configMap(primary)); + context + .resourceOperations() + .serverSideApply(configMap(primary), new ResourceOperations.Options(true)); if (primary.getStatus() == null) { primary.setStatus(new ChangeNamespaceTestCustomResourceStatus()); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateReconciler.java index f0ac28b955..b65d540ee2 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateReconciler.java @@ -27,6 +27,7 @@ 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.ResourceOperations; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; import io.javaoperatorsdk.operator.processing.event.source.EventSource; import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; @@ -50,7 +51,10 @@ public UpdateControl reconcile( // version actually advances. With the read-cache-after-write filter in place, none of the // resulting watch events should trigger a fresh reconciliation. for (int i = 1; i <= OWN_SSA_COUNT; i++) { - context.resourceOperations().serverSideApply(prepareCM(resource, i), configMapEventSource); + context + .resourceOperations() + .serverSideApply( + prepareCM(resource, i), configMapEventSource, new ResourceOperations.Options(true)); } return UpdateControl.noUpdate(); } From 754f920a56af75e17d992d4726995fe9544d936a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sat, 11 Jul 2026 15:24:11 +0200 Subject: [PATCH 10/12] fix: event filtering edge case with no-op updates - extended MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/api/reconciler/Matcher.java | 23 +++ .../api/reconciler/ResourceOperations.java | 175 ++++++++++++++---- .../KubernetesDependentResource.java | 9 +- .../informer/ManagedInformerEventSource.java | 11 +- .../ChangeNamespaceTestReconciler.java | 2 +- .../OnRelistFilterReconciler.java | 6 +- .../OwnSecondaryUpdateReconciler.java | 4 +- 7 files changed, 188 insertions(+), 42 deletions(-) create mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Matcher.java diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Matcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Matcher.java new file mode 100644 index 0000000000..b5d0685245 --- /dev/null +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Matcher.java @@ -0,0 +1,23 @@ +/* + * 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.api.reconciler; + +import io.fabric8.kubernetes.api.model.HasMetadata; + +public interface Matcher { + + boolean matches(R desired, R actual, Context context); +} diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 205d389a95..594758ecfa 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -166,6 +166,10 @@ public R serverSideApplyStatus(R resource) { return serverSideApplyStatus(resource, null); } + public P serverSideApplyPrimary(P resource) { + return serverSideApplyPrimary(resource, Options.defaultMode()); + } + /** * Server-Side Apply the primary resource. * @@ -180,7 +184,7 @@ public R serverSideApplyStatus(R resource) { * @param resource primary resource for server side apply * @return updated resource */ - public P serverSideApplyPrimary(P resource) { + public P serverSideApplyPrimary(P resource, Options options) { return resourcePatch( resource, r -> @@ -193,7 +197,8 @@ public P serverSideApplyPrimary(P resource) { .withFieldManager(context.getControllerConfiguration().fieldManager()) .withPatchType(PatchType.SERVER_SIDE_APPLY) .build()), - context.eventSourceRetriever().getControllerEventSource()); + context.eventSourceRetriever().getControllerEventSource(), + options); } /** @@ -211,6 +216,10 @@ public P serverSideApplyPrimary(P resource) { * @return updated resource */ public P serverSideApplyPrimaryStatus(P resource) { + return serverSideApplyPrimaryStatus(resource, Options.defaultMode()); + } + + public P serverSideApplyPrimaryStatus(P resource, Options options) { return resourcePatch( resource, r -> @@ -224,11 +233,12 @@ public P serverSideApplyPrimaryStatus(P resource) { .withFieldManager(context.getControllerConfiguration().fieldManager()) .withPatchType(PatchType.SERVER_SIDE_APPLY) .build()), - context.eventSourceRetriever().getControllerEventSource()); + context.eventSourceRetriever().getControllerEventSource(), + options); } public R update(R resource) { - return update(resource, (Options) null); + return update(resource, Options.defaultMode()); } /** @@ -251,7 +261,7 @@ public R update(R resource, Options options) { public R update( R resource, InformerEventSource informerEventSource) { - return update(resource, informerEventSource, new Options(true)); + return update(resource, informerEventSource, Options.defaultMode()); } /** @@ -292,8 +302,12 @@ public R update( * @param resource type */ public R create(R resource) { - return resourcePatch( - resource, r -> context.getClient().resource(r).create(), new Options(true)); + // it is safe to do event filtering for create since check if the resource already exists. + return create(resource, Options.forcedFiltering()); + } + + public R create(R resource, Options options) { + return resourcePatch(resource, r -> context.getClient().resource(r).create(), options); } /** @@ -311,16 +325,13 @@ public R create(R resource) { * @param resource type */ public R create( - R resource, InformerEventSource informerEventSource) { + R resource, InformerEventSource informerEventSource, Options options) { if (informerEventSource == null) { return create(resource); } // it is safe to do event filtering for create since check if the resource already exists. return resourcePatch( - resource, - r -> context.getClient().resource(r).create(), - informerEventSource, - new Options(true)); + resource, r -> context.getClient().resource(r).create(), informerEventSource, options); } /** @@ -339,8 +350,11 @@ public R create( * @param resource type */ public R updateStatus(R resource) { - return resourcePatch( - resource, r -> context.getClient().resource(r).updateStatus(), new Options(true)); + return updateStatus(resource, Options.defaultMode()); + } + + public R updateStatus(R resource, Options options) { + return resourcePatch(resource, r -> context.getClient().resource(r).updateStatus(), options); } /** @@ -358,10 +372,15 @@ public R updateStatus(R resource) { * @return updated resource */ public P updatePrimary(P resource) { + return updatePrimary(resource, Options.defaultMode()); + } + + public P updatePrimary(P resource, Options options) { return resourcePatch( resource, r -> context.getClient().resource(r).update(), - context.eventSourceRetriever().getControllerEventSource()); + context.eventSourceRetriever().getControllerEventSource(), + options); } /** @@ -412,6 +431,18 @@ public R jsonPatch( resource, r -> context.getClient().resource(r).edit(unaryOperator), options); } + public R jsonPatch( + R resource, + UnaryOperator unaryOperator, + InformerEventSource informerEventSource, + Options options) { + return resourcePatch( + resource, + r -> context.getClient().resource(r).edit(unaryOperator), + informerEventSource, + options); + } + /** * Applies a JSON Patch to the resource status subresource. The unaryOperator function is used to * modify the resource status, and the differences are sent as a JSON Patch. @@ -436,7 +467,19 @@ public R jsonPatchStatus( } public R jsonPatchStatus(R resource, UnaryOperator unaryOperator) { - return jsonPatchStatus(resource, unaryOperator, null); + return jsonPatchStatus(resource, unaryOperator, Options.defaultMode()); + } + + public R jsonPatchStatus( + R resource, + UnaryOperator unaryOperator, + InformerEventSource informerEventSource, + Options options) { + return resourcePatch( + resource, + r -> context.getClient().resource(r).editStatus(unaryOperator), + informerEventSource, + options); } /** @@ -455,10 +498,15 @@ public R jsonPatchStatus(R resource, UnaryOperator un * @return updated resource */ public P jsonPatchPrimary(P resource, UnaryOperator

unaryOperator) { + return jsonPatchPrimary(resource, unaryOperator, Options.defaultMode()); + } + + public P jsonPatchPrimary(P resource, UnaryOperator

unaryOperator, Options options) { return resourcePatch( resource, r -> context.getClient().resource(r).edit(unaryOperator), - context.eventSourceRetriever().getControllerEventSource()); + context.eventSourceRetriever().getControllerEventSource(), + options); } /** @@ -477,10 +525,15 @@ public P jsonPatchPrimary(P resource, UnaryOperator

unaryOperator) { * @return updated resource */ public P jsonPatchPrimaryStatus(P resource, UnaryOperator

unaryOperator) { + return jsonPatchPrimaryStatus(resource, unaryOperator, Options.defaultMode()); + } + + public P jsonPatchPrimaryStatus(P resource, UnaryOperator

unaryOperator, Options options) { return resourcePatch( resource, r -> context.getClient().resource(r).editStatus(unaryOperator), - context.eventSourceRetriever().getControllerEventSource()); + context.eventSourceRetriever().getControllerEventSource(), + options); } /** @@ -500,13 +553,19 @@ public P jsonPatchPrimaryStatus(P resource, UnaryOperator

unaryOperator) { * @param resource type */ public R jsonMergePatch(R resource) { - return jsonMergePatch(resource, null); + return jsonMergePatch(resource, Options.defaultMode()); } public R jsonMergePatch(R resource, Options options) { return resourcePatch(resource, r -> context.getClient().resource(r).patch(), options); } + public R jsonMergePatch( + R resource, InformerEventSource informerEventSource, Options options) { + return resourcePatch( + resource, r -> context.getClient().resource(r).patch(), informerEventSource, options); + } + /** * Applies a JSON Merge Patch to the resource status subresource. * @@ -530,6 +589,12 @@ public R jsonMergePatchStatus(R resource, Options option return resourcePatch(resource, r -> context.getClient().resource(r).patchStatus(), options); } + public R jsonMergePatchStatus( + R resource, InformerEventSource informerEventSource, Options options) { + return resourcePatch( + resource, r -> context.getClient().resource(r).patchStatus(), informerEventSource, options); + } + /** * Applies a JSON Merge Patch to the primary resource. Caches the response using the controller's * event source. @@ -546,10 +611,15 @@ public R jsonMergePatchStatus(R resource, Options option * @return updated resource */ public P jsonMergePatchPrimary(P resource) { + return jsonMergePatchPrimary(resource, Options.defaultMode()); + } + + public P jsonMergePatchPrimary(P resource, Options options) { return resourcePatch( resource, r -> context.getClient().resource(r).patch(), - context.eventSourceRetriever().getControllerEventSource()); + context.eventSourceRetriever().getControllerEventSource(), + options); } /** @@ -568,14 +638,19 @@ public P jsonMergePatchPrimary(P resource) { * @see #jsonMergePatchPrimaryStatus(HasMetadata) */ public P jsonMergePatchPrimaryStatus(P resource) { + return jsonMergePatchPrimaryStatus(resource, Options.defaultMode()); + } + + public P jsonMergePatchPrimaryStatus(P resource, Options options) { return resourcePatch( resource, r -> context.getClient().resource(r).patchStatus(), - context.eventSourceRetriever().getControllerEventSource()); + context.eventSourceRetriever().getControllerEventSource(), + options); } public R resourcePatch(R resource, UnaryOperator updateOperation) { - return resourcePatch(resource, updateOperation, (Options) null); + return resourcePatch(resource, updateOperation, Options.defaultMode()); } /** @@ -636,8 +711,12 @@ public R resourcePatch( UnaryOperator updateOperation, ManagedInformerEventSource ies, Options options) { - return ies.eventFilteringUpdateAndCacheResource( - resource, updateOperation, options != null && options.forceEventFiltering()); + if (options != null && options.getMode() == Mode.ONLY_CACHE) { + return ies.updateAndCacheResource(resource, updateOperation); + } else { + return ies.eventFilteringUpdateAndCacheResource( + resource, updateOperation, options != null && options.isForcedFiltering()); + } } /** @@ -827,11 +906,45 @@ public P addFinalizerWithSSA(String finalizerName) { } } - /** - * Force filtering only if it is made sure that the update not results on a no-op change. See - * details here: PR - * 3484 - */ - @Experimental("This API might change") - public record Options(boolean forceEventFiltering) {} + // this is designed with forward compatibility in mynd + @Experimental(API_MIGHT_CHANGE) + public static class Options { + + private static final Options FORCED_FILTERING = new Options(Mode.FORCED_FILTERING); + private static final Options ONLY_CACHE = new Options(Mode.ONLY_CACHE); + private static final Options DEFAULT = new Options(Mode.FILTERING_IF_OPTIMISTIC_LOCKING); + + private final Mode mode; + + public static Options forcedFiltering() { + return FORCED_FILTERING; + } + + public static Options onlyCache() { + return ONLY_CACHE; + } + + public static Options defaultMode() { + return DEFAULT; + } + + private Options(Mode mode) { + this.mode = mode; + } + + public Mode getMode() { + return mode; + } + + public boolean isForcedFiltering() { + return mode != Mode.ONLY_CACHE; + } + } + + @Experimental(API_MIGHT_CHANGE) + public enum Mode { + FILTERING_IF_OPTIMISTIC_LOCKING, + FORCED_FILTERING, + ONLY_CACHE, + } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java index b69cf933a4..152cb1eb0a 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/dependent/kubernetes/KubernetesDependentResource.java @@ -93,7 +93,10 @@ public R create(R desired, P primary, Context

context) { ssa); return ssa ? context.resourceOperations().serverSideApply(desired, eventSource().orElse(null)) - : context.resourceOperations().create(desired, eventSource().orElse(null)); + : context + .resourceOperations() + .create( + desired, eventSource().orElse(null), ResourceOperations.Options.forcedFiltering()); } public R update(R actual, R desired, P primary, Context

context) { @@ -117,7 +120,9 @@ public R update(R actual, R desired, P primary, Context

context) { context .resourceOperations() .serverSideApply( - desired, eventSource().orElse(null), new ResourceOperations.Options(true)); + desired, + eventSource().orElse(null), + ResourceOperations.Options.forcedFiltering()); } else { var updatedActual = GenericResourceUpdater.updateResource(actual, desired, context); updatedResource = 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 9739a6d163..31aea1d655 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 @@ -49,6 +49,8 @@ import io.javaoperatorsdk.operator.processing.event.source.*; import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; +import static io.javaoperatorsdk.operator.api.reconciler.Experimental.API_MIGHT_CHANGE; + @SuppressWarnings("rawtypes") public abstract class ManagedInformerEventSource< R extends HasMetadata, P extends HasMetadata, C extends Informable> @@ -89,8 +91,7 @@ public void changeNamespaces(Set namespaces) { } } - @Experimental( - "Internal API. Use ResourceOperation not this directly, this API might change in the future") + @Experimental(API_MIGHT_CHANGE) public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator updateMethod) { return eventFilteringUpdateAndCacheResource(resourceToUpdate, updateMethod, false); } @@ -100,8 +101,7 @@ public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator< * Also makes sure that the even produced by this update is filtered, thus does not trigger the * reconciliation. */ - @Experimental( - "Internal API. Use ResourceOperation not this directly, this API might change in the future") + @Experimental(API_MIGHT_CHANGE) @SuppressWarnings("unchecked") public R eventFilteringUpdateAndCacheResource( R resourceToUpdate, UnaryOperator updateMethod, boolean forceUpdateFilter) { @@ -149,7 +149,8 @@ public R eventFilteringUpdateAndCacheResource( } } - private R updateAndCacheResource(R resourceToUpdate, UnaryOperator updateOperation) { + @Experimental(API_MIGHT_CHANGE) + public R updateAndCacheResource(R resourceToUpdate, UnaryOperator updateOperation) { var result = updateOperation.apply(resourceToUpdate); handleRecentResourceUpdate(ResourceID.fromResource(resourceToUpdate), result, resourceToUpdate); return result; diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java index 16cec09eee..9c02059e4c 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/changenamespace/ChangeNamespaceTestReconciler.java @@ -55,7 +55,7 @@ public UpdateControl reconcile( context .resourceOperations() - .serverSideApply(configMap(primary), new ResourceOperations.Options(true)); + .serverSideApply(configMap(primary), ResourceOperations.Options.forcedFiltering()); if (primary.getStatus() == null) { primary.setStatus(new ChangeNamespaceTestCustomResourceStatus()); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java index d18ed0ef27..c1ec88e4a0 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java @@ -83,7 +83,8 @@ public UpdateControl reconcile( case NO_RELIST -> context .resourceOperations() - .serverSideApply(cm, configMapEventSource, new ResourceOperations.Options(true)); + .serverSideApply( + cm, configMapEventSource, ResourceOperations.Options.forcedFiltering()); case RELIST_AROUND_UPDATE -> { configMapEventSource.simulateOnBeforeList(); var applied = context.resourceOperations().serverSideApply(cm, configMapEventSource); @@ -100,7 +101,8 @@ public UpdateControl reconcile( configMapEventSource.simulateOnList(); context .resourceOperations() - .serverSideApply(cm, configMapEventSource, new ResourceOperations.Options(true)); + .serverSideApply( + cm, configMapEventSource, ResourceOperations.Options.forcedFiltering()); } case RELIST_STARTS_DURING_UPDATE -> { // Drive the event-filtering update path manually so we can fire onBeforeList AFTER the diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateReconciler.java index b65d540ee2..928e66c4d1 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateReconciler.java @@ -54,7 +54,9 @@ public UpdateControl reconcile( context .resourceOperations() .serverSideApply( - prepareCM(resource, i), configMapEventSource, new ResourceOperations.Options(true)); + prepareCM(resource, i), + configMapEventSource, + ResourceOperations.Options.forcedFiltering()); } return UpdateControl.noUpdate(); } From 4ed51b848415824f4d883224f31d99358d32e713 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sat, 11 Jul 2026 15:32:32 +0200 Subject: [PATCH 11/12] remove un-used class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/api/reconciler/Matcher.java | 23 ------------------- 1 file changed, 23 deletions(-) delete mode 100644 operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Matcher.java diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Matcher.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Matcher.java deleted file mode 100644 index b5d0685245..0000000000 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Matcher.java +++ /dev/null @@ -1,23 +0,0 @@ -/* - * 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.api.reconciler; - -import io.fabric8.kubernetes.api.model.HasMetadata; - -public interface Matcher { - - boolean matches(R desired, R actual, Context context); -} From 5fac791c961d837e420dfd78ae51aa7f5f309c5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sat, 11 Jul 2026 15:37:53 +0200 Subject: [PATCH 12/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../operator/api/reconciler/ResourceOperations.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java index 594758ecfa..5f30a3c55f 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ResourceOperations.java @@ -936,9 +936,8 @@ public Mode getMode() { return mode; } - public boolean isForcedFiltering() { - return mode != Mode.ONLY_CACHE; - } + public boolean isForcedFiltering() { + return mode == Mode.FORCED_FILTERING; } @Experimental(API_MIGHT_CHANGE)