From 2a29bf61f5294b00c1c7d84598746aef281822ca Mon Sep 17 00:00:00 2001 From: Sam Barker Date: Mon, 29 Jun 2026 17:10:01 +1200 Subject: [PATCH 1/5] test: reproducer for EventFilterWindow suppressing mapper events on shared InformerEventSource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a controller patches a secondary resource's status through its own InformerEventSource (via eventFilteringUpdateAndCacheResource), the EventFilterWindow suppresses the resulting informer event. If the controller's mapper guards on generation == observedGeneration, it never sees the status become ready and the controller enters a dead state. The test creates a primary and secondary resource, waits for the reconciler to patch the secondary's observedGeneration through the shared event source, then asserts that the mapper fires and triggers re-reconciliation. Currently fails — the filter window swallows the event. Assisted-by: Claude claude-opus-4-6 Signed-off-by: Sam Barker --- .../StatusEventFilteringIT.java | 98 ++++++++++++++ .../StatusEventPrimaryReconciler.java | 127 ++++++++++++++++++ .../StatusEventPrimaryResource.java | 28 ++++ .../StatusEventSecondaryReconciler.java | 31 +++++ .../StatusEventSecondaryResource.java | 40 ++++++ .../StatusEventSecondaryResourceSpec.java | 28 ++++ .../StatusEventSecondaryResourceStatus.java | 28 ++++ 7 files changed, 380 insertions(+) create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventFilteringIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventPrimaryReconciler.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventPrimaryResource.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventSecondaryReconciler.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventSecondaryResource.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventSecondaryResourceSpec.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventSecondaryResourceStatus.java diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventFilteringIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventFilteringIT.java new file mode 100644 index 0000000000..c9c8092b91 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventFilteringIT.java @@ -0,0 +1,98 @@ +/* + * 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.statuseventfiltering; + +import java.time.Duration; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.javaoperatorsdk.annotation.Sample; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static io.javaoperatorsdk.operator.baseapi.statuseventfiltering.StatusEventPrimaryReconciler.PRIMARY_NAME_LABEL; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@Sample( + tldr = "Shared InformerEventSource status event filtering reproducer", + description = + """ + Reproduces an issue where a controller patches a secondary resource's status \ + through its own InformerEventSource. The EventFilterWindow suppresses the resulting \ + event, so the mapper (which guards on generation == observedGeneration) never fires \ + and the controller is never re-triggered by the secondary becoming ready.\ + """) +class StatusEventFilteringIT { + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(StatusEventSecondaryReconciler.class) + .withReconciler(StatusEventPrimaryReconciler.class) + .build(); + + @Test + void mapperShouldFireAfterStatusPatchThroughSharedEventSource() { + var primaryReconciler = extension.getReconcilerOfType(StatusEventPrimaryReconciler.class); + + // Given: a secondary resource exists with generation=1 and no observedGeneration + var secondary = new StatusEventSecondaryResource(); + secondary.setMetadata( + new ObjectMetaBuilder() + .withName("test-secondary") + .withNamespace(extension.getNamespace()) + .withLabels(Map.of(PRIMARY_NAME_LABEL, "test-primary")) + .build()); + secondary.setSpec(new StatusEventSecondaryResourceSpec()); + secondary.getSpec().setValue("initial"); + extension.create(secondary); + + // When: the primary resource is created, triggering reconciliation which patches + // the secondary's observedGeneration=1 through the shared InformerEventSource + var primary = new StatusEventPrimaryResource(); + primary.setMetadata( + new ObjectMetaBuilder() + .withName("test-primary") + .withNamespace(extension.getNamespace()) + .build()); + extension.create(primary); + + await() + .atMost(Duration.ofSeconds(30)) + .untilAsserted( + () -> { + var sec = extension.get(StatusEventSecondaryResource.class, "test-secondary"); + assertThat(sec.getStatus().getObservedGeneration()) + .as("reconciler should patch observedGeneration to match generation") + .isEqualTo(sec.getMetadata().getGeneration()); + }); + + // Then: the mapper should see gen==obsGen and trigger a second reconciliation. + // With the bug, the EventFilterWindow suppresses the status-change event + // so the mapper never fires — the controller is stuck at 1 execution. + await() + .atMost(Duration.ofSeconds(15)) + .pollInterval(Duration.ofMillis(500)) + .untilAsserted( + () -> + assertThat(primaryReconciler.getNumberOfExecutions()) + .as("mapper should fire for gen==obsGen and trigger re-reconciliation") + .isGreaterThanOrEqualTo(2)); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventPrimaryReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventPrimaryReconciler.java new file mode 100644 index 0000000000..0a0495b965 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventPrimaryReconciler.java @@ -0,0 +1,127 @@ +/* + * 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.statuseventfiltering; + +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +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 StatusEventPrimaryReconciler implements Reconciler { + + private static final Logger log = LoggerFactory.getLogger(StatusEventPrimaryReconciler.class); + + public static final String PRIMARY_NAME_LABEL = "primary-name"; + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + private InformerEventSource + secondaryEventSource; + + @Override + public UpdateControl reconcile( + StatusEventPrimaryResource resource, Context context) { + + int count = numberOfExecutions.incrementAndGet(); + log.info( + "PrimaryReconciler reconciled '{}', execution #{}", + resource.getMetadata().getName(), + count); + + var secondaries = + context + .getClient() + .resources(StatusEventSecondaryResource.class) + .inNamespace(resource.getMetadata().getNamespace()) + .withLabel(PRIMARY_NAME_LABEL, resource.getMetadata().getName()) + .list() + .getItems(); + + for (var secondary : secondaries) { + var generation = secondary.getMetadata().getGeneration(); + var observedGeneration = secondary.getStatus().getObservedGeneration(); + + if (!Objects.equals(generation, observedGeneration)) { + log.info( + "Patching secondary '{}' status: obsGen {} -> {}", + secondary.getMetadata().getName(), + observedGeneration, + generation); + + secondaryEventSource.eventFilteringUpdateAndCacheResource( + secondary, + s -> { + s.getStatus().setObservedGeneration(s.getMetadata().getGeneration()); + return context.getClient().resource(s).patchStatus(); + }); + } + } + + return UpdateControl.noUpdate(); + } + + @Override + public List> prepareEventSources( + EventSourceContext context) { + + var config = + InformerEventSourceConfiguration.from( + StatusEventSecondaryResource.class, StatusEventPrimaryResource.class) + .withSecondaryToPrimaryMapper( + secondary -> { + var generation = secondary.getMetadata().getGeneration(); + var observedGeneration = secondary.getStatus().getObservedGeneration(); + + if (!Objects.equals(generation, observedGeneration)) { + log.info( + "Mapper: secondary '{}' gen {} != obsGen {} -> skipping", + secondary.getMetadata().getName(), + generation, + observedGeneration); + return Set.of(); + } + + var primaryName = secondary.getMetadata().getLabels().get(PRIMARY_NAME_LABEL); + log.info( + "Mapper: secondary '{}' gen matches -> mapping to primary '{}'", + secondary.getMetadata().getName(), + primaryName); + return Set.of( + new ResourceID(primaryName, secondary.getMetadata().getNamespace())); + }) + .build(); + + secondaryEventSource = new InformerEventSource<>(config, context); + return List.of(secondaryEventSource); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventPrimaryResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventPrimaryResource.java new file mode 100644 index 0000000000..b253f8c279 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventPrimaryResource.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.statuseventfiltering; + +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("sepr") +public class StatusEventPrimaryResource extends CustomResource + implements Namespaced {} \ No newline at end of file diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventSecondaryReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventSecondaryReconciler.java new file mode 100644 index 0000000000..f6a6811c0f --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventSecondaryReconciler.java @@ -0,0 +1,31 @@ +/* + * 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.statuseventfiltering; + +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 +public class StatusEventSecondaryReconciler implements Reconciler { + + @Override + public UpdateControl reconcile( + StatusEventSecondaryResource resource, Context context) { + return UpdateControl.noUpdate(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventSecondaryResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventSecondaryResource.java new file mode 100644 index 0000000000..874825df62 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventSecondaryResource.java @@ -0,0 +1,40 @@ +/* + * 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.statuseventfiltering; + +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("sesr") +public class StatusEventSecondaryResource + extends CustomResource + implements Namespaced { + + @Override + protected StatusEventSecondaryResourceSpec initSpec() { + return new StatusEventSecondaryResourceSpec(); + } + + @Override + protected StatusEventSecondaryResourceStatus initStatus() { + return new StatusEventSecondaryResourceStatus(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventSecondaryResourceSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventSecondaryResourceSpec.java new file mode 100644 index 0000000000..eb6bc0e734 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventSecondaryResourceSpec.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.statuseventfiltering; + +public class StatusEventSecondaryResourceSpec { + private String value; + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventSecondaryResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventSecondaryResourceStatus.java new file mode 100644 index 0000000000..070f4742c2 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventSecondaryResourceStatus.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.statuseventfiltering; + +public class StatusEventSecondaryResourceStatus { + private Long observedGeneration; + + public Long getObservedGeneration() { + return observedGeneration; + } + + public void setObservedGeneration(Long observedGeneration) { + this.observedGeneration = observedGeneration; + } +} From e3d085ecce88923cd8333b63e60a0fb6c78c55e9 Mon Sep 17 00:00:00 2001 From: Sam Barker Date: Mon, 29 Jun 2026 17:33:36 +1200 Subject: [PATCH 2/5] Disable reproducer test pending fix The issue link in @Disabled will be updated once the issue is created. Assisted-by: Claude claude-opus-4-6 Signed-off-by: Sam Barker --- .../baseapi/statuseventfiltering/StatusEventFilteringIT.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventFilteringIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventFilteringIT.java index c9c8092b91..0610aba69d 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventFilteringIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventFilteringIT.java @@ -18,6 +18,7 @@ import java.time.Duration; import java.util.Map; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -48,6 +49,7 @@ class StatusEventFilteringIT { .build(); @Test + @Disabled("https://github.com/operator-framework/java-operator-sdk/issues/XXXX") void mapperShouldFireAfterStatusPatchThroughSharedEventSource() { var primaryReconciler = extension.getReconcilerOfType(StatusEventPrimaryReconciler.class); From 71cff377cb3ba23ffc310a9a64fa9510b9b87173 Mon Sep 17 00:00:00 2001 From: Sam Barker Date: Mon, 29 Jun 2026 17:36:15 +1200 Subject: [PATCH 3/5] Fix formatting Signed-off-by: Sam Barker --- .../StatusEventFilteringIT.java | 13 +++++++------ .../StatusEventPrimaryResource.java | 3 +-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventFilteringIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventFilteringIT.java index 0610aba69d..589bbab21c 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventFilteringIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventFilteringIT.java @@ -49,7 +49,7 @@ class StatusEventFilteringIT { .build(); @Test - @Disabled("https://github.com/operator-framework/java-operator-sdk/issues/XXXX") + @Disabled("https://github.com/operator-framework/java-operator-sdk/issues/3445") void mapperShouldFireAfterStatusPatchThroughSharedEventSource() { var primaryReconciler = extension.getReconcilerOfType(StatusEventPrimaryReconciler.class); @@ -65,16 +65,20 @@ void mapperShouldFireAfterStatusPatchThroughSharedEventSource() { secondary.getSpec().setValue("initial"); extension.create(secondary); - // When: the primary resource is created, triggering reconciliation which patches - // the secondary's observedGeneration=1 through the shared InformerEventSource var primary = new StatusEventPrimaryResource(); primary.setMetadata( new ObjectMetaBuilder() .withName("test-primary") .withNamespace(extension.getNamespace()) .build()); + + // When: the primary resource is created, triggering reconciliation which patches + // the secondary's observedGeneration=1 through the shared InformerEventSource extension.create(primary); + // Then: the mapper should see gen==obsGen and trigger a second reconciliation. + // With the bug, the EventFilterWindow suppresses the status-change event + // so the mapper never fires — the controller is stuck at 1 execution. await() .atMost(Duration.ofSeconds(30)) .untilAsserted( @@ -85,9 +89,6 @@ void mapperShouldFireAfterStatusPatchThroughSharedEventSource() { .isEqualTo(sec.getMetadata().getGeneration()); }); - // Then: the mapper should see gen==obsGen and trigger a second reconciliation. - // With the bug, the EventFilterWindow suppresses the status-change event - // so the mapper never fires — the controller is stuck at 1 execution. await() .atMost(Duration.ofSeconds(15)) .pollInterval(Duration.ofMillis(500)) diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventPrimaryResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventPrimaryResource.java index b253f8c279..7924a614e6 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventPrimaryResource.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventPrimaryResource.java @@ -24,5 +24,4 @@ @Group("sample.javaoperatorsdk") @Version("v1") @ShortNames("sepr") -public class StatusEventPrimaryResource extends CustomResource - implements Namespaced {} \ No newline at end of file +public class StatusEventPrimaryResource extends CustomResource implements Namespaced {} From 2d2d622498b72be42bb283c15aaf695232725e27 Mon Sep 17 00:00:00 2001 From: Sam Barker Date: Mon, 29 Jun 2026 17:52:14 +1200 Subject: [PATCH 4/5] Apply co-pilot feedback Signed-off-by: Sam Barker --- .../StatusEventFilteringIT.java | 2 +- .../StatusEventSecondaryReconciler.java | 31 ------------------- 2 files changed, 1 insertion(+), 32 deletions(-) delete mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventSecondaryReconciler.java diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventFilteringIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventFilteringIT.java index 589bbab21c..b2f183525b 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventFilteringIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventFilteringIT.java @@ -44,7 +44,7 @@ class StatusEventFilteringIT { @RegisterExtension LocallyRunOperatorExtension extension = LocallyRunOperatorExtension.builder() - .withReconciler(StatusEventSecondaryReconciler.class) + .withAdditionalCustomResourceDefinition(StatusEventSecondaryResource.class) .withReconciler(StatusEventPrimaryReconciler.class) .build(); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventSecondaryReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventSecondaryReconciler.java deleted file mode 100644 index f6a6811c0f..0000000000 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventSecondaryReconciler.java +++ /dev/null @@ -1,31 +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.statuseventfiltering; - -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 -public class StatusEventSecondaryReconciler implements Reconciler { - - @Override - public UpdateControl reconcile( - StatusEventSecondaryResource resource, Context context) { - return UpdateControl.noUpdate(); - } -} From b4f7a159d8c7bed0b29ca995ca42e98da4e7eda5 Mon Sep 17 00:00:00 2001 From: Sam Barker Date: Tue, 30 Jun 2026 13:20:02 +1200 Subject: [PATCH 5/5] Restructure reproducer as two independent controllers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single-controller model (primary patches secondary status through shared InformerEventSource) with two independent controllers matching the Kroxylicious pattern: a SecondaryReconciler that patches its own status via UpdateControl.patchStatus(), and a PrimaryReconciler that watches secondaries via InformerEventSource and tracks readiness. The test uses a stall mechanism to create a timing window where the secondary controller's status patch races with the primary's own reconciliation. Currently passes — the event is delivered correctly. Assisted-by: Claude claude-opus-4-6 Signed-off-by: Sam Barker --- .../StatusEventFilteringIT.java | 84 ++++++++++++------- .../StatusEventPrimaryReconciler.java | 76 +++++++++-------- .../StatusEventPrimaryResource.java | 9 +- .../StatusEventPrimaryResourceStatus.java | 28 +++++++ .../StatusEventSecondaryReconciler.java | 52 ++++++++++++ .../StatusEventSecondaryResourceStatus.java | 9 ++ 6 files changed, 189 insertions(+), 69 deletions(-) create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventPrimaryResourceStatus.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventSecondaryReconciler.java diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventFilteringIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventFilteringIT.java index b2f183525b..75682b1e03 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventFilteringIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventFilteringIT.java @@ -17,8 +17,8 @@ import java.time.Duration; import java.util.Map; +import java.util.Objects; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -31,29 +31,36 @@ import static org.awaitility.Awaitility.await; @Sample( - tldr = "Shared InformerEventSource status event filtering reproducer", + tldr = "Cross-controller status event filtering reproducer", description = """ - Reproduces an issue where a controller patches a secondary resource's status \ - through its own InformerEventSource. The EventFilterWindow suppresses the resulting \ - event, so the mapper (which guards on generation == observedGeneration) never fires \ - and the controller is never re-triggered by the secondary becoming ready.\ + Reproduces an issue where two independent controllers run in the same operator. \ + A secondary controller reconciles its primary resource and patches its status \ + (observedGeneration = generation) while the primary controller patches its own \ + status. The primary controller watches secondaries via InformerEventSource. \ + The concurrent status patches may cause the secondary's status-change event to \ + be lost — leaving the primary controller unaware the secondary is ready.\ """) class StatusEventFilteringIT { @RegisterExtension - LocallyRunOperatorExtension extension = + static LocallyRunOperatorExtension extension = LocallyRunOperatorExtension.builder() - .withAdditionalCustomResourceDefinition(StatusEventSecondaryResource.class) + .withReconciler(StatusEventSecondaryReconciler.class) .withReconciler(StatusEventPrimaryReconciler.class) .build(); @Test - @Disabled("https://github.com/operator-framework/java-operator-sdk/issues/3445") - void mapperShouldFireAfterStatusPatchThroughSharedEventSource() { - var primaryReconciler = extension.getReconcilerOfType(StatusEventPrimaryReconciler.class); + void mapperShouldFireAfterIndependentControllerPatchesStatus() { + // Given: primary exists, secondary created and reaches steady state + var primary = new StatusEventPrimaryResource(); + primary.setMetadata( + new ObjectMetaBuilder() + .withName("test-primary") + .withNamespace(extension.getNamespace()) + .build()); + extension.create(primary); - // Given: a secondary resource exists with generation=1 and no observedGeneration var secondary = new StatusEventSecondaryResource(); secondary.setMetadata( new ObjectMetaBuilder() @@ -65,37 +72,50 @@ void mapperShouldFireAfterStatusPatchThroughSharedEventSource() { secondary.getSpec().setValue("initial"); extension.create(secondary); - var primary = new StatusEventPrimaryResource(); - primary.setMetadata( - new ObjectMetaBuilder() - .withName("test-primary") - .withNamespace(extension.getNamespace()) - .build()); + await() + .atMost(Duration.ofSeconds(30)) + .until( + () -> { + var p = extension.get(StatusEventPrimaryResource.class, "test-primary"); + return Boolean.TRUE.equals(p.getStatus().getSecondaryReady()); + }); - // When: the primary resource is created, triggering reconciliation which patches - // the secondary's observedGeneration=1 through the shared InformerEventSource - extension.create(primary); + // Arm the stall so the next primary reconciliation blocks before returning patchStatus + var primaryReconciler = extension.getReconcilerOfType(StatusEventPrimaryReconciler.class); + primaryReconciler.stallReconcile(); + + // When: secondary spec changes, triggering the secondary controller to reconcile + // and patch its status via UpdateControl.patchStatus() (through + // eventFilteringUpdateAndCacheResource) + var current = extension.get(StatusEventSecondaryResource.class, "test-secondary"); + current.getSpec().setValue("updated"); + extension.replace(current); - // Then: the mapper should see gen==obsGen and trigger a second reconciliation. - // With the bug, the EventFilterWindow suppresses the status-change event - // so the mapper never fires — the controller is stuck at 1 execution. + // Then: wait for secondary controller to patch its status (pure status-only WATCH event), + // then release the stalled primary. await() .atMost(Duration.ofSeconds(30)) - .untilAsserted( + .until( () -> { var sec = extension.get(StatusEventSecondaryResource.class, "test-secondary"); - assertThat(sec.getStatus().getObservedGeneration()) - .as("reconciler should patch observedGeneration to match generation") - .isEqualTo(sec.getMetadata().getGeneration()); + return Objects.equals( + sec.getStatus().getObservedGeneration(), sec.getMetadata().getGeneration()); }); + primaryReconciler.releaseReconcile(); + // The primary was stalled during the spec-change event (secondaryReady=false). + // After release it patches secondaryReady=false. The status-change event from + // the secondary controller's patchStatus should trigger a re-reconciliation + // where the primary sees secondaryReady=true. await() .atMost(Duration.ofSeconds(15)) .pollInterval(Duration.ofMillis(500)) .untilAsserted( - () -> - assertThat(primaryReconciler.getNumberOfExecutions()) - .as("mapper should fire for gen==obsGen and trigger re-reconciliation") - .isGreaterThanOrEqualTo(2)); + () -> { + var p = extension.get(StatusEventPrimaryResource.class, "test-primary"); + assertThat(p.getStatus().getSecondaryReady()) + .as("primary should see secondary as ready after controller status patch") + .isTrue(); + }); } } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventPrimaryReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventPrimaryReconciler.java index 0a0495b965..e7ac101d32 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventPrimaryReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventPrimaryReconciler.java @@ -18,6 +18,8 @@ import java.util.List; import java.util.Objects; import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import org.slf4j.Logger; @@ -41,8 +43,19 @@ public class StatusEventPrimaryReconciler implements Reconciler - secondaryEventSource; + private volatile CountDownLatch stallLatch; + + public void stallReconcile() { + stallLatch = new CountDownLatch(1); + } + + public void releaseReconcile() { + var latch = stallLatch; + if (latch != null) { + latch.countDown(); + } + stallLatch = null; + } @Override public UpdateControl reconcile( @@ -62,28 +75,29 @@ public UpdateControl reconcile( .withLabel(PRIMARY_NAME_LABEL, resource.getMetadata().getName()) .list() .getItems(); - - for (var secondary : secondaries) { - var generation = secondary.getMetadata().getGeneration(); - var observedGeneration = secondary.getStatus().getObservedGeneration(); - - if (!Objects.equals(generation, observedGeneration)) { - log.info( - "Patching secondary '{}' status: obsGen {} -> {}", - secondary.getMetadata().getName(), - observedGeneration, - generation); - - secondaryEventSource.eventFilteringUpdateAndCacheResource( - secondary, - s -> { - s.getStatus().setObservedGeneration(s.getMetadata().getGeneration()); - return context.getClient().resource(s).patchStatus(); - }); + var latch = stallLatch; + if (latch != null) { + try { + log.info("PrimaryReconciler stalled before returning patchStatus..."); + latch.await(60, TimeUnit.SECONDS); + log.info("PrimaryReconciler released, returning patchStatus"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); } } - - return UpdateControl.noUpdate(); + boolean allReady = + !secondaries.isEmpty() + && secondaries.stream() + .allMatch( + s -> + Objects.equals( + s.getMetadata().getGeneration(), + s.getStatus().getObservedGeneration())); + + log.info("PrimaryReconciler: secondaryReady={}, secondaries={}", allReady, secondaries.size()); + + resource.getStatus().setSecondaryReady(allReady); + return UpdateControl.patchStatus(resource); } @Override @@ -95,21 +109,12 @@ public List> prepareEventSources( StatusEventSecondaryResource.class, StatusEventPrimaryResource.class) .withSecondaryToPrimaryMapper( secondary -> { - var generation = secondary.getMetadata().getGeneration(); - var observedGeneration = secondary.getStatus().getObservedGeneration(); - - if (!Objects.equals(generation, observedGeneration)) { - log.info( - "Mapper: secondary '{}' gen {} != obsGen {} -> skipping", - secondary.getMetadata().getName(), - generation, - observedGeneration); + var primaryName = secondary.getMetadata().getLabels().get(PRIMARY_NAME_LABEL); + if (primaryName == null) { return Set.of(); } - - var primaryName = secondary.getMetadata().getLabels().get(PRIMARY_NAME_LABEL); log.info( - "Mapper: secondary '{}' gen matches -> mapping to primary '{}'", + "Mapper: secondary '{}' -> primary '{}'", secondary.getMetadata().getName(), primaryName); return Set.of( @@ -117,8 +122,7 @@ public List> prepareEventSources( }) .build(); - secondaryEventSource = new InformerEventSource<>(config, context); - return List.of(secondaryEventSource); + return List.of(new InformerEventSource<>(config, context)); } public int getNumberOfExecutions() { diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventPrimaryResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventPrimaryResource.java index 7924a614e6..b4162c9dda 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventPrimaryResource.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventPrimaryResource.java @@ -24,4 +24,11 @@ @Group("sample.javaoperatorsdk") @Version("v1") @ShortNames("sepr") -public class StatusEventPrimaryResource extends CustomResource implements Namespaced {} +public class StatusEventPrimaryResource + extends CustomResource implements Namespaced { + + @Override + protected StatusEventPrimaryResourceStatus initStatus() { + return new StatusEventPrimaryResourceStatus(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventPrimaryResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventPrimaryResourceStatus.java new file mode 100644 index 0000000000..caf4ddcf43 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventPrimaryResourceStatus.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.statuseventfiltering; + +public class StatusEventPrimaryResourceStatus { + private Boolean secondaryReady; + + public Boolean getSecondaryReady() { + return secondaryReady; + } + + public void setSecondaryReady(Boolean secondaryReady) { + this.secondaryReady = secondaryReady; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventSecondaryReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventSecondaryReconciler.java new file mode 100644 index 0000000000..351262a904 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventSecondaryReconciler.java @@ -0,0 +1,52 @@ +/* + * 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.statuseventfiltering; + +import java.util.Objects; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +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 +public class StatusEventSecondaryReconciler implements Reconciler { + + private static final Logger log = LoggerFactory.getLogger(StatusEventSecondaryReconciler.class); + + @Override + public UpdateControl reconcile( + StatusEventSecondaryResource resource, Context context) { + + var generation = resource.getMetadata().getGeneration(); + var observedGeneration = resource.getStatus().getObservedGeneration(); + + if (!Objects.equals(generation, observedGeneration)) { + log.info( + "Patching secondary '{}' status: obsGen {} -> {}", + resource.getMetadata().getName(), + observedGeneration, + generation); + resource.getStatus().setObservedGeneration(generation); + return UpdateControl.patchStatus(resource); + } + + return UpdateControl.noUpdate(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventSecondaryResourceStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventSecondaryResourceStatus.java index 070f4742c2..c7f14fa936 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventSecondaryResourceStatus.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/statuseventfiltering/StatusEventSecondaryResourceStatus.java @@ -17,6 +17,7 @@ public class StatusEventSecondaryResourceStatus { private Long observedGeneration; + private String message; public Long getObservedGeneration() { return observedGeneration; @@ -25,4 +26,12 @@ public Long getObservedGeneration() { public void setObservedGeneration(Long observedGeneration) { this.observedGeneration = observedGeneration; } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } }