From 0d7c35dda507f5253ae5b3543c460d5d618b413f Mon Sep 17 00:00:00 2001 From: xstefank Date: Thu, 4 Jun 2026 14:26:58 +0200 Subject: [PATCH] fix: deletion not reconciled when merged into eventFilteringUpdate response Signed-off-by: xstefank --- .../informer/ManagedInformerEventSource.java | 19 +++- ...etionDuringStatusUpdateCustomResource.java | 28 +++++ .../DeletionDuringStatusUpdateIT.java | 107 ++++++++++++++++++ .../DeletionDuringStatusUpdateReconciler.java | 79 +++++++++++++ .../DeletionDuringStatusUpdateStatus.java | 29 +++++ 5 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deletionduringstatusupdate/DeletionDuringStatusUpdateCustomResource.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deletionduringstatusupdate/DeletionDuringStatusUpdateIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deletionduringstatusupdate/DeletionDuringStatusUpdateReconciler.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deletionduringstatusupdate/DeletionDuringStatusUpdateStatus.java 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 f021101229..5278bbb38c 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 @@ -97,6 +97,7 @@ public void changeNamespaces(Set namespaces) { public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator updateMethod) { ResourceID id = ResourceID.fromResource(resourceToUpdate); log.debug("Starting event filtering and caching update"); + final boolean wasMarkedForDeletion = resourceToUpdate.isMarkedForDeletion(); R updatedResource = null; try { temporaryResourceCache.startEventFilteringModify(id); @@ -141,7 +142,23 @@ public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator< ? ((ResourceDeleteEvent) r).isDeletedFinalStateUnknown() : null); }, - () -> log.debug("No new event present after the filtering update")); + () -> { + log.debug("No new event present after the filtering update"); + // If the resource was marked for deletion concurrently with this update, the deletion + // event arrived at a lower RV than the update result and was deferred then dropped by + // doneEventFilterModify (deferred RV < lastOwnUpdatedRV). The API server merges the + // deletionTimestamp into the update response, so updatedForLambda already carries it, + // but no event was propagated to the reconciler. Trigger one now so the finalizer can + // be removed. + if (updatedForLambda != null + && updatedForLambda.isMarkedForDeletion() + && !wasMarkedForDeletion) { + log.debug( + "Resource {} was marked for deletion during update, triggering reconciliation", + id); + handleEvent(ResourceAction.UPDATED, updatedForLambda, resourceToUpdate, null); + } + }); } } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deletionduringstatusupdate/DeletionDuringStatusUpdateCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deletionduringstatusupdate/DeletionDuringStatusUpdateCustomResource.java new file mode 100644 index 0000000000..5cb1170c34 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deletionduringstatusupdate/DeletionDuringStatusUpdateCustomResource.java @@ -0,0 +1,28 @@ +/* + * 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.deletionduringstatusupdate; + +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("ddsu") +public class DeletionDuringStatusUpdateCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deletionduringstatusupdate/DeletionDuringStatusUpdateIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deletionduringstatusupdate/DeletionDuringStatusUpdateIT.java new file mode 100644 index 0000000000..7574dd07b4 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deletionduringstatusupdate/DeletionDuringStatusUpdateIT.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.deletionduringstatusupdate; + +import java.time.Duration; +import java.util.Collections; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * Regression test for: deletion event dropped when resource is deleted concurrently with a status + * update. + */ +class DeletionDuringStatusUpdateIT { + + static final String RESOURCE_NAME = "test-resource"; + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(new DeletionDuringStatusUpdateReconciler()) + .build(); + + @AfterEach + void forceCleanup() { + // If the test failed, remove the finalizer so the resource can be deleted + var res = extension.get(DeletionDuringStatusUpdateCustomResource.class, RESOURCE_NAME); + if (res != null) { + res.getMetadata().setFinalizers(Collections.emptyList()); + extension.replace(res); + extension.delete(res); + } + } + + @Test + void deletionDuringStatusUpdateTriggersCleanup() throws InterruptedException { + var reconciler = extension.getReconcilerOfType(DeletionDuringStatusUpdateReconciler.class); + + extension.create(testResource()); + + // Wait until the reconciler is inside the update operation (active-update window is open) + assertThat(reconciler.patchStartedLatch.await(30, TimeUnit.SECONDS)) + .as("reconciler should enter the patch update operation") + .isTrue(); + + // Issue delete — K8s sets deletionTimestamp while the active-update window is open + extension.delete(testResource()); + + // Wait for deletionTimestamp to be confirmed on the resource in K8s + await() + .atMost(Duration.ofSeconds(30)) + .until( + () -> { + var res = + extension.get(DeletionDuringStatusUpdateCustomResource.class, RESOURCE_NAME); + return res != null && res.isMarkedForDeletion(); + }); + + // Signal the reconciler to proceed with the actual PATCH. K8s will merge deletionTimestamp + // into the response - the deletion event (lower RV) is now deferred and will be dropped + // without the fix. + reconciler.deleteConfirmedLatch.countDown(); + + // cleanup() must be called — the deletion must not be silently lost + assertThat(reconciler.cleanupCalledLatch.await(30, TimeUnit.SECONDS)) + .as("cleanup() must be called after the status update that races with the delete") + .isTrue(); + + // Resource must eventually disappear (finalizer removed) + await() + .atMost(Duration.ofSeconds(30)) + .untilAsserted( + () -> + assertThat( + extension.get( + DeletionDuringStatusUpdateCustomResource.class, RESOURCE_NAME)) + .isNull()); + } + + DeletionDuringStatusUpdateCustomResource testResource() { + var resource = new DeletionDuringStatusUpdateCustomResource(); + resource.setMetadata(new ObjectMetaBuilder().withName(RESOURCE_NAME).build()); + return resource; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deletionduringstatusupdate/DeletionDuringStatusUpdateReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deletionduringstatusupdate/DeletionDuringStatusUpdateReconciler.java new file mode 100644 index 0000000000..db05321ee7 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deletionduringstatusupdate/DeletionDuringStatusUpdateReconciler.java @@ -0,0 +1,79 @@ +/* + * 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.deletionduringstatusupdate; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import io.javaoperatorsdk.operator.api.reconciler.Cleaner; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; + +@ControllerConfiguration +public class DeletionDuringStatusUpdateReconciler + implements Reconciler, + Cleaner { + + final CountDownLatch patchStartedLatch = new CountDownLatch(1); + final CountDownLatch deleteConfirmedLatch = new CountDownLatch(1); + final CountDownLatch cleanupCalledLatch = new CountDownLatch(1); + + @Override + public UpdateControl reconcile( + DeletionDuringStatusUpdateCustomResource resource, + Context context) + throws InterruptedException { + if (resource.isMarkedForDeletion()) { + return UpdateControl.noUpdate(); + } + + var status = new DeletionDuringStatusUpdateStatus(); + status.setReady(true); + resource.setStatus(status); + + context + .resourceOperations() + .resourcePatch( + resource, + r -> { + patchStartedLatch.countDown(); + try { + if (!deleteConfirmedLatch.await(30, TimeUnit.SECONDS)) { + throw new RuntimeException("Timed out waiting for delete confirmation"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + r.getMetadata().setResourceVersion(null); + return context.getClient().resource(r).patchStatus(); + }, + context.eventSourceRetriever().getControllerEventSource()); + + return UpdateControl.noUpdate(); + } + + @Override + public DeleteControl cleanup( + DeletionDuringStatusUpdateCustomResource resource, + Context context) { + cleanupCalledLatch.countDown(); + return DeleteControl.defaultDelete(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deletionduringstatusupdate/DeletionDuringStatusUpdateStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deletionduringstatusupdate/DeletionDuringStatusUpdateStatus.java new file mode 100644 index 0000000000..52da516d00 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deletionduringstatusupdate/DeletionDuringStatusUpdateStatus.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.deletionduringstatusupdate; + +public class DeletionDuringStatusUpdateStatus { + + private boolean ready; + + public boolean isReady() { + return ready; + } + + public void setReady(boolean ready) { + this.ready = ready; + } +}