From aa6410e1a9b78d939debc851fad0edbedc2657d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Sun, 7 Jun 2026 16:51:30 +0200 Subject: [PATCH 01/17] improve: filter only own updates for read-after-write-conistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../controller/ControllerEventSource.java | 2 +- .../source/informer/EventFilterDetails.java | 11 ++++++ .../source/informer/InformerEventSource.java | 4 +-- .../informer/TemporaryResourceCache.java | 36 ++++++++++++++----- 4 files changed, 41 insertions(+), 12 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java index 07d59e039a..89fd2425d8 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java @@ -141,7 +141,7 @@ private void handleOnAddOrUpdate( ResourceAction action, T oldCustomResource, T newCustomResource) { var handling = temporaryResourceCache.onAddOrUpdateEvent(action, newCustomResource, oldCustomResource); - if (handling == EventHandling.NEW) { + if (handling == EventHandling.NEW || handling == EventHandling.IN_BETWEEN) { handleEvent(action, newCustomResource, oldCustomResource, null); } else if (log.isDebugEnabled()) { log.debug("{} event propagation for action: {}", handling, action); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetails.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetails.java index b747c69dff..00b3c02931 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetails.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetails.java @@ -15,7 +15,9 @@ */ package io.javaoperatorsdk.operator.processing.event.source.informer; +import java.util.HashSet; import java.util.Optional; +import java.util.Set; import java.util.function.UnaryOperator; import io.fabric8.kubernetes.api.model.HasMetadata; @@ -27,6 +29,7 @@ class EventFilterDetails { private int activeUpdates = 0; private ResourceEvent lastEvent; private String lastOwnUpdatedResourceVersion; + private Set allOwnResourceVersions = new HashSet<>(); public void increaseActiveUpdates() { activeUpdates = activeUpdates + 1; @@ -69,4 +72,12 @@ public Optional getLatestEventAfterLastUpdateEvent() { public int getActiveUpdates() { return activeUpdates; } + + void addToOwnResourceVersions(String updateVersion) { + allOwnResourceVersions.add(updateVersion); + } + + public boolean isOwnResourceVersions(String resourceVersion) { + return allOwnResourceVersions.contains(resourceVersion); + } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java index 93d3eb5e80..f3550470fb 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java @@ -154,12 +154,12 @@ private synchronized void onAddOrUpdate(ResourceAction action, R newObject, R ol var eventHandling = temporaryResourceCache.onAddOrUpdateEvent(action, newObject, oldObject); - if (eventHandling != EventHandling.NEW) { + if (eventHandling != EventHandling.NEW && eventHandling != EventHandling.IN_BETWEEN) { log.debug( "{} event propagation", eventHandling == EventHandling.DEFER ? "Deferring" : "Skipping"); } else if (eventAcceptedByFilter(action, newObject, oldObject)) { log.debug( - "Propagating event for {}, resource with same version not result of a reconciliation.", + "Propagating event for {}, resource with same version not result of a our update.", action); propagateEvent(newObject); } else { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java index 405f52cc8d..feaa5cc04a 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java @@ -65,6 +65,7 @@ public class TemporaryResourceCache { public enum EventHandling { DEFER, OBSOLETE, + IN_BETWEEN, NEW } @@ -145,17 +146,30 @@ private synchronized EventHandling onEvent( // additional event result = comp == 0 ? EventHandling.OBSOLETE : EventHandling.NEW; } else { - result = EventHandling.OBSOLETE; + // in this case we received and event that might be in some edge case that was + // already used in reconciler or after that, but before our updated resource version. + // That would be hard to distinguish, so for those we are propagating the event further. + log.debug("Received in between event."); + result = EventHandling.IN_BETWEEN; } } - var ed = activeUpdates.get(resourceId); - if (ed != null && result != EventHandling.OBSOLETE) { - log.debug("Setting last event for id: {} delete: {}", resourceId, delete); - ed.setLastEvent( - delete - ? new ResourceDeleteEvent(ResourceAction.DELETED, resourceId, resource, unknownState) - : new ExtendedResourceEvent(action, resourceId, resource, prevResourceVersion)); - return EventHandling.DEFER; + var au = activeUpdates.get(resourceId); + if (au != null) { + if (result == EventHandling.IN_BETWEEN) { + return au.isOwnResourceVersions(resource.getMetadata().getResourceVersion()) + ? EventHandling.DEFER + : EventHandling.IN_BETWEEN; + } + if (result == EventHandling.NEW) { + log.debug("Setting last event for id: {} delete: {}", resourceId, delete); + au.setLastEvent( + delete + ? new ResourceDeleteEvent( + ResourceAction.DELETED, resourceId, resource, unknownState) + : new ExtendedResourceEvent(action, resourceId, resource, prevResourceVersion)); + return EventHandling.DEFER; + } + return result; } else { return result; } @@ -216,6 +230,10 @@ public synchronized void putResource(T newResource) { newResource.getMetadata().getResourceVersion(), resourceId); cache.put(resourceId, newResource); + var au = activeUpdates.get(resourceId); + if (au != null) { + au.addToOwnResourceVersions(newResource.getMetadata().getResourceVersion()); + } } } From 629d3084b781c7774dfb1ed046ea813815fb82bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 8 Jun 2026 11:00:58 +0200 Subject: [PATCH 02/17] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../controller/ControllerEventSource.java | 2 +- .../source/informer/InformerEventSource.java | 2 +- .../informer/TemporaryResourceCache.java | 10 +-- .../informer/InformerEventSourceTest.java | 59 ++++++++++++++++++ .../informer/TemporaryResourceCacheTest.java | 62 +++++++++++++++++++ 5 files changed, 128 insertions(+), 7 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java index 89fd2425d8..23b00499ba 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java @@ -141,7 +141,7 @@ private void handleOnAddOrUpdate( ResourceAction action, T oldCustomResource, T newCustomResource) { var handling = temporaryResourceCache.onAddOrUpdateEvent(action, newCustomResource, oldCustomResource); - if (handling == EventHandling.NEW || handling == EventHandling.IN_BETWEEN) { + if (handling == EventHandling.NEW || handling == EventHandling.INTERMEDIATE) { handleEvent(action, newCustomResource, oldCustomResource, null); } else if (log.isDebugEnabled()) { log.debug("{} event propagation for action: {}", handling, action); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java index f3550470fb..eaf9ee8821 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java @@ -154,7 +154,7 @@ private synchronized void onAddOrUpdate(ResourceAction action, R newObject, R ol var eventHandling = temporaryResourceCache.onAddOrUpdateEvent(action, newObject, oldObject); - if (eventHandling != EventHandling.NEW && eventHandling != EventHandling.IN_BETWEEN) { + if (eventHandling != EventHandling.NEW && eventHandling != EventHandling.INTERMEDIATE) { log.debug( "{} event propagation", eventHandling == EventHandling.DEFER ? "Deferring" : "Skipping"); } else if (eventAcceptedByFilter(action, newObject, oldObject)) { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java index feaa5cc04a..3593f797d8 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java @@ -65,7 +65,7 @@ public class TemporaryResourceCache { public enum EventHandling { DEFER, OBSOLETE, - IN_BETWEEN, + INTERMEDIATE, NEW } @@ -149,16 +149,16 @@ private synchronized EventHandling onEvent( // in this case we received and event that might be in some edge case that was // already used in reconciler or after that, but before our updated resource version. // That would be hard to distinguish, so for those we are propagating the event further. - log.debug("Received in between event."); - result = EventHandling.IN_BETWEEN; + log.debug("Received intermediate event."); + result = EventHandling.INTERMEDIATE; } } var au = activeUpdates.get(resourceId); if (au != null) { - if (result == EventHandling.IN_BETWEEN) { + if (result == EventHandling.INTERMEDIATE) { return au.isOwnResourceVersions(resource.getMetadata().getResourceVersion()) ? EventHandling.DEFER - : EventHandling.IN_BETWEEN; + : EventHandling.INTERMEDIATE; } if (result == EventHandling.NEW) { log.debug("Setting last event for id: {} delete: {}", resourceId, delete); 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 fe78bd3147..f52dd2e292 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 @@ -149,6 +149,15 @@ void processEventPropagationWithIncorrectAnnotation() { verify(eventHandlerMock, times(1)).handleEvent(any()); } + @Test + void propagatesIntermediateEventHandling() { + when(temporaryResourceCache.onAddOrUpdateEvent(any(), any(), any())) + .thenReturn(EventHandling.INTERMEDIATE); + informerEventSource.onUpdate(testDeployment(), testDeployment()); + + verify(eventHandlerMock, times(1)).handleEvent(any()); + } + @Test void propagateEventAndRemoveResourceFromTempCacheIfResourceVersionMismatch() { withRealTemporaryResourceCache(); @@ -439,6 +448,56 @@ void filteringUpdateAndGhostCheckWithNamespaceChange() { assertThat(temporaryResourceCache.getResourceFromCache(resourceId)).isEmpty(); } + @Test + void propagatesIntermediateEventForExternalUpdateDuringFiltering() { + // Causal-dependency fix: another controller updated the resource between our read + // and our write. The informer delivers that update during our active filter; since + // its resource version is NOT one of our own writes, it must be propagated. + var realCache = realCacheWithWatchedNamespace(); + var resourceId = ResourceID.fromResource(testDeployment()); + + realCache.startEventFilteringModify(resourceId); + realCache.putResource(deploymentWithResourceVersion(4)); + + informerEventSource.onUpdate( + deploymentWithResourceVersion(2), deploymentWithResourceVersion(3)); + + verify(eventHandlerMock, times(1)).handleEvent(any()); + + realCache.doneEventFilterModify(resourceId, "4"); + } + + @Test + void doesNotPropagateIntermediateEventForOurOwnIntermediateUpdate() { + // Two consecutive own writes within a single filter window: the older one's event + // arrives after the newer one has been cached. Because the version is recorded as + // our own, the event must be deferred (not propagated). + var realCache = realCacheWithWatchedNamespace(); + var resourceId = ResourceID.fromResource(testDeployment()); + + realCache.startEventFilteringModify(resourceId); + realCache.putResource(deploymentWithResourceVersion(3)); + realCache.putResource(deploymentWithResourceVersion(4)); + + informerEventSource.onUpdate( + deploymentWithResourceVersion(2), deploymentWithResourceVersion(3)); + + verify(eventHandlerMock, never()).handleEvent(any()); + + realCache.doneEventFilterModify(resourceId, "4"); + } + + private TemporaryResourceCache realCacheWithWatchedNamespace() { + var mes = mock(ManagedInformerEventSource.class); + var mim = mock(InformerManager.class); + when(mes.manager()).thenReturn(mim); + when(mim.isWatchingNamespace(any())).thenReturn(true); + when(mim.lastSyncResourceVersion(any())).thenReturn("1"); + temporaryResourceCache = spy(new TemporaryResourceCache<>(true, mes)); + informerEventSource.setTemporalResourceCache(temporaryResourceCache); + return temporaryResourceCache; + } + private void assertNoEventProduced() { await() .pollDelay(Duration.ofMillis(50)) diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCacheTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCacheTest.java index 9a58b83f88..6d0c4b88d4 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCacheTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCacheTest.java @@ -294,6 +294,68 @@ void putAfterEventWithEventFilteringWithPost() { assertTrue(postEvent.isPresent()); } + @Test + void intermediateEventPropagatedWhenNoActiveUpdate() { + // Cache holds a newer version from a prior own write; no active filter is in progress. + // An older event arriving used to be OBSOLETE; now it must be propagated as INTERMEDIATE + // so callers can react to changes that happened between read and write. + var olderEvent = testResource(); + var newer = testResource(); + newer.getMetadata().setResourceVersion("3"); + + temporaryResourceCache.putResource(newer); + assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(olderEvent))) + .isPresent(); + + var result = + temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.UPDATED, olderEvent, null); + + assertThat(result).isEqualTo(EventHandling.INTERMEDIATE); + } + + @Test + void intermediateEventPropagatedWhenNotOurOwnUpdate() { + // Causal-dependency scenario: a third party updated the resource between our read and + // our write. Its version arrives as an event but is NOT in our own resource versions, + // so it must be propagated (INTERMEDIATE), not deferred. + var external = testResource(); // rv=2 — written by another controller + var resourceId = ResourceID.fromResource(external); + + temporaryResourceCache.startEventFilteringModify(resourceId); + + var ourUpdate = testResource(); + ourUpdate.getMetadata().setResourceVersion("3"); + temporaryResourceCache.putResource(ourUpdate); + + var result = temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.UPDATED, external, null); + + assertThat(result).isEqualTo(EventHandling.INTERMEDIATE); + } + + @Test + void intermediateEventDeferredWhenItIsOurOwnIntermediateUpdate() { + // Two consecutive own writes within the same filter window: the older one's event + // arrives after the newer one is cached. Because the version is recorded as our own, + // the event must be DEFERred rather than propagated. + var testResource = testResource(); + var resourceId = ResourceID.fromResource(testResource); + + temporaryResourceCache.startEventFilteringModify(resourceId); + + var ourFirst = testResource(); // rv=2 + temporaryResourceCache.putResource(ourFirst); + + var ourSecond = testResource(); + ourSecond.getMetadata().setResourceVersion("3"); + + temporaryResourceCache.startEventFilteringModify(resourceId); + temporaryResourceCache.putResource(ourSecond); + + var result = temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.UPDATED, ourFirst, null); + + assertThat(result).isEqualTo(EventHandling.DEFER); + } + @Test void rapidDeletion() { var testResource = testResource(); From 813e26e681e9a1cfff4e9b33877480b4455396e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 8 Jun 2026 14:00:47 +0200 Subject: [PATCH 03/17] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../controller/ControllerEventSourceTest.java | 81 +++++++++++++++++++ .../informer/InformerEventSourceTest.java | 58 ++++++++----- 2 files changed, 118 insertions(+), 21 deletions(-) diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java index 4528fa8a83..72ea7df27f 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java @@ -35,12 +35,14 @@ import io.javaoperatorsdk.operator.processing.Controller; import io.javaoperatorsdk.operator.processing.event.EventHandler; import io.javaoperatorsdk.operator.processing.event.EventSourceManager; +import io.javaoperatorsdk.operator.processing.event.ResourceID; import io.javaoperatorsdk.operator.processing.event.source.AbstractEventSourceTestBase; import io.javaoperatorsdk.operator.processing.event.source.EventFilterTestUtils; import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; import io.javaoperatorsdk.operator.processing.event.source.filter.GenericFilter; import io.javaoperatorsdk.operator.processing.event.source.filter.OnAddFilter; import io.javaoperatorsdk.operator.processing.event.source.filter.OnUpdateFilter; +import io.javaoperatorsdk.operator.processing.event.source.informer.TemporaryResourceCache; import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; import static io.javaoperatorsdk.operator.processing.event.source.EventFilterTestUtils.withResourceVersion; @@ -227,6 +229,74 @@ void eventFilteringExceptionDuringUpdate() { expectHandleEvent(2, 1); } + @Test + void propagatesIntermediateEventForExternalUpdateDuringFiltering() { + // Causal-dependency scenario: a third party updated the resource between our read and + // our write. The informer delivers that update during our active filter; since its + // resource version is NOT one of our own writes, it must be propagated. + var src = new TestableControllerEventSource(new TestController(null, null, null)); + setUpSource(src, true, controllerConfig); + + var resourceId = ResourceID.fromResource(TestUtils.testCustomResource1()); + + // first filter writes rv 4 (our own); a second concurrent filter keeps the + // active-updates window open while the event below is processed + var latch1 = sendForEventFilteringUpdate(4); + var latch2 = sendForEventFilteringUpdate(testResourceWithVersion(4), 5); + + latch1.countDown(); + awaitCachedResourceVersion(src.tempCache(), resourceId, "4"); + + // external update with rv 3 (older than our cached rv 4) — must propagate + source.onUpdate(testResourceWithVersion(2), testResourceWithVersion(3)); + + verify(eventHandler, times(1)).handleEvent(any()); + + latch2.countDown(); + } + + @Test + void doesNotPropagateIntermediateEventForOurOwnIntermediateUpdate() { + // Two consecutive own writes (rv 3 then rv 4) within an open filter window: an event + // for the older own version must be deferred since it's recognized as our own. A + // third concurrent filter keeps the active-updates window open while the event below + // is processed. + var src = new TestableControllerEventSource(new TestController(null, null, null)); + setUpSource(src, true, controllerConfig); + + var resourceId = ResourceID.fromResource(TestUtils.testCustomResource1()); + + var latch1 = sendForEventFilteringUpdate(3); + var latch2 = sendForEventFilteringUpdate(testResourceWithVersion(3), 4); + var latch3 = sendForEventFilteringUpdate(testResourceWithVersion(4), 5); + + latch1.countDown(); + awaitCachedResourceVersion(src.tempCache(), resourceId, "3"); + latch2.countDown(); + awaitCachedResourceVersion(src.tempCache(), resourceId, "4"); + + // event for our own rv 3 (older than cached rv 4) — must be deferred + source.onUpdate(testResourceWithVersion(2), testResourceWithVersion(3)); + + verify(eventHandler, never()).handleEvent(any()); + + latch3.countDown(); + } + + private void awaitCachedResourceVersion( + TemporaryResourceCache cache, + ResourceID resourceId, + String resourceVersion) { + await() + .untilAsserted( + () -> + assertThat( + cache + .getResourceFromCache(resourceId) + .map(r -> r.getMetadata().getResourceVersion())) + .hasValue(resourceVersion)); + } + private void expectHandleEvent(int newResourceVersion, int oldResourceVersion) { await() .untilAsserted( @@ -330,4 +400,15 @@ public TestConfiguration( false); } } + + private static class TestableControllerEventSource + extends ControllerEventSource { + TestableControllerEventSource(Controller controller) { + super(controller); + } + + TemporaryResourceCache tempCache() { + return temporaryResourceCache; + } + } } 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 f52dd2e292..b82d280397 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 @@ -453,49 +453,64 @@ void propagatesIntermediateEventForExternalUpdateDuringFiltering() { // Causal-dependency fix: another controller updated the resource between our read // and our write. The informer delivers that update during our active filter; since // its resource version is NOT one of our own writes, it must be propagated. - var realCache = realCacheWithWatchedNamespace(); + withRealTemporaryResourceCache(); + var resourceId = ResourceID.fromResource(testDeployment()); - realCache.startEventFilteringModify(resourceId); - realCache.putResource(deploymentWithResourceVersion(4)); + // first filter writes rv 4 (our own); a second concurrent filter keeps the + // active-updates window open so the event below hits the active path + var latch1 = sendForEventFilteringUpdate(4); + var latch2 = sendForEventFilteringUpdate(deploymentWithResourceVersion(4), 5); + + latch1.countDown(); + awaitCachedResourceVersion(resourceId, "4"); + // external update with rv 3 (older than our cached rv 4) — must propagate informerEventSource.onUpdate( deploymentWithResourceVersion(2), deploymentWithResourceVersion(3)); verify(eventHandlerMock, times(1)).handleEvent(any()); - realCache.doneEventFilterModify(resourceId, "4"); + latch2.countDown(); } @Test void doesNotPropagateIntermediateEventForOurOwnIntermediateUpdate() { - // Two consecutive own writes within a single filter window: the older one's event - // arrives after the newer one has been cached. Because the version is recorded as - // our own, the event must be deferred (not propagated). - var realCache = realCacheWithWatchedNamespace(); + // Two consecutive own writes (rv 3 then rv 4) within an open filter window: an + // event for the older own version must be deferred since it's recognized as our own. + // A third concurrent filter keeps the active-updates window open while the event + // below is processed. + withRealTemporaryResourceCache(); + var resourceId = ResourceID.fromResource(testDeployment()); - realCache.startEventFilteringModify(resourceId); - realCache.putResource(deploymentWithResourceVersion(3)); - realCache.putResource(deploymentWithResourceVersion(4)); + var latch1 = sendForEventFilteringUpdate(3); + var latch2 = sendForEventFilteringUpdate(deploymentWithResourceVersion(3), 4); + var latch3 = sendForEventFilteringUpdate(deploymentWithResourceVersion(4), 5); + + latch1.countDown(); + awaitCachedResourceVersion(resourceId, "3"); + latch2.countDown(); + awaitCachedResourceVersion(resourceId, "4"); + // event for our own rv 3 (older than cached rv 4) — must be deferred informerEventSource.onUpdate( deploymentWithResourceVersion(2), deploymentWithResourceVersion(3)); verify(eventHandlerMock, never()).handleEvent(any()); - realCache.doneEventFilterModify(resourceId, "4"); + latch3.countDown(); } - private TemporaryResourceCache realCacheWithWatchedNamespace() { - var mes = mock(ManagedInformerEventSource.class); - var mim = mock(InformerManager.class); - when(mes.manager()).thenReturn(mim); - when(mim.isWatchingNamespace(any())).thenReturn(true); - when(mim.lastSyncResourceVersion(any())).thenReturn("1"); - temporaryResourceCache = spy(new TemporaryResourceCache<>(true, mes)); - informerEventSource.setTemporalResourceCache(temporaryResourceCache); - return temporaryResourceCache; + private void awaitCachedResourceVersion(ResourceID resourceId, String resourceVersion) { + await() + .untilAsserted( + () -> + assertThat( + temporaryResourceCache + .getResourceFromCache(resourceId) + .map(d -> d.getMetadata().getResourceVersion())) + .hasValue(resourceVersion)); } private void assertNoEventProduced() { @@ -542,6 +557,7 @@ private void withRealTemporaryResourceCache() { var mes = mock(ManagedInformerEventSource.class); var mim = mock(InformerManager.class); when(mes.manager()).thenReturn(mim); + when(mim.isWatchingNamespace(any())).thenReturn(true); when(mim.lastSyncResourceVersion(any())).thenReturn("1"); temporaryResourceCache = spy(new TemporaryResourceCache<>(true, mes)); From d0fb224e363bd9ce6064db531e1557c06f776d95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 8 Jun 2026 16:45:21 +0200 Subject: [PATCH 04/17] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../controller/ControllerEventSource.java | 2 +- .../source/informer/EventFilterDetails.java | 32 ++++-- .../informer/ManagedInformerEventSource.java | 22 +++- .../informer/TemporaryResourceCache.java | 18 ++- ...etionDuringStatusUpdateCustomResource.java | 28 +++++ .../DeletionDuringStatusUpdateIT.java | 107 ++++++++++++++++++ .../DeletionDuringStatusUpdateReconciler.java | 80 +++++++++++++ .../DeletionDuringStatusUpdateStatus.java | 29 +++++ 8 files changed, 298 insertions(+), 20 deletions(-) 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/controller/ControllerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java index 23b00499ba..3e0bc1617b 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java @@ -84,7 +84,7 @@ protected synchronized void handleEvent( try { if (log.isDebugEnabled()) { log.debug("Event received with action: {}", action); - log.trace("Event Old resource: {},\n new resource: {}", oldResource, resource); + log.debug("Event Old resource: {},\n new resource: {}", oldResource, resource); } MDCUtils.addResourceInfo(resource); controller.getEventSourceManager().broadcastOnResourceEvent(action, resource, oldResource); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetails.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetails.java index 00b3c02931..4e1bd4ac47 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetails.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetails.java @@ -27,9 +27,10 @@ class EventFilterDetails { private int activeUpdates = 0; - private ResourceEvent lastEvent; + private ResourceEvent lastRelevantEvent; private String lastOwnUpdatedResourceVersion; private Set allOwnResourceVersions = new HashSet<>(); + private Set uncertainEvents = new HashSet<>(); public void increaseActiveUpdates() { activeUpdates = activeUpdates + 1; @@ -53,18 +54,23 @@ public boolean decreaseActiveUpdates(String updatedResourceVersion) { return activeUpdates == 0; } - public void setLastEvent(ResourceEvent event) { - lastEvent = event; + public void setLastRelevantEvent(ResourceEvent event) { + lastRelevantEvent = event; } - public Optional getLatestEventAfterLastUpdateEvent() { - if (lastEvent != null - && (lastOwnUpdatedResourceVersion == null - || ReconcilerUtilsInternal.compareResourceVersions( - lastEvent.getResource().orElseThrow().getMetadata().getResourceVersion(), - lastOwnUpdatedResourceVersion) - > 0)) { - return Optional.of(lastEvent); + public Optional getRelevantEventToPropagate() { + if (lastRelevantEvent != null + && (lastOwnUpdatedResourceVersion == null + || ReconcilerUtilsInternal.compareResourceVersions( + lastRelevantEvent + .getResource() + .orElseThrow() + .getMetadata() + .getResourceVersion(), + lastOwnUpdatedResourceVersion) + > 0) + || allOwnResourceVersions.containsAll(uncertainEvents)) { + return Optional.of(lastRelevantEvent); } return Optional.empty(); } @@ -80,4 +86,8 @@ void addToOwnResourceVersions(String updateVersion) { public boolean isOwnResourceVersions(String resourceVersion) { return allOwnResourceVersions.contains(resourceVersion); } + + public void addUncertainResourceVersion(String resourceVersion) { + uncertainEvents.add(resourceVersion); + } } 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..07009f7db8 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 @@ -112,7 +112,6 @@ public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator< var updatedForLambda = updatedResource; res.ifPresentOrElse( r -> { - R latestResource = (R) r.getResource().orElseThrow(); // as previous resource version we use the one from successful update, since // we process new event here only if that is more recent then the event from our update. // Note that this is equivalent with the scenario when an informer watch connection @@ -123,8 +122,25 @@ public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator< (r instanceof ExtendedResourceEvent) ? (R) ((ExtendedResourceEvent) r).getPreviousResource().orElse(null) : null; - R prevVersionOfResource = - updatedForLambda != null ? updatedForLambda : extendedResourcePrevVersion; + R prevVersionOfResource = null; + R latestResource = null; + if (updatedForLambda != null) { + var updatedNewerThanRelated = + ReconcilerUtilsInternal.compareResourceVersions( + updatedForLambda, r.getResource().orElseThrow()) + > 0; + prevVersionOfResource = + updatedNewerThanRelated + ? (extendedResourcePrevVersion != null + ? extendedResourcePrevVersion + : prevVersionOfResource) + : updatedForLambda; + latestResource = updatedForLambda; + } else { + prevVersionOfResource = extendedResourcePrevVersion; + latestResource = (R) r.getResource().orElseThrow(); + } + if (log.isDebugEnabled()) { log.debug( "Previous resource version: {} resource from update present: {}" diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java index 3593f797d8..a1517d86b0 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java @@ -98,7 +98,7 @@ public synchronized Optional doneEventFilterModify( return Optional.empty(); } activeUpdates.remove(resourceID); - var res = ed.getLatestEventAfterLastUpdateEvent(); + var res = ed.getRelevantEventToPropagate(); log.debug( "Zero active updates for resource id: {}; event after update event: {}; updated resource" + " version: {}", @@ -156,13 +156,21 @@ private synchronized EventHandling onEvent( var au = activeUpdates.get(resourceId); if (au != null) { if (result == EventHandling.INTERMEDIATE) { - return au.isOwnResourceVersions(resource.getMetadata().getResourceVersion()) - ? EventHandling.DEFER - : EventHandling.INTERMEDIATE; + var ownResourceVersion = + au.isOwnResourceVersions(resource.getMetadata().getResourceVersion()); + log.debug("Handling intermediate event. Own resource version: {}", ownResourceVersion); + return ownResourceVersion ? EventHandling.DEFER : EventHandling.INTERMEDIATE; } if (result == EventHandling.NEW) { + if (cached == null) { + // this is for the case when temp cache is null, we receive an event + // there is ongoing filtering-caching update; at this point we cannot tell + // if that event is from our update + log.debug("Setting uncertain resource version."); + au.addUncertainResourceVersion(resource.getMetadata().getResourceVersion()); + } log.debug("Setting last event for id: {} delete: {}", resourceId, delete); - au.setLastEvent( + au.setLastRelevantEvent( delete ? new ResourceDeleteEvent( ResourceAction.DELETED, resourceId, resource, unknownState) 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..2c8943a977 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deletionduringstatusupdate/DeletionDuringStatusUpdateReconciler.java @@ -0,0 +1,80 @@ +/* + * 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) { + System.out.println("DeletionDuringStatusUpdateReconciler.cleanup"); + 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; + } +} From a44c890ce5afb7da7f579951d7fb506829b4a628 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 8 Jun 2026 20:39:04 +0200 Subject: [PATCH 05/17] Event filtering with recording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../source/informer/EventFilterDetails.java | 75 +++++++++---------- ...ceEvent.java => GenericResourceEvent.java} | 20 +++-- .../informer/ManagedInformerEventSource.java | 59 ++------------- .../informer/TemporaryResourceCache.java | 53 +++---------- .../controller/ControllerEventSourceTest.java | 7 +- .../informer/InformerEventSourceTest.java | 50 +++++++++---- .../informer/TemporaryResourceCacheTest.java | 18 ++--- .../DeletionDuringStatusUpdateReconciler.java | 1 - 8 files changed, 113 insertions(+), 170 deletions(-) rename operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/{ExtendedResourceEvent.java => GenericResourceEvent.java} (81%) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetails.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetails.java index 4e1bd4ac47..fccc7b479c 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetails.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetails.java @@ -15,22 +15,22 @@ */ package io.javaoperatorsdk.operator.processing.event.source.informer; +import java.util.ArrayList; import java.util.HashSet; +import java.util.List; import java.util.Optional; import java.util.Set; import java.util.function.UnaryOperator; +import java.util.stream.Collectors; import io.fabric8.kubernetes.api.model.HasMetadata; -import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; -import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEvent; +import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; class EventFilterDetails { private int activeUpdates = 0; - private ResourceEvent lastRelevantEvent; - private String lastOwnUpdatedResourceVersion; + private List relatedEvents = new ArrayList<>(); private Set allOwnResourceVersions = new HashSet<>(); - private Set uncertainEvents = new HashSet<>(); public void increaseActiveUpdates() { activeUpdates = activeUpdates + 1; @@ -41,40 +41,11 @@ public void increaseActiveUpdates() { * controller to prevent race condition and send event from {@link * ManagedInformerEventSource#eventFilteringUpdateAndCacheResource(HasMetadata, UnaryOperator)} */ - public boolean decreaseActiveUpdates(String updatedResourceVersion) { - if (updatedResourceVersion != null - && (lastOwnUpdatedResourceVersion == null - || ReconcilerUtilsInternal.compareResourceVersions( - updatedResourceVersion, lastOwnUpdatedResourceVersion) - > 0)) { - lastOwnUpdatedResourceVersion = updatedResourceVersion; - } - + public boolean decreaseActiveUpdates() { activeUpdates = activeUpdates - 1; return activeUpdates == 0; } - public void setLastRelevantEvent(ResourceEvent event) { - lastRelevantEvent = event; - } - - public Optional getRelevantEventToPropagate() { - if (lastRelevantEvent != null - && (lastOwnUpdatedResourceVersion == null - || ReconcilerUtilsInternal.compareResourceVersions( - lastRelevantEvent - .getResource() - .orElseThrow() - .getMetadata() - .getResourceVersion(), - lastOwnUpdatedResourceVersion) - > 0) - || allOwnResourceVersions.containsAll(uncertainEvents)) { - return Optional.of(lastRelevantEvent); - } - return Optional.empty(); - } - public int getActiveUpdates() { return activeUpdates; } @@ -83,11 +54,37 @@ void addToOwnResourceVersions(String updateVersion) { allOwnResourceVersions.add(updateVersion); } - public boolean isOwnResourceVersions(String resourceVersion) { - return allOwnResourceVersions.contains(resourceVersion); + public void addRelatedEvent(GenericResourceEvent event) { + relatedEvents.add(event); } - public void addUncertainResourceVersion(String resourceVersion) { - uncertainEvents.add(resourceVersion); + public Optional prepareSummaryEventIfNotOwnEventsPresent() { + if (relatedEvents.isEmpty()) { + return Optional.empty(); + } + if (allOwnResourceVersions.containsAll( + relatedEvents.stream() + .map(e -> e.getResource().orElseThrow().getMetadata().getResourceVersion()) + .collect(Collectors.toSet()))) { + return Optional.empty(); + } + var deleteEvent = + relatedEvents.stream().filter(e -> e.getAction() == ResourceAction.DELETED).findFirst(); + if (deleteEvent.isPresent()) { + return deleteEvent; + } + if (relatedEvents.size() == 1) { + return Optional.of(relatedEvents.get(0)); + } + var firstEvent = relatedEvents.get(0); + var firstResource = + firstEvent.getPreviousResource().orElseGet(() -> firstEvent.getResource().orElseThrow()); + + return Optional.of( + new GenericResourceEvent( + ResourceAction.UPDATED, + relatedEvents.get(relatedEvents.size() - 1).getResource().orElseThrow(), + firstResource, + null)); } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ExtendedResourceEvent.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/GenericResourceEvent.java similarity index 81% rename from operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ExtendedResourceEvent.java rename to operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/GenericResourceEvent.java index 5d30d1b0e1..c6911f48cc 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/ExtendedResourceEvent.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/GenericResourceEvent.java @@ -24,26 +24,32 @@ import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEvent; /** Used only for resource event filtering. */ -public class ExtendedResourceEvent extends ResourceEvent { +public class GenericResourceEvent extends ResourceEvent { private final HasMetadata previousResource; + private final Boolean lastStateUnknow; - public ExtendedResourceEvent( + public GenericResourceEvent( ResourceAction action, - ResourceID resourceID, HasMetadata latestResource, - HasMetadata previousResource) { - super(action, resourceID, latestResource); + HasMetadata previousResource, + Boolean lastStateUnknow) { + super(action, ResourceID.fromResource(latestResource), latestResource); this.previousResource = previousResource; + this.lastStateUnknow = lastStateUnknow; } public Optional getPreviousResource() { return Optional.ofNullable(previousResource); } + public Boolean getLastStateUnknow() { + return lastStateUnknow; + } + @Override public String toString() { - return "ExtendedResourceEvent{" + return "GenericResourceEvent{" + getPreviousResource() .map(r -> "previousResourceVersion=" + r.getMetadata().getResourceVersion()) .orElse("") @@ -61,7 +67,7 @@ public String toString() { public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) return false; if (!super.equals(o)) return false; - ExtendedResourceEvent that = (ExtendedResourceEvent) o; + GenericResourceEvent that = (GenericResourceEvent) o; return Objects.equals(previousResource, that.previousResource); } 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 07009f7db8..52fd296773 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 @@ -46,7 +46,6 @@ import io.javaoperatorsdk.operator.processing.event.ResourceID; import io.javaoperatorsdk.operator.processing.event.source.*; import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; -import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceDeleteEvent; @SuppressWarnings("rawtypes") public abstract class ManagedInformerEventSource< @@ -105,58 +104,14 @@ public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator< handleRecentResourceUpdate(id, updatedResource, resourceToUpdate); return updatedResource; } finally { - var res = - temporaryResourceCache.doneEventFilterModify( - id, - updatedResource == null ? null : updatedResource.getMetadata().getResourceVersion()); - var updatedForLambda = updatedResource; + var res = temporaryResourceCache.doneEventFilterModify(id); res.ifPresentOrElse( - r -> { - // as previous resource version we use the one from successful update, since - // we process new event here only if that is more recent then the event from our update. - // Note that this is equivalent with the scenario when an informer watch connection - // would reconnect and loose some events in between. - // If that update was not successful we still record the previous version from the - // actual event in the ExtendedResourceEvent. - R extendedResourcePrevVersion = - (r instanceof ExtendedResourceEvent) - ? (R) ((ExtendedResourceEvent) r).getPreviousResource().orElse(null) - : null; - R prevVersionOfResource = null; - R latestResource = null; - if (updatedForLambda != null) { - var updatedNewerThanRelated = - ReconcilerUtilsInternal.compareResourceVersions( - updatedForLambda, r.getResource().orElseThrow()) - > 0; - prevVersionOfResource = - updatedNewerThanRelated - ? (extendedResourcePrevVersion != null - ? extendedResourcePrevVersion - : prevVersionOfResource) - : updatedForLambda; - latestResource = updatedForLambda; - } else { - prevVersionOfResource = extendedResourcePrevVersion; - latestResource = (R) r.getResource().orElseThrow(); - } - - if (log.isDebugEnabled()) { - log.debug( - "Previous resource version: {} resource from update present: {}" - + " extendedPrevResource present: {}", - prevVersionOfResource.getMetadata().getResourceVersion(), - updatedForLambda != null, - extendedResourcePrevVersion != null); - } - handleEvent( - r.getAction(), - latestResource, - prevVersionOfResource, - (r instanceof ResourceDeleteEvent) - ? ((ResourceDeleteEvent) r).isDeletedFinalStateUnknown() - : null); - }, + r -> + handleEvent( + r.getAction(), + (R) r.getResource().orElseThrow(), + (R) r.getPreviousResource().orElse(null), + r.getLastStateUnknow()), () -> log.debug("No new event present after the filtering update")); } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java index a1517d86b0..8ee3f44b4d 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java @@ -29,8 +29,6 @@ import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResource; import io.javaoperatorsdk.operator.processing.event.ResourceID; import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; -import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceDeleteEvent; -import io.javaoperatorsdk.operator.processing.event.source.controller.ResourceEvent; /** * Temporal cache is used to solve the problem for {@link KubernetesDependentResource} that is, when @@ -84,13 +82,12 @@ public synchronized void startEventFilteringModify(ResourceID resourceID) { ed.increaseActiveUpdates(); } - public synchronized Optional doneEventFilterModify( - ResourceID resourceID, String updatedResourceVersion) { + public synchronized Optional doneEventFilterModify(ResourceID resourceID) { if (!comparableResourceVersions) { return Optional.empty(); } var ed = activeUpdates.get(resourceID); - if (ed == null || !ed.decreaseActiveUpdates(updatedResourceVersion)) { + if (ed == null || !ed.decreaseActiveUpdates()) { log.debug( "Active updates {} for resource id: {}", ed != null ? ed.getActiveUpdates() : 0, @@ -98,31 +95,20 @@ public synchronized Optional doneEventFilterModify( return Optional.empty(); } activeUpdates.remove(resourceID); - var res = ed.getRelevantEventToPropagate(); - log.debug( - "Zero active updates for resource id: {}; event after update event: {}; updated resource" - + " version: {}", - resourceID, - res.isPresent(), - updatedResourceVersion); - return res; + return ed.prepareSummaryEventIfNotOwnEventsPresent(); } public void onDeleteEvent(T resource, boolean unknownState) { - onEvent(ResourceAction.DELETED, resource, null, unknownState, true); + onEvent(ResourceAction.DELETED, resource, null, unknownState); } public EventHandling onAddOrUpdateEvent( ResourceAction action, T resource, T prevResourceVersion) { - return onEvent(action, resource, prevResourceVersion, false, false); + return onEvent(action, resource, prevResourceVersion, false); } private synchronized EventHandling onEvent( - ResourceAction action, - T resource, - T prevResourceVersion, - boolean unknownState, - boolean delete) { + ResourceAction action, T resource, T prevResourceVersion, boolean unknownState) { if (!comparableResourceVersions) { return EventHandling.NEW; } @@ -155,29 +141,10 @@ private synchronized EventHandling onEvent( } var au = activeUpdates.get(resourceId); if (au != null) { - if (result == EventHandling.INTERMEDIATE) { - var ownResourceVersion = - au.isOwnResourceVersions(resource.getMetadata().getResourceVersion()); - log.debug("Handling intermediate event. Own resource version: {}", ownResourceVersion); - return ownResourceVersion ? EventHandling.DEFER : EventHandling.INTERMEDIATE; - } - if (result == EventHandling.NEW) { - if (cached == null) { - // this is for the case when temp cache is null, we receive an event - // there is ongoing filtering-caching update; at this point we cannot tell - // if that event is from our update - log.debug("Setting uncertain resource version."); - au.addUncertainResourceVersion(resource.getMetadata().getResourceVersion()); - } - log.debug("Setting last event for id: {} delete: {}", resourceId, delete); - au.setLastRelevantEvent( - delete - ? new ResourceDeleteEvent( - ResourceAction.DELETED, resourceId, resource, unknownState) - : new ExtendedResourceEvent(action, resourceId, resource, prevResourceVersion)); - return EventHandling.DEFER; - } - return result; + log.debug("Recording relevant event"); + au.addRelatedEvent( + new GenericResourceEvent(action, resource, prevResourceVersion, unknownState)); + return EventHandling.DEFER; } else { return result; } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java index 72ea7df27f..b84b7992b7 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java @@ -249,10 +249,9 @@ void propagatesIntermediateEventForExternalUpdateDuringFiltering() { // external update with rv 3 (older than our cached rv 4) — must propagate source.onUpdate(testResourceWithVersion(2), testResourceWithVersion(3)); - - verify(eventHandler, times(1)).handleEvent(any()); - latch2.countDown(); + + await().untilAsserted(() -> verify(eventHandler, times(1)).handleEvent(any())); } @Test @@ -317,7 +316,7 @@ private void expectHandleEvent(int newResourceVersion, int oldResourceVersion) { .isEqualTo("" + oldResourceVersion); return true; }), - isNull()); + any()); }); } 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 b82d280397..847556870c 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 @@ -71,7 +71,7 @@ class InformerEventSourceTest { private static final String PREV_RESOURCE_VERSION = "0"; - private static final String DEFAULT_RESOURCE_VERSION = "1"; + private static final String DEFAULT_RESOURCE_VERSION = "2"; private InformerEventSource informerEventSource; private final KubernetesClient clientMock = MockKubernetesClient.client(Deployment.class); @@ -218,12 +218,12 @@ void filtersOnDeleteEvents() { void handlesPrevResourceVersionForUpdate() { withRealTemporaryResourceCache(); - CountDownLatch latch = sendForEventFilteringUpdate(2); + CountDownLatch latch = sendForEventFilteringUpdate(3); informerEventSource.onUpdate( - deploymentWithResourceVersion(2), deploymentWithResourceVersion(3)); + deploymentWithResourceVersion(1), deploymentWithResourceVersion(2)); latch.countDown(); - expectHandleEvent(3, 2); + expectHandleAddEvent(2, 1); } @Test @@ -241,7 +241,7 @@ void handlesPrevResourceVersionForUpdateInCaseOfException() { deploymentWithResourceVersion(1), deploymentWithResourceVersion(2)); latch.countDown(); - expectHandleEvent(2, 1); + expectHandleAddEvent(2, 1); } @Test @@ -256,7 +256,7 @@ void handlesPrevResourceVersionForUpdateInCaseOfMultipleUpdates() { withResourceVersion(testDeployment(), 3), withResourceVersion(testDeployment(), 4)); latch.countDown(); - expectHandleEvent(4, 2); + expectHandleAddEvent(4, 2); } @Test @@ -275,11 +275,11 @@ void doesNotPropagateEventIfReceivedBeforeUpdate() { void filterAddEventBeforeUpdate() { withRealTemporaryResourceCache(); - CountDownLatch latch = sendForEventFilteringUpdate(2); - informerEventSource.onAdd(deploymentWithResourceVersion(1)); + CountDownLatch latch = sendForEventFilteringUpdate(3); + informerEventSource.onAdd(deploymentWithResourceVersion(2)); latch.countDown(); - assertNoEventProduced(); + expectHandleAddEvent(2); } @Test @@ -379,7 +379,7 @@ void ghostCheckRemovesCachedResourceDuringFilteringUpdate() { assertThat(temporaryResourceCache.getResourceFromCache(resourceId)).isEmpty(); // complete the filtering update - the resource should not reappear - temporaryResourceCache.doneEventFilterModify(resourceId, "2"); + temporaryResourceCache.doneEventFilterModify(resourceId); assertThat(temporaryResourceCache.getResourceFromCache(resourceId)).isEmpty(); } @@ -439,7 +439,7 @@ void filteringUpdateAndGhostCheckWithNamespaceChange() { assertThat(temporaryResourceCache.getResourceFromCache(resourceId)).isEmpty(); // complete the filtering update - var doneResult = temporaryResourceCache.doneEventFilterModify(resourceId, "2"); + var doneResult = temporaryResourceCache.doneEventFilterModify(resourceId); // resource was already cleaned by ghost check, so no deferred event assertThat(doneResult).isEmpty(); @@ -469,9 +469,9 @@ void propagatesIntermediateEventForExternalUpdateDuringFiltering() { informerEventSource.onUpdate( deploymentWithResourceVersion(2), deploymentWithResourceVersion(3)); - verify(eventHandlerMock, times(1)).handleEvent(any()); - latch2.countDown(); + + expectHandleAddEvent(3, 2); } @Test @@ -521,8 +521,28 @@ private void assertNoEventProduced() { () -> verify(informerEventSource, never()).handleEvent(any(), any(), any(), any())); } - private void expectHandleEvent(int newResourceVersion, int oldResourceVersion) { + private void expectHandleAddEvent(int newResourceVersion) { + await() + .atMost(Duration.ofSeconds(1)) + .untilAsserted( + () -> { + verify(informerEventSource, times(1)) + .handleEvent( + eq(ResourceAction.ADDED), + argThat( + newResource -> { + assertThat(newResource.getMetadata().getResourceVersion()) + .isEqualTo("" + newResourceVersion); + return true; + }), + isNull(), + any()); + }); + } + + private void expectHandleAddEvent(int newResourceVersion, int oldResourceVersion) { await() + .atMost(Duration.ofSeconds(1)) .untilAsserted( () -> { verify(informerEventSource, times(1)) @@ -540,7 +560,7 @@ private void expectHandleEvent(int newResourceVersion, int oldResourceVersion) { .isEqualTo("" + oldResourceVersion); return true; }), - isNull()); + any()); }); } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCacheTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCacheTest.java index 6d0c4b88d4..2917367333 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCacheTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCacheTest.java @@ -155,7 +155,7 @@ void eventReceivedDuringFiltering() { .isEmpty(); var doneRes = - temporaryResourceCache.doneEventFilterModify(ResourceID.fromResource(testResource), "2"); + temporaryResourceCache.doneEventFilterModify(ResourceID.fromResource(testResource)); assertThat(doneRes).isEmpty(); assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) @@ -179,7 +179,7 @@ void newerEventDuringFiltering() { .isEmpty(); var doneRes = - temporaryResourceCache.doneEventFilterModify(ResourceID.fromResource(testResource), "2"); + temporaryResourceCache.doneEventFilterModify(ResourceID.fromResource(testResource)); assertThat(doneRes).isPresent(); assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) @@ -197,7 +197,7 @@ void eventAfterFiltering() { .isPresent(); var doneRes = - temporaryResourceCache.doneEventFilterModify(ResourceID.fromResource(testResource), "2"); + temporaryResourceCache.doneEventFilterModify(ResourceID.fromResource(testResource)); assertThat(doneRes).isEmpty(); assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(testResource))) @@ -241,7 +241,7 @@ void putBeforeEventWithEventFiltering() { temporaryResourceCache.startEventFilteringModify(resourceId); temporaryResourceCache.putResource(nextResource); - temporaryResourceCache.doneEventFilterModify(resourceId, "3"); + temporaryResourceCache.doneEventFilterModify(resourceId); latestSyncVersion = "3"; result = temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.UPDATED, nextResource, null); @@ -268,7 +268,7 @@ void putAfterEventWithEventFilteringNoPost() { // the result is deferred assertThat(result).isEqualTo(EventHandling.DEFER); temporaryResourceCache.putResource(nextResource); - var postEvent = temporaryResourceCache.doneEventFilterModify(resourceId, "3"); + var postEvent = temporaryResourceCache.doneEventFilterModify(resourceId); // there is no post event because the done call claimed responsibility for rv 3 assertTrue(postEvent.isEmpty()); @@ -280,7 +280,7 @@ void putAfterEventWithEventFilteringWithPost() { var resourceId = ResourceID.fromResource(testResource); temporaryResourceCache.startEventFilteringModify(resourceId); - // this should be a corner case - watch had a hard reset since the start of the + // this should be a corner case - watch had a hard reset since the start // of the update operation, such that 4 rv event is seen prior to the update // completing with the 3 rv. var nextResource = testResource(); @@ -289,7 +289,7 @@ void putAfterEventWithEventFilteringWithPost() { temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.ADDED, nextResource, null); assertThat(result).isEqualTo(EventHandling.DEFER); - var postEvent = temporaryResourceCache.doneEventFilterModify(resourceId, "3"); + var postEvent = temporaryResourceCache.doneEventFilterModify(resourceId); assertTrue(postEvent.isPresent()); } @@ -314,7 +314,7 @@ void intermediateEventPropagatedWhenNoActiveUpdate() { } @Test - void intermediateEventPropagatedWhenNotOurOwnUpdate() { + void intermediateEventRecorded() { // Causal-dependency scenario: a third party updated the resource between our read and // our write. Its version arrives as an event but is NOT in our own resource versions, // so it must be propagated (INTERMEDIATE), not deferred. @@ -329,7 +329,7 @@ void intermediateEventPropagatedWhenNotOurOwnUpdate() { var result = temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.UPDATED, external, null); - assertThat(result).isEqualTo(EventHandling.INTERMEDIATE); + assertThat(result).isEqualTo(EventHandling.DEFER); } @Test 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 index 2c8943a977..db05321ee7 100644 --- 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 @@ -73,7 +73,6 @@ public UpdateControl reconcile( public DeleteControl cleanup( DeletionDuringStatusUpdateCustomResource resource, Context context) { - System.out.println("DeletionDuringStatusUpdateReconciler.cleanup"); cleanupCalledLatch.countDown(); return DeleteControl.defaultDelete(); } From 7d2e16bc803224d6b0f376e4d2742db61907a186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 8 Jun 2026 20:43:15 +0200 Subject: [PATCH 06/17] test fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../event/source/informer/InformerEventSourceTest.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 847556870c..25ae10c321 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 @@ -339,14 +339,14 @@ void multipleCachingFilteringUpdates_variant3() { void multipleCachingFilteringUpdates_variant4() { withRealTemporaryResourceCache(); - CountDownLatch latch = sendForEventFilteringUpdate(2); + CountDownLatch latch = sendForEventFilteringUpdate(3); CountDownLatch latch2 = - sendForEventFilteringUpdate(withResourceVersion(testDeployment(), 2), 3); + sendForEventFilteringUpdate(withResourceVersion(testDeployment(), 3), 4); - informerEventSource.onUpdate( - deploymentWithResourceVersion(1), deploymentWithResourceVersion(2)); informerEventSource.onUpdate( deploymentWithResourceVersion(2), deploymentWithResourceVersion(3)); + informerEventSource.onUpdate( + deploymentWithResourceVersion(3), deploymentWithResourceVersion(4)); latch.countDown(); latch2.countDown(); From 6426d74c25783d95b22fd8221b6ef5c1d27e18f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 9 Jun 2026 10:44:04 +0200 Subject: [PATCH 07/17] Simplified EventHandling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../controller/ControllerEventSource.java | 2 +- .../source/informer/InformerEventSource.java | 4 ++-- .../informer/TemporaryResourceCache.java | 15 ++++++-------- .../informer/InformerEventSourceTest.java | 8 ++++---- .../informer/TemporaryResourceCacheTest.java | 20 +++++++++---------- 5 files changed, 23 insertions(+), 26 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java index 3e0bc1617b..1ce8ce0620 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java @@ -141,7 +141,7 @@ private void handleOnAddOrUpdate( ResourceAction action, T oldCustomResource, T newCustomResource) { var handling = temporaryResourceCache.onAddOrUpdateEvent(action, newCustomResource, oldCustomResource); - if (handling == EventHandling.NEW || handling == EventHandling.INTERMEDIATE) { + if (handling == EventHandling.PROPAGATE) { handleEvent(action, newCustomResource, oldCustomResource, null); } else if (log.isDebugEnabled()) { log.debug("{} event propagation for action: {}", handling, action); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java index eaf9ee8821..afbf0a33ab 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java @@ -154,9 +154,9 @@ private synchronized void onAddOrUpdate(ResourceAction action, R newObject, R ol var eventHandling = temporaryResourceCache.onAddOrUpdateEvent(action, newObject, oldObject); - if (eventHandling != EventHandling.NEW && eventHandling != EventHandling.INTERMEDIATE) { + if (eventHandling != EventHandling.PROPAGATE) { log.debug( - "{} event propagation", eventHandling == EventHandling.DEFER ? "Deferring" : "Skipping"); + "{} event propagation", eventHandling == EventHandling.IGNORE ? "Deferring" : "Skipping"); } else if (eventAcceptedByFilter(action, newObject, oldObject)) { log.debug( "Propagating event for {}, resource with same version not result of a our update.", diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java index 8ee3f44b4d..b9f50c5ac9 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java @@ -61,10 +61,8 @@ public class TemporaryResourceCache { private final ManagedInformerEventSource managedInformerEventSource; public enum EventHandling { - DEFER, - OBSOLETE, - INTERMEDIATE, - NEW + IGNORE, + PROPAGATE } public TemporaryResourceCache( @@ -110,7 +108,7 @@ public EventHandling onAddOrUpdateEvent( private synchronized EventHandling onEvent( ResourceAction action, T resource, T prevResourceVersion, boolean unknownState) { if (!comparableResourceVersions) { - return EventHandling.NEW; + return EventHandling.PROPAGATE; } var resourceId = ResourceID.fromResource(resource); @@ -118,7 +116,7 @@ private synchronized EventHandling onEvent( log.debug("Processing event"); } var cached = cache.get(resourceId); - EventHandling result = EventHandling.NEW; + EventHandling result = EventHandling.PROPAGATE; if (cached != null) { int comp = ReconcilerUtilsInternal.compareResourceVersions(resource, cached); if (comp >= 0 || unknownState) { @@ -130,13 +128,12 @@ private synchronized EventHandling onEvent( // we propagate event only for our update or newer other can be discarded since we know we // will receive // additional event - result = comp == 0 ? EventHandling.OBSOLETE : EventHandling.NEW; + result = comp == 0 ? EventHandling.IGNORE : EventHandling.PROPAGATE; } else { // in this case we received and event that might be in some edge case that was // already used in reconciler or after that, but before our updated resource version. // That would be hard to distinguish, so for those we are propagating the event further. log.debug("Received intermediate event."); - result = EventHandling.INTERMEDIATE; } } var au = activeUpdates.get(resourceId); @@ -144,7 +141,7 @@ private synchronized EventHandling onEvent( log.debug("Recording relevant event"); au.addRelatedEvent( new GenericResourceEvent(action, resource, prevResourceVersion, unknownState)); - return EventHandling.DEFER; + return EventHandling.IGNORE; } else { return result; } 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 25ae10c321..4e3e9dacf2 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 @@ -118,7 +118,7 @@ void skipsEventPropagation() { .thenReturn(Optional.of(testDeployment())); when(temporaryResourceCache.onAddOrUpdateEvent(any(), any(), any())) - .thenReturn(EventHandling.OBSOLETE); + .thenReturn(EventHandling.IGNORE); informerEventSource.onAdd(testDeployment()); informerEventSource.onUpdate(testDeployment(), testDeployment()); @@ -129,7 +129,7 @@ void skipsEventPropagation() { @Test void processEventPropagationWithoutAnnotation() { when(temporaryResourceCache.onAddOrUpdateEvent(any(), any(), any())) - .thenReturn(EventHandling.NEW); + .thenReturn(EventHandling.PROPAGATE); informerEventSource.onUpdate(testDeployment(), testDeployment()); verify(eventHandlerMock, times(1)).handleEvent(any()); @@ -138,7 +138,7 @@ void processEventPropagationWithoutAnnotation() { @Test void processEventPropagationWithIncorrectAnnotation() { when(temporaryResourceCache.onAddOrUpdateEvent(any(), any(), any())) - .thenReturn(EventHandling.NEW); + .thenReturn(EventHandling.PROPAGATE); informerEventSource.onAdd( new DeploymentBuilder(testDeployment()) .editMetadata() @@ -152,7 +152,7 @@ void processEventPropagationWithIncorrectAnnotation() { @Test void propagatesIntermediateEventHandling() { when(temporaryResourceCache.onAddOrUpdateEvent(any(), any(), any())) - .thenReturn(EventHandling.INTERMEDIATE); + .thenReturn(EventHandling.PROPAGATE); informerEventSource.onUpdate(testDeployment(), testDeployment()); verify(eventHandlerMock, times(1)).handleEvent(any()); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCacheTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCacheTest.java index 2917367333..84530066e1 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCacheTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCacheTest.java @@ -215,14 +215,14 @@ void putBeforeEvent() { // first ensure an event is not known var result = temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.ADDED, testResource, null); - assertThat(result).isEqualTo(EventHandling.NEW); + assertThat(result).isEqualTo(EventHandling.PROPAGATE); var nextResource = testResource(); nextResource.getMetadata().setResourceVersion("3"); temporaryResourceCache.putResource(nextResource); result = temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.UPDATED, nextResource, null); - assertThat(result).isEqualTo(EventHandling.OBSOLETE); + assertThat(result).isEqualTo(EventHandling.IGNORE); } @Test @@ -232,7 +232,7 @@ void putBeforeEventWithEventFiltering() { // first ensure an event is not known var result = temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.ADDED, testResource, null); - assertThat(result).isEqualTo(EventHandling.NEW); + assertThat(result).isEqualTo(EventHandling.PROPAGATE); latestSyncVersion = RESOURCE_VERSION; var nextResource = testResource(); @@ -245,7 +245,7 @@ void putBeforeEventWithEventFiltering() { latestSyncVersion = "3"; result = temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.UPDATED, nextResource, null); - assertThat(result).isEqualTo(EventHandling.OBSOLETE); + assertThat(result).isEqualTo(EventHandling.IGNORE); } @Test @@ -255,7 +255,7 @@ void putAfterEventWithEventFilteringNoPost() { // first ensure an event is not known var result = temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.ADDED, testResource, null); - assertThat(result).isEqualTo(EventHandling.NEW); + assertThat(result).isEqualTo(EventHandling.PROPAGATE); var nextResource = testResource(); nextResource.getMetadata().setResourceVersion("3"); @@ -266,7 +266,7 @@ void putAfterEventWithEventFilteringNoPost() { temporaryResourceCache.onAddOrUpdateEvent( ResourceAction.UPDATED, nextResource, testResource); // the result is deferred - assertThat(result).isEqualTo(EventHandling.DEFER); + assertThat(result).isEqualTo(EventHandling.IGNORE); temporaryResourceCache.putResource(nextResource); var postEvent = temporaryResourceCache.doneEventFilterModify(resourceId); @@ -287,7 +287,7 @@ void putAfterEventWithEventFilteringWithPost() { nextResource.getMetadata().setResourceVersion("4"); var result = temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.ADDED, nextResource, null); - assertThat(result).isEqualTo(EventHandling.DEFER); + assertThat(result).isEqualTo(EventHandling.IGNORE); var postEvent = temporaryResourceCache.doneEventFilterModify(resourceId); @@ -310,7 +310,7 @@ void intermediateEventPropagatedWhenNoActiveUpdate() { var result = temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.UPDATED, olderEvent, null); - assertThat(result).isEqualTo(EventHandling.INTERMEDIATE); + assertThat(result).isEqualTo(EventHandling.PROPAGATE); } @Test @@ -329,7 +329,7 @@ void intermediateEventRecorded() { var result = temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.UPDATED, external, null); - assertThat(result).isEqualTo(EventHandling.DEFER); + assertThat(result).isEqualTo(EventHandling.IGNORE); } @Test @@ -353,7 +353,7 @@ void intermediateEventDeferredWhenItIsOurOwnIntermediateUpdate() { var result = temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.UPDATED, ourFirst, null); - assertThat(result).isEqualTo(EventHandling.DEFER); + assertThat(result).isEqualTo(EventHandling.IGNORE); } @Test From 6bc4b2c24ecee8fc0ea26d1034714c14c32d1fda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 9 Jun 2026 11:16:19 +0200 Subject: [PATCH 08/17] unit tests fix 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 | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) 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 4e3e9dacf2..a7d5423fb4 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 @@ -285,16 +285,16 @@ void filterAddEventBeforeUpdate() { @Test void multipleCachingFilteringUpdates() { withRealTemporaryResourceCache(); - CountDownLatch latch = sendForEventFilteringUpdate(2); + CountDownLatch latch = sendForEventFilteringUpdate(3); CountDownLatch latch2 = - sendForEventFilteringUpdate(withResourceVersion(testDeployment(), 2), 3); + sendForEventFilteringUpdate(withResourceVersion(testDeployment(), 3), 4); informerEventSource.onUpdate( - deploymentWithResourceVersion(1), deploymentWithResourceVersion(2)); + deploymentWithResourceVersion(2), deploymentWithResourceVersion(3)); latch.countDown(); latch2.countDown(); informerEventSource.onUpdate( - deploymentWithResourceVersion(2), deploymentWithResourceVersion(3)); + deploymentWithResourceVersion(3), deploymentWithResourceVersion(4)); assertNoEventProduced(); } @@ -303,15 +303,15 @@ void multipleCachingFilteringUpdates() { void multipleCachingFilteringUpdates_variant2() { withRealTemporaryResourceCache(); - CountDownLatch latch = sendForEventFilteringUpdate(2); + CountDownLatch latch = sendForEventFilteringUpdate(3); CountDownLatch latch2 = - sendForEventFilteringUpdate(withResourceVersion(testDeployment(), 2), 3); + sendForEventFilteringUpdate(withResourceVersion(testDeployment(), 3), 4); informerEventSource.onUpdate( - deploymentWithResourceVersion(1), deploymentWithResourceVersion(2)); + deploymentWithResourceVersion(2), deploymentWithResourceVersion(3)); latch.countDown(); informerEventSource.onUpdate( - deploymentWithResourceVersion(2), deploymentWithResourceVersion(3)); + deploymentWithResourceVersion(3), deploymentWithResourceVersion(4)); latch2.countDown(); assertNoEventProduced(); @@ -321,15 +321,15 @@ void multipleCachingFilteringUpdates_variant2() { void multipleCachingFilteringUpdates_variant3() { withRealTemporaryResourceCache(); - CountDownLatch latch = sendForEventFilteringUpdate(2); + CountDownLatch latch = sendForEventFilteringUpdate(3); CountDownLatch latch2 = - sendForEventFilteringUpdate(withResourceVersion(testDeployment(), 2), 3); + sendForEventFilteringUpdate(withResourceVersion(testDeployment(), 3), 4); latch.countDown(); - informerEventSource.onUpdate( - deploymentWithResourceVersion(1), deploymentWithResourceVersion(2)); informerEventSource.onUpdate( deploymentWithResourceVersion(2), deploymentWithResourceVersion(3)); + informerEventSource.onUpdate( + deploymentWithResourceVersion(4), deploymentWithResourceVersion(4)); latch2.countDown(); assertNoEventProduced(); From 5e1db440a1a8bf7f1187036de58f07ff2b24813e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 9 Jun 2026 12:57:12 +0200 Subject: [PATCH 09/17] small fix, test repeats 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 +++++----- .../informer/TemporaryResourceCache.java | 8 ++--- .../informer/InformerEventSourceTest.java | 30 ++++++++++--------- 3 files changed, 29 insertions(+), 25 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 52fd296773..9dc487215a 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 @@ -100,18 +100,20 @@ public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator< try { temporaryResourceCache.startEventFilteringModify(id); updatedResource = updateMethod.apply(resourceToUpdate); - log.debug("Resource update successful"); handleRecentResourceUpdate(id, updatedResource, resourceToUpdate); + log.debug("Caching resource update successful"); return updatedResource; } finally { var res = temporaryResourceCache.doneEventFilterModify(id); res.ifPresentOrElse( - r -> - handleEvent( - r.getAction(), - (R) r.getResource().orElseThrow(), - (R) r.getPreviousResource().orElse(null), - r.getLastStateUnknow()), + r -> { + log.debug("Propagating not own event"); + handleEvent( + r.getAction(), + (R) r.getResource().orElseThrow(), + (R) r.getPreviousResource().orElse(null), + r.getLastStateUnknow()); + }, () -> log.debug("No new event present after the filtering update")); } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java index b9f50c5ac9..b98837a48b 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java @@ -143,6 +143,7 @@ private synchronized EventHandling onEvent( new GenericResourceEvent(action, resource, prevResourceVersion, unknownState)); return EventHandling.IGNORE; } else { + log.debug("No active recornding, event handling: {}", result); return result; } } @@ -194,6 +195,9 @@ public synchronized void putResource(T newResource) { // also make sure that we're later than the existing temporary entry var cachedResource = getResourceFromCache(resourceId).orElse(null); + Optional.ofNullable(activeUpdates.get(resourceId)) + .ifPresent( + au -> au.addToOwnResourceVersions(newResource.getMetadata().getResourceVersion())); if (cachedResource == null || ReconcilerUtilsInternal.compareResourceVersions(newResource, cachedResource) > 0) { @@ -202,10 +206,6 @@ public synchronized void putResource(T newResource) { newResource.getMetadata().getResourceVersion(), resourceId); cache.put(resourceId, newResource); - var au = activeUpdates.get(resourceId); - if (au != null) { - au.addToOwnResourceVersions(newResource.getMetadata().getResourceVersion()); - } } } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSourceTest.java index a7d5423fb4..f02082d7cd 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 @@ -25,6 +25,7 @@ import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.RepeatedTest; import org.junit.jupiter.api.Test; import io.fabric8.kubernetes.api.model.ObjectMeta; @@ -72,6 +73,7 @@ class InformerEventSourceTest { private static final String PREV_RESOURCE_VERSION = "0"; private static final String DEFAULT_RESOURCE_VERSION = "2"; + public static final int REPEAT_COUNT = 10; private InformerEventSource informerEventSource; private final KubernetesClient clientMock = MockKubernetesClient.client(Deployment.class); @@ -214,7 +216,7 @@ void filtersOnDeleteEvents() { verify(eventHandlerMock, never()).handleEvent(any()); } - @Test + @RepeatedTest(REPEAT_COUNT) void handlesPrevResourceVersionForUpdate() { withRealTemporaryResourceCache(); @@ -226,7 +228,7 @@ void handlesPrevResourceVersionForUpdate() { expectHandleAddEvent(2, 1); } - @Test + @RepeatedTest(REPEAT_COUNT) void handlesPrevResourceVersionForUpdateInCaseOfException() { withRealTemporaryResourceCache(); @@ -244,7 +246,7 @@ void handlesPrevResourceVersionForUpdateInCaseOfException() { expectHandleAddEvent(2, 1); } - @Test + @RepeatedTest(REPEAT_COUNT) void handlesPrevResourceVersionForUpdateInCaseOfMultipleUpdates() { withRealTemporaryResourceCache(); @@ -259,7 +261,7 @@ void handlesPrevResourceVersionForUpdateInCaseOfMultipleUpdates() { expectHandleAddEvent(4, 2); } - @Test + @RepeatedTest(REPEAT_COUNT) void doesNotPropagateEventIfReceivedBeforeUpdate() { withRealTemporaryResourceCache(); @@ -271,7 +273,7 @@ void doesNotPropagateEventIfReceivedBeforeUpdate() { assertNoEventProduced(); } - @Test + @RepeatedTest(REPEAT_COUNT) void filterAddEventBeforeUpdate() { withRealTemporaryResourceCache(); @@ -282,7 +284,7 @@ void filterAddEventBeforeUpdate() { expectHandleAddEvent(2); } - @Test + @RepeatedTest(REPEAT_COUNT) void multipleCachingFilteringUpdates() { withRealTemporaryResourceCache(); CountDownLatch latch = sendForEventFilteringUpdate(3); @@ -299,7 +301,7 @@ void multipleCachingFilteringUpdates() { assertNoEventProduced(); } - @Test + @RepeatedTest(REPEAT_COUNT) void multipleCachingFilteringUpdates_variant2() { withRealTemporaryResourceCache(); @@ -317,7 +319,7 @@ void multipleCachingFilteringUpdates_variant2() { assertNoEventProduced(); } - @Test + @RepeatedTest(REPEAT_COUNT) void multipleCachingFilteringUpdates_variant3() { withRealTemporaryResourceCache(); @@ -335,7 +337,7 @@ void multipleCachingFilteringUpdates_variant3() { assertNoEventProduced(); } - @Test + @RepeatedTest(REPEAT_COUNT) void multipleCachingFilteringUpdates_variant4() { withRealTemporaryResourceCache(); @@ -353,7 +355,7 @@ void multipleCachingFilteringUpdates_variant4() { assertNoEventProduced(); } - @Test + @RepeatedTest(REPEAT_COUNT) void ghostCheckRemovesCachedResourceDuringFilteringUpdate() { var mes = mock(ManagedInformerEventSource.class); var mim = mock(InformerManager.class); @@ -383,7 +385,7 @@ void ghostCheckRemovesCachedResourceDuringFilteringUpdate() { assertThat(temporaryResourceCache.getResourceFromCache(resourceId)).isEmpty(); } - @Test + @RepeatedTest(REPEAT_COUNT) void ghostCheckRunsConcurrentlyWithPutResource() { var mes = mock(ManagedInformerEventSource.class); var mim = mock(InformerManager.class); @@ -414,7 +416,7 @@ void ghostCheckRunsConcurrentlyWithPutResource() { .isPresent(); } - @Test + @RepeatedTest(REPEAT_COUNT) void filteringUpdateAndGhostCheckWithNamespaceChange() { var mes = mock(ManagedInformerEventSource.class); var mim = mock(InformerManager.class); @@ -448,7 +450,7 @@ void filteringUpdateAndGhostCheckWithNamespaceChange() { assertThat(temporaryResourceCache.getResourceFromCache(resourceId)).isEmpty(); } - @Test + @RepeatedTest(REPEAT_COUNT) void propagatesIntermediateEventForExternalUpdateDuringFiltering() { // Causal-dependency fix: another controller updated the resource between our read // and our write. The informer delivers that update during our active filter; since @@ -474,7 +476,7 @@ void propagatesIntermediateEventForExternalUpdateDuringFiltering() { expectHandleAddEvent(3, 2); } - @Test + @RepeatedTest(REPEAT_COUNT) void doesNotPropagateIntermediateEventForOurOwnIntermediateUpdate() { // Two consecutive own writes (rv 3 then rv 4) within an open filter window: an // event for the older own version must be deferred since it's recognized as our own. From 8f5b4dac9adb158a955f499c20f0a31d53bd609d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 9 Jun 2026 14:31:29 +0200 Subject: [PATCH 10/17] improvements and releated unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../controller/ControllerEventSource.java | 27 ++++-- .../source/informer/EventFilterDetails.java | 33 +++++-- .../source/informer/InformerEventSource.java | 15 +-- .../informer/TemporaryResourceCache.java | 56 +++++++---- .../controller/ControllerEventSourceTest.java | 1 + .../informer/InformerEventSourceTest.java | 93 ++++++------------- .../informer/TemporaryResourceCacheTest.java | 36 ++++--- 7 files changed, 143 insertions(+), 118 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java index 1ce8ce0620..7afb62ea64 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java @@ -31,8 +31,8 @@ import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; import io.javaoperatorsdk.operator.processing.event.source.filter.OnDeleteFilter; import io.javaoperatorsdk.operator.processing.event.source.filter.OnUpdateFilter; +import io.javaoperatorsdk.operator.processing.event.source.informer.GenericResourceEvent; import io.javaoperatorsdk.operator.processing.event.source.informer.ManagedInformerEventSource; -import io.javaoperatorsdk.operator.processing.event.source.informer.TemporaryResourceCache.EventHandling; import static io.javaoperatorsdk.operator.ReconcilerUtilsInternal.handleKubernetesClientException; import static io.javaoperatorsdk.operator.processing.event.source.controller.InternalEventFilters.*; @@ -141,11 +141,22 @@ private void handleOnAddOrUpdate( ResourceAction action, T oldCustomResource, T newCustomResource) { var handling = temporaryResourceCache.onAddOrUpdateEvent(action, newCustomResource, oldCustomResource); - if (handling == EventHandling.PROPAGATE) { - handleEvent(action, newCustomResource, oldCustomResource, null); - } else if (log.isDebugEnabled()) { - log.debug("{} event propagation for action: {}", handling, action); - } + handling.ifPresentOrElse( + this::handleEvent, + () -> { + if (log.isDebugEnabled()) { + log.debug("{} event propagation for action: {}", handling, action); + } + }); + } + + @SuppressWarnings("unchecked") + private void handleEvent(GenericResourceEvent r) { + handleEvent( + r.getAction(), + (T) r.getResource().orElseThrow(), + (T) r.getPreviousResource().orElse(null), + r.getLastStateUnknow()); } @Override @@ -154,10 +165,10 @@ public synchronized void onDelete(T resource, boolean deletedFinalStateUnknown) resource, ResourceAction.DELETED, () -> { - temporaryResourceCache.onDeleteEvent(resource, deletedFinalStateUnknown); + var res = temporaryResourceCache.onDeleteEvent(resource, deletedFinalStateUnknown); // delete event is quite special here, that requires special care, since we clean up // caches on delete event. - handleEvent(ResourceAction.DELETED, resource, null, deletedFinalStateUnknown); + res.ifPresent(this::handleEvent); }); } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetails.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetails.java index fccc7b479c..b9d12f9f10 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetails.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetails.java @@ -24,13 +24,14 @@ import java.util.stream.Collectors; import io.fabric8.kubernetes.api.model.HasMetadata; +import io.javaoperatorsdk.operator.ReconcilerUtilsInternal; import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; class EventFilterDetails { private int activeUpdates = 0; - private List relatedEvents = new ArrayList<>(); - private Set allOwnResourceVersions = new HashSet<>(); + private final List relatedEvents = new ArrayList<>(5); + private final Set allOwnResourceVersions = new HashSet<>(5); public void increaseActiveUpdates() { activeUpdates = activeUpdates + 1; @@ -50,6 +51,10 @@ public int getActiveUpdates() { return activeUpdates; } + public boolean isNoActiveUpdate() { + return activeUpdates == 0; + } + void addToOwnResourceVersions(String updateVersion) { allOwnResourceVersions.add(updateVersion); } @@ -62,10 +67,7 @@ public Optional prepareSummaryEventIfNotOwnEventsPresent() if (relatedEvents.isEmpty()) { return Optional.empty(); } - if (allOwnResourceVersions.containsAll( - relatedEvents.stream() - .map(e -> e.getResource().orElseThrow().getMetadata().getResourceVersion()) - .collect(Collectors.toSet()))) { + if (allOwnResourceVersions.containsAll(relatedEventResourceVersions())) { return Optional.empty(); } var deleteEvent = @@ -87,4 +89,23 @@ public Optional prepareSummaryEventIfNotOwnEventsPresent() firstResource, null)); } + + private Set relatedEventResourceVersions() { + return relatedEvents.stream() + .map(e -> e.getResource().orElseThrow().getMetadata().getResourceVersion()) + .collect(Collectors.toSet()); + } + + public boolean newerOrEqualEventReceivedForOwnLastUpdate() { + if (allOwnResourceVersions.isEmpty()) { + return true; + } + String lastOwn = + allOwnResourceVersions.stream() + .reduce((a, b) -> ReconcilerUtilsInternal.compareResourceVersions(a, b) >= 0 ? a : b) + .orElseThrow(); + return relatedEvents.stream() + .map(e -> e.getResource().orElseThrow().getMetadata().getResourceVersion()) + .anyMatch(rv -> ReconcilerUtilsInternal.compareResourceVersions(rv, lastOwn) >= 0); + } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java index afbf0a33ab..d0cec2e112 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java @@ -33,7 +33,6 @@ import io.javaoperatorsdk.operator.processing.event.ResourceID; import io.javaoperatorsdk.operator.processing.event.source.PrimaryToSecondaryMapper; import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; -import io.javaoperatorsdk.operator.processing.event.source.informer.TemporaryResourceCache.EventHandling; /** * Wraps informer(s) so they are connected to the eventing system of the framework. Note that since @@ -154,20 +153,24 @@ private synchronized void onAddOrUpdate(ResourceAction action, R newObject, R ol var eventHandling = temporaryResourceCache.onAddOrUpdateEvent(action, newObject, oldObject); - if (eventHandling != EventHandling.PROPAGATE) { - log.debug( - "{} event propagation", eventHandling == EventHandling.IGNORE ? "Deferring" : "Skipping"); + if (eventHandling.isEmpty()) { + log.debug("Deferring event propagation"); } else if (eventAcceptedByFilter(action, newObject, oldObject)) { log.debug( "Propagating event for {}, resource with same version not result of a our update.", action); - propagateEvent(newObject); + var event = eventHandling.get(); + handleEvent( + event.getAction(), + (R) event.getResource().orElseThrow(), + (R) event.getPreviousResource().orElse(null), + event.getLastStateUnknow()); } else { log.debug("Event filtered out for operation: {}, resourceID: {}", action, resourceID); } } - private void propagateEvent(R object) { + protected void propagateEvent(R object) { var primaryResourceIdSet = configuration().getSecondaryToPrimaryMapper().toPrimaryResourceIDs(object); if (primaryResourceIdSet.isEmpty()) { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java index b98837a48b..7eadbfb67e 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java @@ -85,41 +85,44 @@ public synchronized Optional doneEventFilterModify(Resourc return Optional.empty(); } var ed = activeUpdates.get(resourceID); - if (ed == null || !ed.decreaseActiveUpdates()) { - log.debug( - "Active updates {} for resource id: {}", - ed != null ? ed.getActiveUpdates() : 0, - resourceID); + if (!ed.decreaseActiveUpdates()) { + log.debug("Active updates {} for resource id: {}", ed.getActiveUpdates(), resourceID); + return Optional.empty(); + } + + if (ed.newerOrEqualEventReceivedForOwnLastUpdate()) { + activeUpdates.remove(resourceID); + return ed.prepareSummaryEventIfNotOwnEventsPresent(); + } else { return Optional.empty(); } - activeUpdates.remove(resourceID); - return ed.prepareSummaryEventIfNotOwnEventsPresent(); } - public void onDeleteEvent(T resource, boolean unknownState) { - onEvent(ResourceAction.DELETED, resource, null, unknownState); + public Optional onDeleteEvent(T resource, boolean unknownState) { + return onEvent(ResourceAction.DELETED, resource, null, unknownState); } - public EventHandling onAddOrUpdateEvent( + public Optional onAddOrUpdateEvent( ResourceAction action, T resource, T prevResourceVersion) { - return onEvent(action, resource, prevResourceVersion, false); + return onEvent(action, resource, prevResourceVersion, null); } - private synchronized EventHandling onEvent( - ResourceAction action, T resource, T prevResourceVersion, boolean unknownState) { + private synchronized Optional onEvent( + ResourceAction action, T resource, T prevResourceVersion, Boolean unknownState) { + GenericResourceEvent actualEvent = + toGenericResourceEvent(action, resource, prevResourceVersion, unknownState); if (!comparableResourceVersions) { - return EventHandling.PROPAGATE; + return Optional.of(actualEvent); } - var resourceId = ResourceID.fromResource(resource); if (log.isDebugEnabled()) { log.debug("Processing event"); } var cached = cache.get(resourceId); - EventHandling result = EventHandling.PROPAGATE; + Optional result = Optional.of(actualEvent); if (cached != null) { int comp = ReconcilerUtilsInternal.compareResourceVersions(resource, cached); - if (comp >= 0 || unknownState) { + if (comp >= 0 || Boolean.TRUE.equals(unknownState)) { log.debug( "Removing resource from temp cache. comparison: {} unknown state: {}", comp, @@ -128,7 +131,9 @@ private synchronized EventHandling onEvent( // we propagate event only for our update or newer other can be discarded since we know we // will receive // additional event - result = comp == 0 ? EventHandling.IGNORE : EventHandling.PROPAGATE; + if (comp == 0) { + result = Optional.empty(); + } } else { // in this case we received and event that might be in some edge case that was // already used in reconciler or after that, but before our updated resource version. @@ -141,13 +146,24 @@ private synchronized EventHandling onEvent( log.debug("Recording relevant event"); au.addRelatedEvent( new GenericResourceEvent(action, resource, prevResourceVersion, unknownState)); - return EventHandling.IGNORE; + // this is to cover the situation when we finished the filtering and caching update but + // did not receive events for our own updates yet. + if (au.isNoActiveUpdate() && au.newerOrEqualEventReceivedForOwnLastUpdate()) { + activeUpdates.remove(resourceId); + return au.prepareSummaryEventIfNotOwnEventsPresent(); + } + return Optional.empty(); } else { - log.debug("No active recornding, event handling: {}", result); + log.debug("No active recording, event handling: {}", result); return result; } } + static GenericResourceEvent toGenericResourceEvent( + ResourceAction action, T resource, T prevResourceVersion, Boolean unknownState) { + return new GenericResourceEvent(action, resource, prevResourceVersion, unknownState); + } + /** put the item into the cache if it's for a later state than what has already been observed. */ public synchronized void putResource(T newResource) { if (!comparableResourceVersions) { diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java index b84b7992b7..a7765da4fa 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSourceTest.java @@ -250,6 +250,7 @@ void propagatesIntermediateEventForExternalUpdateDuringFiltering() { // external update with rv 3 (older than our cached rv 4) — must propagate source.onUpdate(testResourceWithVersion(2), testResourceWithVersion(3)); latch2.countDown(); + source.onUpdate(testResourceWithVersion(3), testResourceWithVersion(5)); await().untilAsserted(() -> verify(eventHandler, times(1)).handleEvent(any())); } 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 f02082d7cd..65bb3f0fea 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 @@ -30,7 +30,6 @@ import io.fabric8.kubernetes.api.model.ObjectMeta; import io.fabric8.kubernetes.api.model.apps.Deployment; -import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder; import io.fabric8.kubernetes.client.KubernetesClient; import io.fabric8.kubernetes.client.KubernetesClientException; import io.javaoperatorsdk.operator.MockKubernetesClient; @@ -47,7 +46,6 @@ import io.javaoperatorsdk.operator.processing.event.source.EventFilterTestUtils; import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper; -import io.javaoperatorsdk.operator.processing.event.source.informer.TemporaryResourceCache.EventHandling; import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; import static io.javaoperatorsdk.operator.api.reconciler.Constants.DEFAULT_NAMESPACES_SET; @@ -114,52 +112,6 @@ public synchronized void start() {} informerEventSource.setTemporalResourceCache(temporaryResourceCache); } - @Test - void skipsEventPropagation() { - when(temporaryResourceCache.getResourceFromCache(any())) - .thenReturn(Optional.of(testDeployment())); - - when(temporaryResourceCache.onAddOrUpdateEvent(any(), any(), any())) - .thenReturn(EventHandling.IGNORE); - - informerEventSource.onAdd(testDeployment()); - informerEventSource.onUpdate(testDeployment(), testDeployment()); - - verify(eventHandlerMock, never()).handleEvent(any()); - } - - @Test - void processEventPropagationWithoutAnnotation() { - when(temporaryResourceCache.onAddOrUpdateEvent(any(), any(), any())) - .thenReturn(EventHandling.PROPAGATE); - informerEventSource.onUpdate(testDeployment(), testDeployment()); - - verify(eventHandlerMock, times(1)).handleEvent(any()); - } - - @Test - void processEventPropagationWithIncorrectAnnotation() { - when(temporaryResourceCache.onAddOrUpdateEvent(any(), any(), any())) - .thenReturn(EventHandling.PROPAGATE); - informerEventSource.onAdd( - new DeploymentBuilder(testDeployment()) - .editMetadata() - .addToAnnotations(InformerEventSource.PREVIOUS_ANNOTATION_KEY, "invalid") - .endMetadata() - .build()); - - verify(eventHandlerMock, times(1)).handleEvent(any()); - } - - @Test - void propagatesIntermediateEventHandling() { - when(temporaryResourceCache.onAddOrUpdateEvent(any(), any(), any())) - .thenReturn(EventHandling.PROPAGATE); - informerEventSource.onUpdate(testDeployment(), testDeployment()); - - verify(eventHandlerMock, times(1)).handleEvent(any()); - } - @Test void propagateEventAndRemoveResourceFromTempCacheIfResourceVersionMismatch() { withRealTemporaryResourceCache(); @@ -224,8 +176,10 @@ void handlesPrevResourceVersionForUpdate() { informerEventSource.onUpdate( deploymentWithResourceVersion(1), deploymentWithResourceVersion(2)); latch.countDown(); + informerEventSource.onUpdate( + deploymentWithResourceVersion(2), deploymentWithResourceVersion(3)); - expectHandleAddEvent(2, 1); + expectHandleAddEvent(3, 1); } @RepeatedTest(REPEAT_COUNT) @@ -273,17 +227,6 @@ void doesNotPropagateEventIfReceivedBeforeUpdate() { assertNoEventProduced(); } - @RepeatedTest(REPEAT_COUNT) - void filterAddEventBeforeUpdate() { - withRealTemporaryResourceCache(); - - CountDownLatch latch = sendForEventFilteringUpdate(3); - informerEventSource.onAdd(deploymentWithResourceVersion(2)); - latch.countDown(); - - expectHandleAddEvent(2); - } - @RepeatedTest(REPEAT_COUNT) void multipleCachingFilteringUpdates() { withRealTemporaryResourceCache(); @@ -355,6 +298,24 @@ void multipleCachingFilteringUpdates_variant4() { assertNoEventProduced(); } + @RepeatedTest(REPEAT_COUNT) + void multipleCachingFilteringUpdates_variant5() { + withRealTemporaryResourceCache(); + + CountDownLatch latch = sendForEventFilteringUpdate(3); + CountDownLatch latch2 = + sendForEventFilteringUpdate(withResourceVersion(testDeployment(), 3), 4); + latch.countDown(); + latch2.countDown(); + + informerEventSource.onUpdate( + deploymentWithResourceVersion(2), deploymentWithResourceVersion(3)); + informerEventSource.onUpdate( + deploymentWithResourceVersion(3), deploymentWithResourceVersion(4)); + + assertNoEventProduced(); + } + @RepeatedTest(REPEAT_COUNT) void ghostCheckRemovesCachedResourceDuringFilteringUpdate() { var mes = mock(ManagedInformerEventSource.class); @@ -470,10 +431,11 @@ void propagatesIntermediateEventForExternalUpdateDuringFiltering() { // external update with rv 3 (older than our cached rv 4) — must propagate informerEventSource.onUpdate( deploymentWithResourceVersion(2), deploymentWithResourceVersion(3)); - latch2.countDown(); + informerEventSource.onUpdate( + deploymentWithResourceVersion(4), deploymentWithResourceVersion(5)); - expectHandleAddEvent(3, 2); + expectHandleAddEvent(5, 2); } @RepeatedTest(REPEAT_COUNT) @@ -517,10 +479,9 @@ private void awaitCachedResourceVersion(ResourceID resourceId, String resourceVe private void assertNoEventProduced() { await() - .pollDelay(Duration.ofMillis(50)) - .timeout(Duration.ofMillis(51)) - .untilAsserted( - () -> verify(informerEventSource, never()).handleEvent(any(), any(), any(), any())); + .pollDelay(Duration.ofMillis(70)) + .timeout(Duration.ofMillis(71)) + .untilAsserted(() -> verify(informerEventSource, never()).propagateEvent(any())); } private void expectHandleAddEvent(int newResourceVersion) { diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCacheTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCacheTest.java index 84530066e1..edae142770 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCacheTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCacheTest.java @@ -25,7 +25,6 @@ import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; import io.javaoperatorsdk.operator.processing.event.ResourceID; import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; -import io.javaoperatorsdk.operator.processing.event.source.informer.TemporaryResourceCache.EventHandling; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -215,14 +214,14 @@ void putBeforeEvent() { // first ensure an event is not known var result = temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.ADDED, testResource, null); - assertThat(result).isEqualTo(EventHandling.PROPAGATE); + assertThat(result).isPresent(); var nextResource = testResource(); nextResource.getMetadata().setResourceVersion("3"); temporaryResourceCache.putResource(nextResource); result = temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.UPDATED, nextResource, null); - assertThat(result).isEqualTo(EventHandling.IGNORE); + assertThat(result).isEmpty(); } @Test @@ -232,7 +231,7 @@ void putBeforeEventWithEventFiltering() { // first ensure an event is not known var result = temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.ADDED, testResource, null); - assertThat(result).isEqualTo(EventHandling.PROPAGATE); + assertThat(result).isPresent(); latestSyncVersion = RESOURCE_VERSION; var nextResource = testResource(); @@ -245,7 +244,7 @@ void putBeforeEventWithEventFiltering() { latestSyncVersion = "3"; result = temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.UPDATED, nextResource, null); - assertThat(result).isEqualTo(EventHandling.IGNORE); + assertThat(result).isEmpty(); } @Test @@ -255,7 +254,14 @@ void putAfterEventWithEventFilteringNoPost() { // first ensure an event is not known var result = temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.ADDED, testResource, null); - assertThat(result).isEqualTo(EventHandling.PROPAGATE); + assertThat(result) + .hasValueSatisfying( + v -> { + assertThat(v.getAction()).isEqualTo(ResourceAction.ADDED); + assertThat(v.getPreviousResource()).isEmpty(); + assertThat(v.getResource()).contains(testResource); + assertThat(v.getLastStateUnknow()).isNull(); + }); var nextResource = testResource(); nextResource.getMetadata().setResourceVersion("3"); @@ -265,8 +271,8 @@ void putAfterEventWithEventFilteringNoPost() { result = temporaryResourceCache.onAddOrUpdateEvent( ResourceAction.UPDATED, nextResource, testResource); - // the result is deferred - assertThat(result).isEqualTo(EventHandling.IGNORE); + assertThat(result).isEmpty(); + temporaryResourceCache.putResource(nextResource); var postEvent = temporaryResourceCache.doneEventFilterModify(resourceId); @@ -287,7 +293,7 @@ void putAfterEventWithEventFilteringWithPost() { nextResource.getMetadata().setResourceVersion("4"); var result = temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.ADDED, nextResource, null); - assertThat(result).isEqualTo(EventHandling.IGNORE); + assertThat(result).isEmpty(); var postEvent = temporaryResourceCache.doneEventFilterModify(resourceId); @@ -310,7 +316,13 @@ void intermediateEventPropagatedWhenNoActiveUpdate() { var result = temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.UPDATED, olderEvent, null); - assertThat(result).isEqualTo(EventHandling.PROPAGATE); + assertThat(result) + .hasValueSatisfying( + e -> { + assertThat(e.getResource().orElseThrow()).isEqualTo(olderEvent); + assertThat(e.getPreviousResource()).isNotPresent(); + assertThat(e.getAction()).isEqualTo(ResourceAction.UPDATED); + }); } @Test @@ -329,7 +341,7 @@ void intermediateEventRecorded() { var result = temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.UPDATED, external, null); - assertThat(result).isEqualTo(EventHandling.IGNORE); + assertThat(result).isEmpty(); } @Test @@ -353,7 +365,7 @@ void intermediateEventDeferredWhenItIsOurOwnIntermediateUpdate() { var result = temporaryResourceCache.onAddOrUpdateEvent(ResourceAction.UPDATED, ourFirst, null); - assertThat(result).isEqualTo(EventHandling.IGNORE); + assertThat(result).isEmpty(); } @Test From 1787e4ceb29136f422491e0b15f15404896e0ef4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 9 Jun 2026 14:58:39 +0200 Subject: [PATCH 11/17] cleanup 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/TemporaryResourceCache.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java index 7eadbfb67e..51ca7516ba 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java @@ -60,11 +60,6 @@ public class TemporaryResourceCache { private final ManagedInformerEventSource managedInformerEventSource; - public enum EventHandling { - IGNORE, - PROPAGATE - } - public TemporaryResourceCache( boolean comparableResourceVersions, ManagedInformerEventSource managedInformerEventSource) { From 66c765c19790566a423eb34e4f8e757e1d9142f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 9 Jun 2026 21:48:25 +0200 Subject: [PATCH 12/17] improvements on edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../informer/TemporaryResourceCache.java | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java index 51ca7516ba..c7c301c94a 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java @@ -80,8 +80,11 @@ public synchronized Optional doneEventFilterModify(Resourc return Optional.empty(); } var ed = activeUpdates.get(resourceID); - if (!ed.decreaseActiveUpdates()) { - log.debug("Active updates {} for resource id: {}", ed.getActiveUpdates(), resourceID); + if (ed == null || !ed.decreaseActiveUpdates()) { + log.debug( + "Active updates {} for resource id: {}", + ed == null ? null : ed.getActiveUpdates(), + resourceID); return Optional.empty(); } @@ -232,7 +235,7 @@ private String getLastSyncResourceVersion(String namespace) { * explicitly add resources to this cache. Those are cleaned up by this check, which is triggered * by the informer's onList callback. */ - public void checkGhostResources() { + public synchronized void checkGhostResources() { log.debug("Checking for ghost resources."); var iterator = cache.entrySet().iterator(); while (iterator.hasNext()) { @@ -247,19 +250,18 @@ public void checkGhostResources() { e.getKey(), ns); iterator.remove(); + activeUpdates.remove(e.getKey()); continue; } if ((ReconcilerUtilsInternal.compareResourceVersions( e.getValue().getMetadata().getResourceVersion(), getLastSyncResourceVersion(ns)) < 0) // making sure we have the situation where resource is missing from the cache - && managedInformerEventSource - .manager() - .get(ResourceID.fromResource(e.getValue())) - .isEmpty()) { + && managedInformerEventSource.manager().get(e.getKey()).isEmpty()) { + log.debug("Removing ghost resource with ID: {}", e.getKey()); iterator.remove(); + activeUpdates.remove(e.getKey()); managedInformerEventSource.handleEvent(ResourceAction.DELETED, e.getValue(), null, true); - log.debug("Removing ghost resource with ID: {}", e.getKey()); } } } From 0d877223c9c728018405a6429737b3544754f994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 10 Jun 2026 09:05:12 +0200 Subject: [PATCH 13/17] Potential fix for pull request finding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Attila Mészáros --- .../event/source/controller/ControllerEventSource.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java index 7afb62ea64..9bda71effa 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/ControllerEventSource.java @@ -145,7 +145,7 @@ private void handleOnAddOrUpdate( this::handleEvent, () -> { if (log.isDebugEnabled()) { - log.debug("{} event propagation for action: {}", handling, action); + log.debug("Skipping/deferring event propagation for action: {}", action); } }); } From 2aa6194d2500ad445b6107ae898900b5714446e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 10 Jun 2026 10:30:36 +0200 Subject: [PATCH 14/17] delete related improvements and unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../source/informer/EventFilterDetails.java | 10 +- .../informer/TemporaryResourceCache.java | 5 + .../informer/EventFilterDetailsTest.java | 218 ++++++++++++++++++ .../informer/InformerEventSourceTest.java | 38 ++- 4 files changed, 266 insertions(+), 5 deletions(-) create mode 100644 operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetailsTest.java diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetails.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetails.java index b9d12f9f10..fca7e8034a 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetails.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetails.java @@ -70,10 +70,11 @@ public Optional prepareSummaryEventIfNotOwnEventsPresent() if (allOwnResourceVersions.containsAll(relatedEventResourceVersions())) { return Optional.empty(); } - var deleteEvent = - relatedEvents.stream().filter(e -> e.getAction() == ResourceAction.DELETED).findFirst(); - if (deleteEvent.isPresent()) { - return deleteEvent; + // we propagate delete event only if it is the last, if there are newer events + // means the resource was re-created (not necessarily by our controller) + var lastEvent = relatedEvents.get(relatedEvents.size() - 1); + if (lastEvent.getAction() == ResourceAction.DELETED) { + return Optional.of(lastEvent); } if (relatedEvents.size() == 1) { return Optional.of(relatedEvents.get(0)); @@ -97,6 +98,7 @@ private Set relatedEventResourceVersions() { } public boolean newerOrEqualEventReceivedForOwnLastUpdate() { + // this means our update was not successful if (allOwnResourceVersions.isEmpty()) { return true; } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java index c7c301c94a..e157da75ad 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java @@ -277,4 +277,9 @@ synchronized boolean isEmpty() { synchronized Map getResources() { return Collections.unmodifiableMap(cache); } + + // for testing purposes + synchronized Map getActiveUpdates() { + return Collections.unmodifiableMap(activeUpdates); + } } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetailsTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetailsTest.java new file mode 100644 index 0000000000..8ed705f251 --- /dev/null +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterDetailsTest.java @@ -0,0 +1,218 @@ +/* + * 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.processing.event.source.informer; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; + +import static org.assertj.core.api.Assertions.assertThat; + +class EventFilterDetailsTest { + + private EventFilterDetails details; + + @BeforeEach + void setup() { + details = new EventFilterDetails(); + } + + @Test + void activeUpdatesCounter() { + assertThat(details.isNoActiveUpdate()).isTrue(); + assertThat(details.getActiveUpdates()).isZero(); + + details.increaseActiveUpdates(); + details.increaseActiveUpdates(); + assertThat(details.getActiveUpdates()).isEqualTo(2); + assertThat(details.isNoActiveUpdate()).isFalse(); + + assertThat(details.decreaseActiveUpdates()).isFalse(); + assertThat(details.getActiveUpdates()).isEqualTo(1); + + assertThat(details.decreaseActiveUpdates()).isTrue(); + assertThat(details.isNoActiveUpdate()).isTrue(); + } + + @Test + void summaryEmptyWhenAllRelatedEventsAreOwn() { + details.addToOwnResourceVersions("2"); + details.addToOwnResourceVersions("3"); + details.addRelatedEvent(updatedEvent("2", null)); + details.addRelatedEvent(updatedEvent("3", "2")); + + assertThat(details.prepareSummaryEventIfNotOwnEventsPresent()).isEmpty(); + } + + @Test + void summaryReturnsSingleNonOwnEvent() { + var thirdParty = updatedEvent("4", "3"); + details.addToOwnResourceVersions("2"); + details.addRelatedEvent(thirdParty); + + var summary = details.prepareSummaryEventIfNotOwnEventsPresent(); + + assertThat(summary).contains(thirdParty); + } + + @Test + void summaryReturnsLastEventWhenItIsDelete() { + var firstUpdate = updatedEvent("3", "2"); + var deleteAtEnd = deleteEvent("4"); + details.addRelatedEvent(firstUpdate); + details.addRelatedEvent(deleteAtEnd); + + var summary = details.prepareSummaryEventIfNotOwnEventsPresent(); + + assertThat(summary).contains(deleteAtEnd); + } + + @Test + void summaryDoesNotReturnDeleteWhenItIsNotLast() { + // simulates a delete-then-recreate sequence inside the filter window: + // returning the DELETE would mask the fact that the resource exists again. + var deleteEvent = deleteEvent("3"); + var recreate = addedEvent("4"); + details.addRelatedEvent(deleteEvent); + details.addRelatedEvent(recreate); + + var summary = details.prepareSummaryEventIfNotOwnEventsPresent(); + + assertThat(summary).isPresent(); + assertThat(summary.get().getAction()).isEqualTo(ResourceAction.UPDATED); + assertThat(summary.get().getResource().orElseThrow()).isEqualTo(recreate.getResource().get()); + } + + @Test + void summarySynthesizesUpdatedFromFirstPreviousToLastResource() { + var first = updatedEvent("3", "2"); + var middle = updatedEvent("4", "3"); + var last = updatedEvent("5", "4"); + details.addRelatedEvent(first); + details.addRelatedEvent(middle); + details.addRelatedEvent(last); + + var summary = details.prepareSummaryEventIfNotOwnEventsPresent().orElseThrow(); + + assertThat(summary.getAction()).isEqualTo(ResourceAction.UPDATED); + assertThat(summary.getResource().orElseThrow()).isEqualTo(last.getResource().get()); + assertThat(summary.getPreviousResource().orElseThrow()) + .isEqualTo(first.getPreviousResource().get()); + assertThat(summary.getLastStateUnknow()).isNull(); + } + + @Test + void summaryUsesFirstResourceAsPreviousWhenFirstEventHasNoPrevious() { + // first event is ADD (no previous resource); synthesis must fall back to the resource itself. + var added = addedEvent("3"); + var updated = updatedEvent("4", "3"); + details.addRelatedEvent(added); + details.addRelatedEvent(updated); + + var summary = details.prepareSummaryEventIfNotOwnEventsPresent().orElseThrow(); + + assertThat(summary.getAction()).isEqualTo(ResourceAction.UPDATED); + assertThat(summary.getResource().orElseThrow()).isEqualTo(updated.getResource().get()); + assertThat(summary.getPreviousResource().orElseThrow()).isEqualTo(added.getResource().get()); + } + + @Test + void summarySkipsOwnFilterWhenAtLeastOneEventIsForeign() { + // even with own rvs in the mix, presence of a non-own event must surface a summary. + details.addToOwnResourceVersions("3"); + var ownEvent = updatedEvent("3", "2"); + var foreign = updatedEvent("4", "3"); + details.addRelatedEvent(ownEvent); + details.addRelatedEvent(foreign); + + var summary = details.prepareSummaryEventIfNotOwnEventsPresent().orElseThrow(); + + assertThat(summary.getAction()).isEqualTo(ResourceAction.UPDATED); + assertThat(summary.getResource().orElseThrow()).isEqualTo(foreign.getResource().get()); + assertThat(summary.getPreviousResource().orElseThrow()) + .isEqualTo(ownEvent.getPreviousResource().get()); + } + + @Test + void newerOrEqualReturnsTrueWhenNoOwnVersions() { + assertThat(details.newerOrEqualEventReceivedForOwnLastUpdate()).isTrue(); + details.addRelatedEvent(updatedEvent("2", null)); + assertThat(details.newerOrEqualEventReceivedForOwnLastUpdate()).isTrue(); + } + + @Test + void newerOrEqualReturnsFalseWhenNoRelatedEventsYet() { + details.addToOwnResourceVersions("3"); + + assertThat(details.newerOrEqualEventReceivedForOwnLastUpdate()).isFalse(); + } + + @Test + void newerOrEqualReturnsFalseWhenAllRelatedAreOlderThanLastOwn() { + details.addToOwnResourceVersions("5"); + details.addRelatedEvent(updatedEvent("3", "2")); + details.addRelatedEvent(updatedEvent("4", "3")); + + assertThat(details.newerOrEqualEventReceivedForOwnLastUpdate()).isFalse(); + } + + @Test + void newerOrEqualReturnsTrueWhenRelatedMatchesLastOwn() { + details.addToOwnResourceVersions("3"); + details.addToOwnResourceVersions("5"); + details.addRelatedEvent(updatedEvent("5", "4")); + + assertThat(details.newerOrEqualEventReceivedForOwnLastUpdate()).isTrue(); + } + + @Test + void newerOrEqualReturnsTrueWhenRelatedNewerThanLastOwn() { + details.addToOwnResourceVersions("3"); + details.addRelatedEvent(updatedEvent("7", "3")); + + assertThat(details.newerOrEqualEventReceivedForOwnLastUpdate()).isTrue(); + } + + private static GenericResourceEvent addedEvent(String resourceVersion) { + return new GenericResourceEvent(ResourceAction.ADDED, resource(resourceVersion), null, null); + } + + private static GenericResourceEvent updatedEvent( + String resourceVersion, String previousResourceVersion) { + var prev = previousResourceVersion == null ? null : resource(previousResourceVersion); + return new GenericResourceEvent(ResourceAction.UPDATED, resource(resourceVersion), prev, null); + } + + private static GenericResourceEvent deleteEvent(String resourceVersion) { + return new GenericResourceEvent(ResourceAction.DELETED, resource(resourceVersion), null, null); + } + + private static ConfigMap resource(String resourceVersion) { + return new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName("test") + .withNamespace("default") + .withUid("test-uid") + .withResourceVersion(resourceVersion) + .build()) + .build(); + } +} 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 65bb3f0fea..6ee9a453bf 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 @@ -71,7 +71,7 @@ class InformerEventSourceTest { private static final String PREV_RESOURCE_VERSION = "0"; private static final String DEFAULT_RESOURCE_VERSION = "2"; - public static final int REPEAT_COUNT = 10; + public static final int REPEAT_COUNT = 5; private InformerEventSource informerEventSource; private final KubernetesClient clientMock = MockKubernetesClient.client(Deployment.class); @@ -466,6 +466,23 @@ void doesNotPropagateIntermediateEventForOurOwnIntermediateUpdate() { latch3.countDown(); } + @RepeatedTest(REPEAT_COUNT) + void deleteEventPropagatedIfItWasTheLastEvent() { + // Within an open filter window, an external UPDATE arrives followed by a DELETE. + // The summary must surface the DELETE since it represents the final state. + withRealTemporaryResourceCache(); + + var latch = sendForEventFilteringUpdate(3); + + informerEventSource.onUpdate( + deploymentWithResourceVersion(3), deploymentWithResourceVersion(4)); + informerEventSource.onDelete(deploymentWithResourceVersion(5), false); + + latch.countDown(); + + expectHandleDeleteEvent(5); + } + private void awaitCachedResourceVersion(ResourceID resourceId, String resourceVersion) { await() .untilAsserted( @@ -527,6 +544,25 @@ private void expectHandleAddEvent(int newResourceVersion, int oldResourceVersion }); } + private void expectHandleDeleteEvent(int resourceVersion) { + await() + .atMost(Duration.ofSeconds(1)) + .untilAsserted( + () -> { + verify(informerEventSource, times(1)) + .handleEvent( + eq(ResourceAction.DELETED), + argThat( + newResource -> { + assertThat(newResource.getMetadata().getResourceVersion()) + .isEqualTo("" + resourceVersion); + return true; + }), + isNull(), + any()); + }); + } + private CountDownLatch sendForEventFilteringUpdate(int resourceVersion) { return sendForEventFilteringUpdate(testDeployment(), resourceVersion); } From 24a710bc44dc78bdda2b17e206b305b29ce87c15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 10 Jun 2026 10:49:45 +0200 Subject: [PATCH 15/17] delete handling improvements and test improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../source/informer/InformerEventSource.java | 15 ++++++++--- .../informer/TemporaryResourceCache.java | 9 +++++++ .../informer/InformerEventSourceTest.java | 25 +++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java index d0cec2e112..ea2ab89c2d 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerEventSource.java @@ -122,8 +122,15 @@ public synchronized void onDelete(R resource, boolean deletedFinalStateUnknown) log.debug( "On delete event received. deletedFinalStateUnknown: {}", deletedFinalStateUnknown); } + var resultEvent = + temporaryResourceCache.onDeleteEvent(resource, deletedFinalStateUnknown); + if (resultEvent.isEmpty()) { + return; + } + if (resultEvent.orElseThrow().getAction() != ResourceAction.DELETED) { + log.warn("Non delete event received on onDelete handling. This should not happen."); + } primaryToSecondaryIndex.onDelete(resource); - temporaryResourceCache.onDeleteEvent(resource, deletedFinalStateUnknown); if (acceptedByDeleteFilters(resource, deletedFinalStateUnknown)) { propagateEvent(resource); } @@ -151,15 +158,15 @@ private synchronized void onAddOrUpdate(ResourceAction action, R newObject, R ol primaryToSecondaryIndex.onAddOrUpdate(newObject); var resourceID = ResourceID.fromResource(newObject); - var eventHandling = temporaryResourceCache.onAddOrUpdateEvent(action, newObject, oldObject); + var resultEvent = temporaryResourceCache.onAddOrUpdateEvent(action, newObject, oldObject); - if (eventHandling.isEmpty()) { + if (resultEvent.isEmpty()) { log.debug("Deferring event propagation"); } else if (eventAcceptedByFilter(action, newObject, oldObject)) { log.debug( "Propagating event for {}, resource with same version not result of a our update.", action); - var event = eventHandling.get(); + var event = resultEvent.get(); handleEvent( event.getAction(), (R) event.getResource().orElseThrow(), diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java index e157da75ad..2670fb7f33 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/TemporaryResourceCache.java @@ -48,6 +48,15 @@ * *

If comparable resource versions are disabled, then this cache is effectively disabled. * + *

Some principles to realize with the current filtering algorithm: + * + *

    + *
  • We propagate events only if we received an event that has the same resourceVersion or newer + * than resource version from update + *
  • The propagated event should correspond to a possible real world scenario - considering also + * ones that could happen is the Informer does a re-list. + *
+ * * @param resource to cache. */ public class TemporaryResourceCache { 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 6ee9a453bf..ce415f6a33 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 @@ -180,6 +180,7 @@ void handlesPrevResourceVersionForUpdate() { deploymentWithResourceVersion(2), deploymentWithResourceVersion(3)); expectHandleAddEvent(3, 1); + expectNoActiveUpdates(); } @RepeatedTest(REPEAT_COUNT) @@ -198,6 +199,7 @@ void handlesPrevResourceVersionForUpdateInCaseOfException() { latch.countDown(); expectHandleAddEvent(2, 1); + expectNoActiveUpdates(); } @RepeatedTest(REPEAT_COUNT) @@ -213,6 +215,7 @@ void handlesPrevResourceVersionForUpdateInCaseOfMultipleUpdates() { latch.countDown(); expectHandleAddEvent(4, 2); + expectNoActiveUpdates(); } @RepeatedTest(REPEAT_COUNT) @@ -225,6 +228,7 @@ void doesNotPropagateEventIfReceivedBeforeUpdate() { latch.countDown(); assertNoEventProduced(); + expectNoActiveUpdates(); } @RepeatedTest(REPEAT_COUNT) @@ -242,6 +246,7 @@ void multipleCachingFilteringUpdates() { deploymentWithResourceVersion(3), deploymentWithResourceVersion(4)); assertNoEventProduced(); + expectNoActiveUpdates(); } @RepeatedTest(REPEAT_COUNT) @@ -260,6 +265,7 @@ void multipleCachingFilteringUpdates_variant2() { latch2.countDown(); assertNoEventProduced(); + expectNoActiveUpdates(); } @RepeatedTest(REPEAT_COUNT) @@ -278,6 +284,7 @@ void multipleCachingFilteringUpdates_variant3() { latch2.countDown(); assertNoEventProduced(); + expectNoActiveUpdates(); } @RepeatedTest(REPEAT_COUNT) @@ -296,6 +303,7 @@ void multipleCachingFilteringUpdates_variant4() { latch2.countDown(); assertNoEventProduced(); + expectNoActiveUpdates(); } @RepeatedTest(REPEAT_COUNT) @@ -314,6 +322,7 @@ void multipleCachingFilteringUpdates_variant5() { deploymentWithResourceVersion(3), deploymentWithResourceVersion(4)); assertNoEventProduced(); + expectNoActiveUpdates(); } @RepeatedTest(REPEAT_COUNT) @@ -436,6 +445,7 @@ void propagatesIntermediateEventForExternalUpdateDuringFiltering() { deploymentWithResourceVersion(4), deploymentWithResourceVersion(5)); expectHandleAddEvent(5, 2); + expectNoActiveUpdates(); } @RepeatedTest(REPEAT_COUNT) @@ -464,6 +474,14 @@ void doesNotPropagateIntermediateEventForOurOwnIntermediateUpdate() { verify(eventHandlerMock, never()).handleEvent(any()); latch3.countDown(); + awaitCachedResourceVersion(resourceId, "5"); + // drain the filter with the event for our own rv 5 — all events are now own, + // summary must be empty and no event propagated. + informerEventSource.onUpdate( + deploymentWithResourceVersion(4), deploymentWithResourceVersion(5)); + + assertNoEventProduced(); + expectNoActiveUpdates(); } @RepeatedTest(REPEAT_COUNT) @@ -481,6 +499,7 @@ void deleteEventPropagatedIfItWasTheLastEvent() { latch.countDown(); expectHandleDeleteEvent(5); + expectNoActiveUpdates(); } private void awaitCachedResourceVersion(ResourceID resourceId, String resourceVersion) { @@ -501,6 +520,12 @@ private void assertNoEventProduced() { .untilAsserted(() -> verify(informerEventSource, never()).propagateEvent(any())); } + private void expectNoActiveUpdates() { + await() + .atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> assertThat(temporaryResourceCache.getActiveUpdates()).isEmpty()); + } + private void expectHandleAddEvent(int newResourceVersion) { await() .atMost(Duration.ofSeconds(1)) From 2deff066c36e877e99ebf4f51d6b1b9a69b1e3b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 10 Jun 2026 11:24:36 +0200 Subject: [PATCH 16/17] wip 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 | 132 +++++++++++++++++- 1 file changed, 125 insertions(+), 7 deletions(-) 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 ce415f6a33..afdb1664ab 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 @@ -168,6 +168,28 @@ void filtersOnDeleteEvents() { verify(eventHandlerMock, never()).handleEvent(any()); } + @Test + void deletePropagatesEventWhenTempCacheReturnsDeleteEvent() { + var resource = testDeployment(); + when(temporaryResourceCache.onDeleteEvent(resource, false)) + .thenReturn( + Optional.of(new GenericResourceEvent(ResourceAction.DELETED, resource, null, false))); + + informerEventSource.onDelete(resource, false); + + verify(eventHandlerMock, times(1)).handleEvent(any()); + } + + @Test + void deleteDoesNotPropagateWhenTempCacheReturnsEmpty() { + var resource = testDeployment(); + when(temporaryResourceCache.onDeleteEvent(resource, false)).thenReturn(Optional.empty()); + + informerEventSource.onDelete(resource, false); + + verify(eventHandlerMock, never()).handleEvent(any()); + } + @RepeatedTest(REPEAT_COUNT) void handlesPrevResourceVersionForUpdate() { withRealTemporaryResourceCache(); @@ -187,13 +209,7 @@ void handlesPrevResourceVersionForUpdate() { void handlesPrevResourceVersionForUpdateInCaseOfException() { withRealTemporaryResourceCache(); - CountDownLatch latch = - EventFilterTestUtils.sendForEventFilteringUpdate( - informerEventSource, - testDeployment(), - r -> { - throw new KubernetesClientException("fake"); - }); + CountDownLatch latch = sendForExceptionThrowingUpdate(); informerEventSource.onUpdate( deploymentWithResourceVersion(1), deploymentWithResourceVersion(2)); latch.countDown(); @@ -202,6 +218,99 @@ void handlesPrevResourceVersionForUpdateInCaseOfException() { expectNoActiveUpdates(); } + @RepeatedTest(REPEAT_COUNT) + void failedUpdate_withNoEventsDuringWindow_propagatesNothing() { + // No event arrives between start and the thrown exception. doneEventFilterModify + // sees an empty filter window with no own writes — summary must be empty. + withRealTemporaryResourceCache(); + + CountDownLatch latch = sendForExceptionThrowingUpdate(); + latch.countDown(); + + assertNoEventProduced(); + expectNoActiveUpdates(); + assertThat(temporaryResourceCache.getResources()).isEmpty(); + } + + @RepeatedTest(REPEAT_COUNT) + void failedUpdate_withMultipleEventsDuringWindow_synthesizesSummary() { + // Multiple foreign updates arrive while we are about to fail. Since no own write + // happened, every related event is foreign and must be folded into one summary + // event spanning first.previous → last.resource. + withRealTemporaryResourceCache(); + + CountDownLatch latch = sendForExceptionThrowingUpdate(); + informerEventSource.onUpdate( + deploymentWithResourceVersion(1), deploymentWithResourceVersion(2)); + informerEventSource.onUpdate( + deploymentWithResourceVersion(2), deploymentWithResourceVersion(3)); + latch.countDown(); + + expectHandleAddEvent(3, 1); + expectNoActiveUpdates(); + } + + @RepeatedTest(REPEAT_COUNT) + void failedUpdate_withDeleteEventDuringWindow_propagatesDelete() { + // delete arrives during the (failing) filter window — must surface as DELETE. + withRealTemporaryResourceCache(); + + CountDownLatch latch = sendForExceptionThrowingUpdate(); + informerEventSource.onDelete(deploymentWithResourceVersion(2), false); + latch.countDown(); + + expectHandleDeleteEvent(2); + expectNoActiveUpdates(); + } + + @RepeatedTest(REPEAT_COUNT) + void failedUpdate_withUpdateThenDelete_propagatesDelete() { + // Update followed by delete inside a failing filter window: last event is DELETE, + // so the summary must surface the delete (not a synthesized update). + withRealTemporaryResourceCache(); + + CountDownLatch latch = sendForExceptionThrowingUpdate(); + informerEventSource.onUpdate( + deploymentWithResourceVersion(1), deploymentWithResourceVersion(2)); + informerEventSource.onDelete(deploymentWithResourceVersion(3), false); + latch.countDown(); + + expectHandleDeleteEvent(3); + expectNoActiveUpdates(); + } + + @RepeatedTest(REPEAT_COUNT) + void failedUpdate_doesNotPopulateTempCache() { + // putResource is only called from handleRecentResourceUpdate, which never runs + // when updateMethod throws. The temp cache must therefore stay empty. + withRealTemporaryResourceCache(); + + CountDownLatch latch = sendForExceptionThrowingUpdate(); + informerEventSource.onUpdate( + deploymentWithResourceVersion(1), deploymentWithResourceVersion(2)); + latch.countDown(); + + expectHandleAddEvent(2, 1); + expectNoActiveUpdates(); + assertThat(temporaryResourceCache.getResources()).isEmpty(); + } + + @RepeatedTest(REPEAT_COUNT) + void eventReceivedAfterFailedUpdate_isPropagatedNormally() { + // After the exception unwinds and the filter window is fully closed, subsequent + // events must propagate via the regular non-filtered path. + withRealTemporaryResourceCache(); + + CountDownLatch latch = sendForExceptionThrowingUpdate(); + latch.countDown(); + expectNoActiveUpdates(); + + informerEventSource.onUpdate( + deploymentWithResourceVersion(1), deploymentWithResourceVersion(2)); + + expectHandleAddEvent(2, 1); + } + @RepeatedTest(REPEAT_COUNT) void handlesPrevResourceVersionForUpdateInCaseOfMultipleUpdates() { withRealTemporaryResourceCache(); @@ -597,6 +706,15 @@ private CountDownLatch sendForEventFilteringUpdate(Deployment deployment, int re informerEventSource, deployment, r -> withResourceVersion(deployment, resourceVersion)); } + private CountDownLatch sendForExceptionThrowingUpdate() { + return EventFilterTestUtils.sendForEventFilteringUpdate( + informerEventSource, + testDeployment(), + r -> { + throw new KubernetesClientException("fake"); + }); + } + private void withRealTemporaryResourceCache() { var mes = mock(ManagedInformerEventSource.class); var mim = mock(InformerManager.class); From 6a2ae15dbc83e27444f5cbaa492bf84f2d72f8a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 10 Jun 2026 13:01:58 +0200 Subject: [PATCH 17/17] tests 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 | 47 ++++++ .../CachingFilteringUpdateIT.java | 82 ---------- .../CachingFilteringUpdateReconciler.java | 146 ------------------ ...etionDuringStatusUpdateCustomResource.java | 2 +- .../DeletionDuringStatusUpdateIT.java | 2 +- .../DeletionDuringStatusUpdateReconciler.java | 2 +- .../DeletionDuringStatusUpdateStatus.java | 2 +- ...xternalSecondaryUpdateCustomResource.java} | 8 +- .../ExternalSecondaryUpdateIT.java | 110 +++++++++++++ .../ExternalSecondaryUpdateReconciler.java | 120 ++++++++++++++ .../ExternalSecondaryUpdateStatus.java | 30 ++++ ...alUpdateDuringOwnUpdateCustomResource.java | 28 ++++ .../ExternalUpdateDuringOwnUpdateIT.java | 88 +++++++++++ ...ternalUpdateDuringOwnUpdateReconciler.java | 80 ++++++++++ .../ExternalUpdateDuringOwnUpdateStatus.java} | 15 +- .../filterpatchevent/FilterPatchEventIT.java | 2 +- .../FilterPatchEventTestCustomResource.java | 2 +- ...terPatchEventTestCustomResourceStatus.java | 2 +- .../FilterPatchEventTestReconciler.java | 4 +- 19 files changed, 524 insertions(+), 248 deletions(-) delete mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cachingfilteringupdate/CachingFilteringUpdateIT.java delete mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cachingfilteringupdate/CachingFilteringUpdateReconciler.java rename operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/{ => readcacheafterwrite}/deletionduringstatusupdate/DeletionDuringStatusUpdateCustomResource.java (92%) rename operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/{ => readcacheafterwrite}/deletionduringstatusupdate/DeletionDuringStatusUpdateIT.java (97%) rename operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/{ => readcacheafterwrite}/deletionduringstatusupdate/DeletionDuringStatusUpdateReconciler.java (96%) rename operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/{ => readcacheafterwrite}/deletionduringstatusupdate/DeletionDuringStatusUpdateStatus.java (89%) rename operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/{cachingfilteringupdate/CachingFilteringUpdateCustomResource.java => readcacheafterwrite/externalsecondaryupdate/ExternalSecondaryUpdateCustomResource.java} (78%) create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalsecondaryupdate/ExternalSecondaryUpdateIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalsecondaryupdate/ExternalSecondaryUpdateReconciler.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalsecondaryupdate/ExternalSecondaryUpdateStatus.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalupdateduringownupdate/ExternalUpdateDuringOwnUpdateCustomResource.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalupdateduringownupdate/ExternalUpdateDuringOwnUpdateIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalupdateduringownupdate/ExternalUpdateDuringOwnUpdateReconciler.java rename operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/{cachingfilteringupdate/CachingFilteringUpdateStatus.java => readcacheafterwrite/externalupdateduringownupdate/ExternalUpdateDuringOwnUpdateStatus.java} (65%) rename operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/{ => readcacheafterwrite}/filterpatchevent/FilterPatchEventIT.java (98%) rename operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/{ => readcacheafterwrite}/filterpatchevent/FilterPatchEventTestCustomResource.java (93%) rename operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/{ => readcacheafterwrite}/filterpatchevent/FilterPatchEventTestCustomResourceStatus.java (91%) rename operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/{ => readcacheafterwrite}/filterpatchevent/FilterPatchEventTestReconciler.java (91%) 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 afdb1664ab..b39d1af6a6 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 @@ -529,6 +529,53 @@ void filteringUpdateAndGhostCheckWithNamespaceChange() { assertThat(temporaryResourceCache.getResourceFromCache(resourceId)).isEmpty(); } + @RepeatedTest(REPEAT_COUNT) + void ghostCheckDuringOpenFilteringUpdate_cleansUpAndDoneIsNoOp() { + // Combines the real eventFilteringUpdateAndCacheResource flow with a ghost-resource + // cleanup happening while a second filter window is still open. The ghost check + // must clear cache + activeUpdates and fire a synthetic DELETE; the still-open + // filter's later doneEventFilterModify must complete cleanly (no NPE on the + // already-removed EventFilterDetails) and not propagate any further events. + var mes = mock(ManagedInformerEventSource.class); + var mim = mock(InformerManager.class); + when(mes.manager()).thenReturn(mim); + when(mim.isWatchingNamespace(any())).thenReturn(true); + when(mim.lastSyncResourceVersion(any())).thenReturn("1"); + when(mim.get(any())).thenReturn(Optional.empty()); + + temporaryResourceCache = spy(new TemporaryResourceCache<>(true, mes)); + informerEventSource.setTemporalResourceCache(temporaryResourceCache); + + var resourceId = ResourceID.fromResource(testDeployment()); + + // first filter completes and caches rv 2; second filter keeps the window open + var latch1 = sendForEventFilteringUpdate(2); + var latch2 = sendForEventFilteringUpdate(deploymentWithResourceVersion(2), 3); + + latch1.countDown(); + awaitCachedResourceVersion(resourceId, "2"); + + // simulate watch disconnect + relist while the second filter is still open: + // lastSync moved well past our cached rv, informer no longer has the resource + when(mim.lastSyncResourceVersion(any())).thenReturn("10"); + + temporaryResourceCache.checkGhostResources(); + + // ghost cleanup wiped both cache and activeUpdates + assertThat(temporaryResourceCache.getResourceFromCache(resourceId)).isEmpty(); + assertThat(temporaryResourceCache.getActiveUpdates()).isEmpty(); + + // synthetic DELETE fired through the cache's manager reference + verify(mes, times(1)).handleEvent(eq(ResourceAction.DELETED), any(), isNull(), eq(true)); + + // closing the still-open filter must not NPE on the missing EventFilterDetails + // and must not propagate anything + latch2.countDown(); + + assertNoEventProduced(); + expectNoActiveUpdates(); + } + @RepeatedTest(REPEAT_COUNT) void propagatesIntermediateEventForExternalUpdateDuringFiltering() { // Causal-dependency fix: another controller updated the resource between our read diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cachingfilteringupdate/CachingFilteringUpdateIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cachingfilteringupdate/CachingFilteringUpdateIT.java deleted file mode 100644 index c62c8ca186..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cachingfilteringupdate/CachingFilteringUpdateIT.java +++ /dev/null @@ -1,82 +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.baseapi.cachingfilteringupdate; - -import java.time.Duration; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; - -import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; -import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.awaitility.Awaitility.await; - -class CachingFilteringUpdateIT { - - public static final int RESOURCE_NUMBER = 250; - CachingFilteringUpdateReconciler reconciler = new CachingFilteringUpdateReconciler(); - - @RegisterExtension - LocallyRunOperatorExtension operator = - LocallyRunOperatorExtension.builder().withReconciler(reconciler).build(); - - @Test - void testResourceAccessAfterUpdate() { - for (int i = 0; i < RESOURCE_NUMBER; i++) { - operator.create(createCustomResource(i)); - } - await() - .pollDelay(Duration.ofSeconds(5)) - .atMost(Duration.ofMinutes(1)) - .until( - () -> { - if (reconciler.isIssueFound()) { - // Stop waiting as soon as an issue is detected. - return true; - } - // Use a single representative resource to detect that updates have completed. - var res = - operator.get( - CachingFilteringUpdateCustomResource.class, - "resource" + (RESOURCE_NUMBER - 1)); - return res != null - && res.getStatus() != null - && Boolean.TRUE.equals(res.getStatus().getUpdated()); - }); - - if (operator.getReconcilerOfType(CachingFilteringUpdateReconciler.class).isIssueFound()) { - throw new IllegalStateException("Error already found."); - } - - for (int i = 0; i < RESOURCE_NUMBER; i++) { - var res = operator.get(CachingFilteringUpdateCustomResource.class, "resource" + i); - assertThat(res.getStatus()).isNotNull(); - assertThat(res.getStatus().getUpdated()).isTrue(); - } - } - - public CachingFilteringUpdateCustomResource createCustomResource(int i) { - CachingFilteringUpdateCustomResource resource = new CachingFilteringUpdateCustomResource(); - resource.setMetadata( - new ObjectMetaBuilder() - .withName("resource" + i) - .withNamespace(operator.getNamespace()) - .build()); - return resource; - } -} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cachingfilteringupdate/CachingFilteringUpdateReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cachingfilteringupdate/CachingFilteringUpdateReconciler.java deleted file mode 100644 index c8fc206106..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cachingfilteringupdate/CachingFilteringUpdateReconciler.java +++ /dev/null @@ -1,146 +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.baseapi.cachingfilteringupdate; - -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicBoolean; - -import io.fabric8.kubernetes.api.model.ConfigMap; -import io.fabric8.kubernetes.api.model.ConfigMapBuilder; -import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; -import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; -import io.javaoperatorsdk.operator.api.reconciler.Context; -import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; -import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; -import io.javaoperatorsdk.operator.api.reconciler.Reconciler; -import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; -import io.javaoperatorsdk.operator.processing.event.ResourceID; -import io.javaoperatorsdk.operator.processing.event.source.EventSource; -import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; - -@ControllerConfiguration -public class CachingFilteringUpdateReconciler - implements Reconciler { - - public static final String RESOURCE_VERSION_INDEX = "resourceVersionIndex"; - private final AtomicBoolean issueFound = new AtomicBoolean(false); - - private InformerEventSource configMapEventSource; - - @Override - public UpdateControl reconcile( - CachingFilteringUpdateCustomResource resource, - Context context) { - try { - var updated = context.resourceOperations().serverSideApply(prepareCM(resource, 1)); - var cachedCM = context.getSecondaryResource(ConfigMap.class); - if (cachedCM.isEmpty()) { - throw new IllegalStateException("Error for resource: " + ResourceID.fromResource(resource)); - } - checkListContainsCM(updated); - checkIfResourceVersionIndexContainsUpdated(updated); - updated = context.resourceOperations().serverSideApply(prepareCM(resource, 2)); - cachedCM = context.getSecondaryResource(ConfigMap.class); - if (!cachedCM - .orElseThrow() - .getMetadata() - .getResourceVersion() - .equals(updated.getMetadata().getResourceVersion())) { - throw new IllegalStateException( - "Update error for resource: " + ResourceID.fromResource(resource)); - } - checkListContainsCM(updated); - checkIfResourceVersionIndexContainsUpdated(updated); - - ensureStatusExists(resource); - resource.getStatus().setUpdated(true); - return UpdateControl.patchStatus(resource); - } catch (IllegalStateException e) { - issueFound.set(true); - throw e; - } - } - - private void checkIfResourceVersionIndexContainsUpdated(ConfigMap updated) { - if (configMapEventSource - .byIndex(RESOURCE_VERSION_INDEX, updated.getMetadata().getResourceVersion()) - .stream() - .noneMatch( - r -> - ResourceID.fromResource(r).equals(ResourceID.fromResource(updated)) - && r.getMetadata() - .getResourceVersion() - .equals(updated.getMetadata().getResourceVersion()))) { - throw new IllegalStateException( - "Index does not contain resource: " + ResourceID.fromResource(updated)); - } - } - - private void checkListContainsCM(ConfigMap updated) { - if (configMapEventSource - .list() - .noneMatch( - r -> - ResourceID.fromResource(r).equals(ResourceID.fromResource(updated)) - && r.getMetadata() - .getResourceVersion() - .equals(updated.getMetadata().getResourceVersion()))) { - throw new IllegalStateException( - "List does not contain resource: " + ResourceID.fromResource(updated)); - } - } - - private static ConfigMap prepareCM(CachingFilteringUpdateCustomResource p, int num) { - var cm = - new ConfigMapBuilder() - .withMetadata( - new ObjectMetaBuilder() - .withName(p.getMetadata().getName()) - .withNamespace(p.getMetadata().getNamespace()) - .build()) - .withData(Map.of("name", p.getMetadata().getName(), "num", "" + num)) - .build(); - cm.addOwnerReference(p); - return cm; - } - - @Override - public List> prepareEventSources( - EventSourceContext context) { - configMapEventSource = - new InformerEventSource<>( - InformerEventSourceConfiguration.from( - ConfigMap.class, CachingFilteringUpdateCustomResource.class) - .build(), - context); - configMapEventSource.addIndexers( - Map.of(RESOURCE_VERSION_INDEX, cm -> List.of(cm.getMetadata().getResourceVersion()))); - return List.of(configMapEventSource); - } - - private void ensureStatusExists(CachingFilteringUpdateCustomResource resource) { - CachingFilteringUpdateStatus status = resource.getStatus(); - if (status == null) { - status = new CachingFilteringUpdateStatus(); - resource.setStatus(status); - } - } - - public boolean isIssueFound() { - return issueFound.get(); - } -} 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/readcacheafterwrite/deletionduringstatusupdate/DeletionDuringStatusUpdateCustomResource.java similarity index 92% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deletionduringstatusupdate/DeletionDuringStatusUpdateCustomResource.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/deletionduringstatusupdate/DeletionDuringStatusUpdateCustomResource.java index 5cb1170c34..60d09f82f8 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deletionduringstatusupdate/DeletionDuringStatusUpdateCustomResource.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/deletionduringstatusupdate/DeletionDuringStatusUpdateCustomResource.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.baseapi.deletionduringstatusupdate; +package io.javaoperatorsdk.operator.baseapi.readcacheafterwrite.deletionduringstatusupdate; import io.fabric8.kubernetes.api.model.Namespaced; import io.fabric8.kubernetes.client.CustomResource; 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/readcacheafterwrite/deletionduringstatusupdate/DeletionDuringStatusUpdateIT.java similarity index 97% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deletionduringstatusupdate/DeletionDuringStatusUpdateIT.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/deletionduringstatusupdate/DeletionDuringStatusUpdateIT.java index 7574dd07b4..3012c9538c 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deletionduringstatusupdate/DeletionDuringStatusUpdateIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/deletionduringstatusupdate/DeletionDuringStatusUpdateIT.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.baseapi.deletionduringstatusupdate; +package io.javaoperatorsdk.operator.baseapi.readcacheafterwrite.deletionduringstatusupdate; import java.time.Duration; import java.util.Collections; 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/readcacheafterwrite/deletionduringstatusupdate/DeletionDuringStatusUpdateReconciler.java similarity index 96% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deletionduringstatusupdate/DeletionDuringStatusUpdateReconciler.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/deletionduringstatusupdate/DeletionDuringStatusUpdateReconciler.java index db05321ee7..feb0509e72 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deletionduringstatusupdate/DeletionDuringStatusUpdateReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/deletionduringstatusupdate/DeletionDuringStatusUpdateReconciler.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.baseapi.deletionduringstatusupdate; +package io.javaoperatorsdk.operator.baseapi.readcacheafterwrite.deletionduringstatusupdate; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; 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/readcacheafterwrite/deletionduringstatusupdate/DeletionDuringStatusUpdateStatus.java similarity index 89% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deletionduringstatusupdate/DeletionDuringStatusUpdateStatus.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/deletionduringstatusupdate/DeletionDuringStatusUpdateStatus.java index 52da516d00..c7acedce20 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/deletionduringstatusupdate/DeletionDuringStatusUpdateStatus.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/deletionduringstatusupdate/DeletionDuringStatusUpdateStatus.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.baseapi.deletionduringstatusupdate; +package io.javaoperatorsdk.operator.baseapi.readcacheafterwrite.deletionduringstatusupdate; public class DeletionDuringStatusUpdateStatus { diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cachingfilteringupdate/CachingFilteringUpdateCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalsecondaryupdate/ExternalSecondaryUpdateCustomResource.java similarity index 78% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cachingfilteringupdate/CachingFilteringUpdateCustomResource.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalsecondaryupdate/ExternalSecondaryUpdateCustomResource.java index 0d25bbfdd4..dd28ca9254 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cachingfilteringupdate/CachingFilteringUpdateCustomResource.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalsecondaryupdate/ExternalSecondaryUpdateCustomResource.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.baseapi.cachingfilteringupdate; +package io.javaoperatorsdk.operator.baseapi.readcacheafterwrite.externalsecondaryupdate; import io.fabric8.kubernetes.api.model.Namespaced; import io.fabric8.kubernetes.client.CustomResource; @@ -23,6 +23,6 @@ @Group("sample.javaoperatorsdk") @Version("v1") -@ShortNames("cfu") -public class CachingFilteringUpdateCustomResource - extends CustomResource implements Namespaced {} +@ShortNames("esu") +public class ExternalSecondaryUpdateCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalsecondaryupdate/ExternalSecondaryUpdateIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalsecondaryupdate/ExternalSecondaryUpdateIT.java new file mode 100644 index 0000000000..04b1565654 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalsecondaryupdate/ExternalSecondaryUpdateIT.java @@ -0,0 +1,110 @@ +/* + * 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.externalsecondaryupdate; + +import java.time.Duration; +import java.util.HashMap; +import java.util.concurrent.TimeUnit; + +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 io.javaoperatorsdk.operator.baseapi.readcacheafterwrite.externalsecondaryupdate.ExternalSecondaryUpdateReconciler.EXTERNAL_LABEL_KEY; +import static io.javaoperatorsdk.operator.baseapi.readcacheafterwrite.externalsecondaryupdate.ExternalSecondaryUpdateReconciler.EXTERNAL_LABEL_VALUE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * Verifies that when a secondary resource (a ConfigMap owned by the primary) is modified externally + * between two caching+filtering updates from the controller, the external change is NOT silently + * absorbed: a later reconciliation must observe it through the merged temp/informer cache. + */ +class ExternalSecondaryUpdateIT { + + static final String RESOURCE_NAME = "test-resource"; + + ExternalSecondaryUpdateReconciler reconciler = new ExternalSecondaryUpdateReconciler(); + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder().withReconciler(reconciler).build(); + + @Test + void externalUpdateOnSecondaryDuringFilteringUpdatePropagates() throws InterruptedException { + operator.create(testResource()); + + // wait for the reconciler to enter the first reconciliation and create the secondary CM + assertThat(reconciler.firstReconcileEntered.await(30, TimeUnit.SECONDS)) + .as("reconciler must enter first reconciliation") + .isTrue(); + + // a third party adds a label to the secondary CM while we are mid-reconcile + var cm = + operator + .getKubernetesClient() + .configMaps() + .inNamespace(operator.getNamespace()) + .withName(RESOURCE_NAME) + .get(); + assertThat(cm).as("secondary CM created by reconciler").isNotNull(); + var labels = new HashMap(); + if (cm.getMetadata().getLabels() != null) { + labels.putAll(cm.getMetadata().getLabels()); + } + labels.put(EXTERNAL_LABEL_KEY, EXTERNAL_LABEL_VALUE); + cm.getMetadata().setLabels(labels); + operator.getKubernetesClient().resource(cm).inNamespace(operator.getNamespace()).replace(); + + // signal the reconciler to issue the second caching+filtering SSA + reconciler.externalUpdateApplied.countDown(); + + // a later reconciliation, triggered by the external label event, must see the label + // through the cache (informer + temp cache merge). + await() + .atMost(Duration.ofSeconds(30)) + .untilAsserted( + () -> { + assertThat(reconciler.numberOfExecutions.get()) + .as("external CM update must trigger a fresh reconciliation") + .isGreaterThanOrEqualTo(2); + assertThat(reconciler.externalLabelSeenInLaterReconciliation.get()) + .as("a later reconciliation must observe the externally-applied label") + .isTrue(); + }); + + // the second SSA from the reconciler did go through and was captured + assertThat(reconciler.rvAfterCachingFilteringUpdate.get()).isNotNull(); + var finalCm = + operator + .getKubernetesClient() + .configMaps() + .inNamespace(operator.getNamespace()) + .withName(RESOURCE_NAME) + .get(); + assertThat(finalCm.getMetadata().getLabels()) + .as("external label preserved on the secondary after the SSA") + .containsEntry(EXTERNAL_LABEL_KEY, EXTERNAL_LABEL_VALUE); + } + + ExternalSecondaryUpdateCustomResource testResource() { + var r = new ExternalSecondaryUpdateCustomResource(); + r.setMetadata(new ObjectMetaBuilder().withName(RESOURCE_NAME).build()); + return r; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalsecondaryupdate/ExternalSecondaryUpdateReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalsecondaryupdate/ExternalSecondaryUpdateReconciler.java new file mode 100644 index 0000000000..5853a9b8c1 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalsecondaryupdate/ExternalSecondaryUpdateReconciler.java @@ -0,0 +1,120 @@ +/* + * 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.externalsecondaryupdate; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; + +@ControllerConfiguration +public class ExternalSecondaryUpdateReconciler + implements Reconciler { + + static final String CM_DATA_KEY = "managed-by"; + static final String CM_DATA_VALUE = "operator"; + static final String EXTERNAL_LABEL_KEY = "externally-set"; + static final String EXTERNAL_LABEL_VALUE = "yes"; + + final AtomicInteger numberOfExecutions = new AtomicInteger(); + final CountDownLatch firstReconcileEntered = new CountDownLatch(1); + final CountDownLatch externalUpdateApplied = new CountDownLatch(1); + // Whether a later reconciliation (after the external label appeared) actually saw the label + // through the informer/temp cache. + final AtomicBoolean externalLabelSeenInLaterReconciliation = new AtomicBoolean(); + final AtomicReference rvAfterCachingFilteringUpdate = new AtomicReference<>(); + + private InformerEventSource + configMapEventSource; + + @Override + public UpdateControl reconcile( + ExternalSecondaryUpdateCustomResource resource, + Context context) + throws InterruptedException { + int execution = numberOfExecutions.incrementAndGet(); + + if (execution == 1) { + // first reconciliation: create the secondary CM via SSA, then ask the test to apply + // an external metadata change BEFORE we issue our second SSA on it. + context.resourceOperations().serverSideApply(prepareCM(resource), configMapEventSource); + + firstReconcileEntered.countDown(); + if (!externalUpdateApplied.await(30, TimeUnit.SECONDS)) { + throw new RuntimeException("timed out waiting for external CM update"); + } + + // second SSA on the secondary — the temp cache must filter out the event for OUR + // resulting rv but NOT the rv from the external label change. We capture the rv our + // SSA observed. + var updated = + context.resourceOperations().serverSideApply(prepareCM(resource), configMapEventSource); + rvAfterCachingFilteringUpdate.set(updated.getMetadata().getResourceVersion()); + } else { + // any subsequent reconciliation must be able to see the external label through the + // informer cache (merged with the temp cache). + var cached = context.getSecondaryResource(ConfigMap.class).orElse(null); + if (cached != null + && cached.getMetadata().getLabels() != null + && EXTERNAL_LABEL_VALUE.equals( + cached.getMetadata().getLabels().get(EXTERNAL_LABEL_KEY))) { + externalLabelSeenInLaterReconciliation.set(true); + } + } + return UpdateControl.noUpdate(); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + configMapEventSource = + new InformerEventSource<>( + InformerEventSourceConfiguration.from( + ConfigMap.class, ExternalSecondaryUpdateCustomResource.class) + .build(), + context); + return List.of(configMapEventSource); + } + + private static ConfigMap prepareCM(ExternalSecondaryUpdateCustomResource p) { + var cm = + new ConfigMapBuilder() + .withMetadata( + new ObjectMetaBuilder() + .withName(p.getMetadata().getName()) + .withNamespace(p.getMetadata().getNamespace()) + .build()) + .withData(Map.of(CM_DATA_KEY, CM_DATA_VALUE)) + .build(); + cm.addOwnerReference(p); + return cm; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalsecondaryupdate/ExternalSecondaryUpdateStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalsecondaryupdate/ExternalSecondaryUpdateStatus.java new file mode 100644 index 0000000000..70c555f7aa --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalsecondaryupdate/ExternalSecondaryUpdateStatus.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.externalsecondaryupdate; + +public class ExternalSecondaryUpdateStatus { + + private Integer reconciliations; + + public Integer getReconciliations() { + return reconciliations; + } + + public ExternalSecondaryUpdateStatus setReconciliations(Integer reconciliations) { + this.reconciliations = reconciliations; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalupdateduringownupdate/ExternalUpdateDuringOwnUpdateCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalupdateduringownupdate/ExternalUpdateDuringOwnUpdateCustomResource.java new file mode 100644 index 0000000000..3621c72c8f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalupdateduringownupdate/ExternalUpdateDuringOwnUpdateCustomResource.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.readcacheafterwrite.externalupdateduringownupdate; + +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("eudou") +public class ExternalUpdateDuringOwnUpdateCustomResource + extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalupdateduringownupdate/ExternalUpdateDuringOwnUpdateIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalupdateduringownupdate/ExternalUpdateDuringOwnUpdateIT.java new file mode 100644 index 0000000000..ed3330358e --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalupdateduringownupdate/ExternalUpdateDuringOwnUpdateIT.java @@ -0,0 +1,88 @@ +/* + * 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.externalupdateduringownupdate; + +import java.time.Duration; +import java.util.HashMap; +import java.util.concurrent.TimeUnit; + +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 io.javaoperatorsdk.operator.baseapi.readcacheafterwrite.externalupdateduringownupdate.ExternalUpdateDuringOwnUpdateReconciler.EXTERNAL_LABEL_KEY; +import static io.javaoperatorsdk.operator.baseapi.readcacheafterwrite.externalupdateduringownupdate.ExternalUpdateDuringOwnUpdateReconciler.EXTERNAL_LABEL_VALUE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * Verifies that an external update arriving while the controller's own filter window is open is NOT + * mistakenly filtered. The third-party event must propagate as a fresh reconciliation in which the + * reconciler observes the externally-applied change. + */ +class ExternalUpdateDuringOwnUpdateIT { + + static final String RESOURCE_NAME = "test-resource"; + + ExternalUpdateDuringOwnUpdateReconciler reconciler = + new ExternalUpdateDuringOwnUpdateReconciler(); + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder().withReconciler(reconciler).build(); + + @Test + void externalUpdateDuringOwnUpdateTriggersFreshReconciliation() throws InterruptedException { + extension.create(testResource()); + + assertThat(reconciler.updateStartedLatch.await(30, TimeUnit.SECONDS)) + .as("reconciler should enter the patch update operation") + .isTrue(); + + // external party modifies a label while our filter window is still open + var current = extension.get(ExternalUpdateDuringOwnUpdateCustomResource.class, RESOURCE_NAME); + var labels = new HashMap(); + if (current.getMetadata().getLabels() != null) { + labels.putAll(current.getMetadata().getLabels()); + } + labels.put(EXTERNAL_LABEL_KEY, EXTERNAL_LABEL_VALUE); + current.getMetadata().setLabels(labels); + extension.replace(current); + + // signal reconciler to complete its own status update + reconciler.externalUpdateDoneLatch.countDown(); + + // the external update event must NOT be silently absorbed by the filter window; + // a fresh reconciliation must observe the external label. + await() + .atMost(Duration.ofSeconds(30)) + .untilAsserted( + () -> { + assertThat(reconciler.numberOfExecutions.get()).isGreaterThanOrEqualTo(2); + assertThat(reconciler.externalLabelSeenInLaterReconciliation.get()) + .as("a later reconciliation must observe the externally-applied label") + .isTrue(); + }); + } + + ExternalUpdateDuringOwnUpdateCustomResource testResource() { + var r = new ExternalUpdateDuringOwnUpdateCustomResource(); + r.setMetadata(new ObjectMetaBuilder().withName(RESOURCE_NAME).build()); + return r; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalupdateduringownupdate/ExternalUpdateDuringOwnUpdateReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalupdateduringownupdate/ExternalUpdateDuringOwnUpdateReconciler.java new file mode 100644 index 0000000000..e5c956dc4e --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalupdateduringownupdate/ExternalUpdateDuringOwnUpdateReconciler.java @@ -0,0 +1,80 @@ +/* + * 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.externalupdateduringownupdate; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +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; + +@ControllerConfiguration(generationAwareEventProcessing = false) +public class ExternalUpdateDuringOwnUpdateReconciler + implements Reconciler { + + static final String EXTERNAL_LABEL_KEY = "externally-set"; + static final String EXTERNAL_LABEL_VALUE = "yes"; + static final String STATUS_VALUE = "ready"; + + final AtomicInteger numberOfExecutions = new AtomicInteger(); + final CountDownLatch updateStartedLatch = new CountDownLatch(1); + final CountDownLatch externalUpdateDoneLatch = new CountDownLatch(1); + final AtomicBoolean externalLabelSeenInLaterReconciliation = new AtomicBoolean(); + + @Override + public UpdateControl reconcile( + ExternalUpdateDuringOwnUpdateCustomResource resource, + Context context) { + int execution = numberOfExecutions.incrementAndGet(); + + if (execution == 1) { + var status = new ExternalUpdateDuringOwnUpdateStatus().setValue(STATUS_VALUE); + resource.setStatus(status); + + // wrap our own status update in resourcePatch with a hook that lets the test + // perform an external metadata update WHILE our filter window is still open. + context + .resourceOperations() + .resourcePatch( + resource, + r -> { + updateStartedLatch.countDown(); + try { + if (!externalUpdateDoneLatch.await(30, TimeUnit.SECONDS)) { + throw new RuntimeException("timed out waiting for external update"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + // server-side state moved due to the external label change; drop our stale rv + r.getMetadata().setResourceVersion(null); + return context.getClient().resource(r).patchStatus(); + }, + context.eventSourceRetriever().getControllerEventSource()); + } else { + var labels = resource.getMetadata().getLabels(); + if (labels != null && EXTERNAL_LABEL_VALUE.equals(labels.get(EXTERNAL_LABEL_KEY))) { + externalLabelSeenInLaterReconciliation.set(true); + } + } + return UpdateControl.noUpdate(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cachingfilteringupdate/CachingFilteringUpdateStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalupdateduringownupdate/ExternalUpdateDuringOwnUpdateStatus.java similarity index 65% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cachingfilteringupdate/CachingFilteringUpdateStatus.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalupdateduringownupdate/ExternalUpdateDuringOwnUpdateStatus.java index 80b6c4ba54..b059a6ee5e 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/cachingfilteringupdate/CachingFilteringUpdateStatus.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/externalupdateduringownupdate/ExternalUpdateDuringOwnUpdateStatus.java @@ -13,17 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.baseapi.cachingfilteringupdate; +package io.javaoperatorsdk.operator.baseapi.readcacheafterwrite.externalupdateduringownupdate; -public class CachingFilteringUpdateStatus { +public class ExternalUpdateDuringOwnUpdateStatus { - private Boolean updated; + private String value; - public Boolean getUpdated() { - return updated; + public String getValue() { + return value; } - public void setUpdated(Boolean updated) { - this.updated = updated; + public ExternalUpdateDuringOwnUpdateStatus setValue(String value) { + this.value = value; + return this; } } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/filterpatchevent/FilterPatchEventIT.java similarity index 98% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventIT.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/filterpatchevent/FilterPatchEventIT.java index 6f27925e21..398cdcf864 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/filterpatchevent/FilterPatchEventIT.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.baseapi.filterpatchevent; +package io.javaoperatorsdk.operator.baseapi.readcacheafterwrite.filterpatchevent; import java.time.Duration; diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/filterpatchevent/FilterPatchEventTestCustomResource.java similarity index 93% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestCustomResource.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/filterpatchevent/FilterPatchEventTestCustomResource.java index 7f8b4838de..f228c0caf4 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestCustomResource.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/filterpatchevent/FilterPatchEventTestCustomResource.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.baseapi.filterpatchevent; +package io.javaoperatorsdk.operator.baseapi.readcacheafterwrite.filterpatchevent; import io.fabric8.kubernetes.api.model.Namespaced; import io.fabric8.kubernetes.client.CustomResource; diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestCustomResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/filterpatchevent/FilterPatchEventTestCustomResourceStatus.java similarity index 91% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestCustomResourceStatus.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/filterpatchevent/FilterPatchEventTestCustomResourceStatus.java index 1c7aeafadd..b1828f0241 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestCustomResourceStatus.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/filterpatchevent/FilterPatchEventTestCustomResourceStatus.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.baseapi.filterpatchevent; +package io.javaoperatorsdk.operator.baseapi.readcacheafterwrite.filterpatchevent; public class FilterPatchEventTestCustomResourceStatus { diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/filterpatchevent/FilterPatchEventTestReconciler.java similarity index 91% rename from operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestReconciler.java rename to operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/filterpatchevent/FilterPatchEventTestReconciler.java index e7599a2881..1f19015dcd 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filterpatchevent/FilterPatchEventTestReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/filterpatchevent/FilterPatchEventTestReconciler.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.javaoperatorsdk.operator.baseapi.filterpatchevent; +package io.javaoperatorsdk.operator.baseapi.readcacheafterwrite.filterpatchevent; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -23,7 +23,7 @@ import io.javaoperatorsdk.operator.api.reconciler.Reconciler; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; -import static io.javaoperatorsdk.operator.baseapi.filterpatchevent.FilterPatchEventIT.UPDATED; +import static io.javaoperatorsdk.operator.baseapi.readcacheafterwrite.filterpatchevent.FilterPatchEventIT.UPDATED; @ControllerConfiguration(generationAwareEventProcessing = false) public class FilterPatchEventTestReconciler