From 4296fb24cad5dc308a82fd0005a64c05398fccfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 11 Sep 2026 15:41:51 +0200 Subject: [PATCH 1/5] test: deflake additionalEventDuringRetryOnDeleteEvent The test released the blocked reconciler right after the update call returned, but the update event still had to travel back through the informer. When the reconciliation failed before the event was registered, the framework treated the failure as a plain retry (consuming the last attempt) instead of instantly re-triggering because of a superseding event, so only 4 instead of 5 reconciliations happened. Wait for isNextReconciliationImminent() in the reconciler, which reflects exactly the state the framework checks after the reconciliation fails and cannot be unset while it is in progress. The reconciliation triggered by the superseding event reuses the same retry execution, so its attempt count is still 1 and it would enter the wait too, this time never being released; guard the wait with a one-shot flag. Also fix the isWaiting() assertion that had no terminal assertion and was therefore a no-op. --- .../TriggerReconcilerOnAllEventIT.java | 6 ++++-- ...TriggerReconcilerOnAllEventReconciler.java | 21 ++++++++++++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventIT.java index 0f193d9440..a8b022b642 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventIT.java @@ -192,14 +192,16 @@ void additionalEventDuringRetryOnDeleteEvent() { await() .untilAsserted( () -> { - assertThat(reconciler.isWaiting()); + assertThat(reconciler.isWaiting()).isTrue(); }); // trigger reconciliation while waiting in reconciler res = getResource(); res.getMetadata().getAnnotations().put("my-annotation", "true"); extension.update(res); - // continue reconciliation + // continue reconciliation; the reconciler additionally waits until the framework actually + // registered the event above, otherwise the failure below would consume a retry attempt + // instead of being instantly re-triggered by the superseding event reconciler.setContinuerOnRetryWait(true); await() diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventReconciler.java index f8804bd25d..e905646abf 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventReconciler.java @@ -35,6 +35,10 @@ public class TriggerReconcilerOnAllEventReconciler public static final String ADDITIONAL_FINALIZER = "all.event.mode/finalizer2"; public static final String NO_MORE_EXCEPTION_ANNOTATION_KEY = "no.more.exception"; + // safety net so a missing event does not block the reconciler thread forever, the test assertions + // fail long before this elapses + private static final long MAX_WAIT_FOR_SUPERSEDING_EVENT_MILLIS = 30_000; + private static final Logger log = LoggerFactory.getLogger(TriggerReconcilerOnAllEventReconciler.class); @@ -47,6 +51,7 @@ public class TriggerReconcilerOnAllEventReconciler private volatile boolean waitAfterFirstRetry = false; private volatile boolean continuerOnRetryWait = false; private volatile boolean waiting = false; + private volatile boolean alreadyWaitedAfterFirstRetry = false; // control flag to throw an exception on first delete event private volatile boolean isFirstDeleteEvent = true; @@ -79,10 +84,24 @@ public UpdateControl reconcile( } if (waitAfterFirstRetry + && !alreadyWaitedAfterFirstRetry && context.getRetryInfo().isPresent() && context.getRetryInfo().orElseThrow().getAttemptCount() == 1) { + // The reconciliation triggered by the superseding event below reuses the same retry + // execution, so its attempt count is still 1. Wait only on the very first one, otherwise that + // follow-up reconciliation would block here too and never be released. + alreadyWaitedAfterFirstRetry = true; waiting = true; - while (!continuerOnRetryWait) { + // Releasing on continuerOnRetryWait alone is racy: the test sets that flag right after the + // update call returns, but the update event still has to travel back through the informer. + // If this reconciliation failed before the event was registered, the framework would treat + // the failure as a plain retry (consuming the last attempt) instead of instantly + // re-triggering because of a superseding event. isNextReconciliationImminent() reflects + // exactly the state (event marked as received) the framework checks after this + // reconciliation fails, and it cannot be unset while this reconciliation is in progress. + var waitUntil = System.currentTimeMillis() + MAX_WAIT_FOR_SUPERSEDING_EVENT_MILLIS; + while ((!continuerOnRetryWait || !context.isNextReconciliationImminent()) + && System.currentTimeMillis() < waitUntil) { Thread.sleep(50); } waiting = false; From 76be6c03b9cd295103f109f889d615fe747aa52d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 14 Sep 2026 12:15:09 +0200 Subject: [PATCH 2/5] fix: report an event after a delete event as imminent reconciliation Context.isNextReconciliationImminent() only checked ResourceState.eventPresent(), which is false for ADDITIONAL_EVENT_PRESENT_AFTER_DELETE_EVENT. In triggerReconcilerOnAllEvents mode that state does trigger a new reconciliation right after the current one, both on the success path (eventProcessingFinished) and on the failure path (handleRetryOnException), so the method contradicted its own contract and a reconciler could skip a status update that nothing else would produce. Extract the condition handleRetryOnException already computed into a shared helper and use it for isNextReconciliationImminent() too, so the two cannot drift apart again. The condition is the one that holds regardless of whether the current reconciliation succeeds or throws; documented on Context. --- .../operator/api/reconciler/Context.java | 4 ++++ .../processing/event/EventProcessor.java | 18 ++++++++++++++---- .../TriggerReconcilerOnAllEventReconciler.java | 6 +++--- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java index 9743632404..5a87659246 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/Context.java @@ -253,6 +253,10 @@ default ResourceEventRecorder eventRecorder() { * reconciliation is already scheduled, which would in turn trigger another status update, thus * rendering the current one moot. * + *

This holds regardless of whether the current reconciliation succeeds or throws, so with + * {@link ControllerConfiguration#triggerReconcilerOnAllEvents()} it also covers an event that + * arrived after a delete event. + * * @return {@code true} is another reconciliation is already scheduled, {@code false} otherwise */ boolean isNextReconciliationImminent(); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java index 8931e49486..316b065c25 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java @@ -380,9 +380,7 @@ private void handleRetryOnException( ExecutionScope

executionScope, Exception exception, boolean errorHandledByReconciler) { final var state = getOrInitRetryExecution(executionScope); var resourceID = state.getId(); - boolean eventPresent = - state.eventPresent() - || (triggerOnAllEvents() && state.isAdditionalEventPresentAfterDeleteEvent()); + boolean eventPresent = nextReconciliationImminent(state); state.markEventReceived(); retryAwareErrorLogging( state.getRetry(), eventPresent, errorHandledByReconciler, exception, executionScope); @@ -511,7 +509,19 @@ public synchronized void start() throws OperatorException { } public boolean isNextReconciliationImminent(ResourceID resourceID) { - return resourceStateManager.getOrCreate(resourceID).eventPresent(); + return nextReconciliationImminent(resourceStateManager.getOrCreate(resourceID)); + } + + /** + * An event that arrives after a delete event is tracked in a dedicated state, so {@link + * ResourceState#eventPresent()} alone does not cover it. Such an event triggers a new + * reconciliation right after the current one, both when it succeeds (see {@link + * #eventProcessingFinished}) and when it fails (see {@link #handleRetryOnException}), so it has + * to be reported as imminent too. + */ + private boolean nextReconciliationImminent(ResourceState state) { + return state.eventPresent() + || (triggerOnAllEvents() && state.isAdditionalEventPresentAfterDeleteEvent()); } private void handleAlreadyMarkedEvents() { diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventReconciler.java index e905646abf..8849993f52 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/triggerallevent/eventing/TriggerReconcilerOnAllEventReconciler.java @@ -96,9 +96,9 @@ public UpdateControl reconcile( // update call returns, but the update event still has to travel back through the informer. // If this reconciliation failed before the event was registered, the framework would treat // the failure as a plain retry (consuming the last attempt) instead of instantly - // re-triggering because of a superseding event. isNextReconciliationImminent() reflects - // exactly the state (event marked as received) the framework checks after this - // reconciliation fails, and it cannot be unset while this reconciliation is in progress. + // re-triggering because of a superseding event. isNextReconciliationImminent() reports the + // same condition the framework evaluates after this reconciliation fails, and it cannot be + // unset while this reconciliation is in progress. var waitUntil = System.currentTimeMillis() + MAX_WAIT_FOR_SUPERSEDING_EVENT_MILLIS; while ((!continuerOnRetryWait || !context.isNextReconciliationImminent()) && System.currentTimeMillis() < waitUntil) { From f77b26e659a2dabcec0d181de32be95bcda9955c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 14 Sep 2026 13:42:43 +0200 Subject: [PATCH 3/5] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/processing/event/EventProcessor.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java index 316b065c25..f8f5744240 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java @@ -380,7 +380,7 @@ private void handleRetryOnException( ExecutionScope

executionScope, Exception exception, boolean errorHandledByReconciler) { final var state = getOrInitRetryExecution(executionScope); var resourceID = state.getId(); - boolean eventPresent = nextReconciliationImminent(state); + boolean eventPresent = isNextReconciliationImminent(state); state.markEventReceived(); retryAwareErrorLogging( state.getRetry(), eventPresent, errorHandledByReconciler, exception, executionScope); @@ -509,7 +509,7 @@ public synchronized void start() throws OperatorException { } public boolean isNextReconciliationImminent(ResourceID resourceID) { - return nextReconciliationImminent(resourceStateManager.getOrCreate(resourceID)); + return isNextReconciliationImminent(resourceStateManager.getOrCreate(resourceID)); } /** @@ -519,7 +519,7 @@ public boolean isNextReconciliationImminent(ResourceID resourceID) { * #eventProcessingFinished}) and when it fails (see {@link #handleRetryOnException}), so it has * to be reported as imminent too. */ - private boolean nextReconciliationImminent(ResourceState state) { + private boolean isNextReconciliationImminent(ResourceState state) { return state.eventPresent() || (triggerOnAllEvents() && state.isAdditionalEventPresentAfterDeleteEvent()); } From f6bc85b9c8a2f12c45ffc0db27fdc99bee114d07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 14 Sep 2026 14:27:43 +0200 Subject: [PATCH 4/5] Make isNextReconciliationImminent synchronized Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../operator/processing/event/EventProcessor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java index f8f5744240..ef3bc12548 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java @@ -508,7 +508,7 @@ public synchronized void start() throws OperatorException { handleAlreadyMarkedEvents(); } - public boolean isNextReconciliationImminent(ResourceID resourceID) { + public synchronized boolean isNextReconciliationImminent(ResourceID resourceID) return isNextReconciliationImminent(resourceStateManager.getOrCreate(resourceID)); } From 13b99dd65beaeffda3423a01b3bbda805f3977dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 14 Sep 2026 14:30:41 +0200 Subject: [PATCH 5/5] wip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/processing/event/EventProcessor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java index ef3bc12548..1b048123a0 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/EventProcessor.java @@ -508,7 +508,7 @@ public synchronized void start() throws OperatorException { handleAlreadyMarkedEvents(); } - public synchronized boolean isNextReconciliationImminent(ResourceID resourceID) + public synchronized boolean isNextReconciliationImminent(ResourceID resourceID) { return isNextReconciliationImminent(resourceStateManager.getOrCreate(resourceID)); }