From 259a648d9920e4264d2452d5259396806781fe3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 13 Mar 2026 16:02:56 +0100 Subject: [PATCH 01/15] chore: update version to 999-SNAPSHOT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- bootstrapper-maven-plugin/pom.xml | 4 ++-- caffeine-bounded-cache-support/pom.xml | 2 +- micrometer-support/pom.xml | 2 +- operator-framework-bom/pom.xml | 2 +- operator-framework-core/pom.xml | 2 +- operator-framework-junit/pom.xml | 2 +- operator-framework/pom.xml | 2 +- pom.xml | 4 ++-- sample-operators/controller-namespace-deletion/pom.xml | 2 +- sample-operators/leader-election/pom.xml | 2 +- sample-operators/mysql-schema/pom.xml | 2 +- sample-operators/operations/pom.xml | 7 +------ sample-operators/pom.xml | 2 +- sample-operators/tomcat-operator/pom.xml | 2 +- sample-operators/webpage/pom.xml | 2 +- test-index-processor/pom.xml | 2 +- 16 files changed, 18 insertions(+), 23 deletions(-) diff --git a/bootstrapper-maven-plugin/pom.xml b/bootstrapper-maven-plugin/pom.xml index da41ae46d5..9edf011170 100644 --- a/bootstrapper-maven-plugin/pom.xml +++ b/bootstrapper-maven-plugin/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk java-operator-sdk - 5.3.6-SNAPSHOT + 999-SNAPSHOT bootstrapper @@ -32,7 +32,7 @@ 3.15.2 - 3.9.16 + 3.9.15 3.1.0 3.15.2 diff --git a/caffeine-bounded-cache-support/pom.xml b/caffeine-bounded-cache-support/pom.xml index 356fabebd0..be70ab9a2e 100644 --- a/caffeine-bounded-cache-support/pom.xml +++ b/caffeine-bounded-cache-support/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.3.6-SNAPSHOT + 999-SNAPSHOT caffeine-bounded-cache-support diff --git a/micrometer-support/pom.xml b/micrometer-support/pom.xml index 42e94e5cb8..ae3c4d0be1 100644 --- a/micrometer-support/pom.xml +++ b/micrometer-support/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.3.6-SNAPSHOT + 999-SNAPSHOT micrometer-support diff --git a/operator-framework-bom/pom.xml b/operator-framework-bom/pom.xml index 0f819fa641..9b874fbbcc 100644 --- a/operator-framework-bom/pom.xml +++ b/operator-framework-bom/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk operator-framework-bom - 5.3.6-SNAPSHOT + 999-SNAPSHOT pom Operator SDK - Bill of Materials Java SDK for implementing Kubernetes operators diff --git a/operator-framework-core/pom.xml b/operator-framework-core/pom.xml index 3127d2e9fb..2356433ca9 100644 --- a/operator-framework-core/pom.xml +++ b/operator-framework-core/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.3.6-SNAPSHOT + 999-SNAPSHOT ../pom.xml diff --git a/operator-framework-junit/pom.xml b/operator-framework-junit/pom.xml index 10923adf65..aa18d5c778 100644 --- a/operator-framework-junit/pom.xml +++ b/operator-framework-junit/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.3.6-SNAPSHOT + 999-SNAPSHOT operator-framework-junit diff --git a/operator-framework/pom.xml b/operator-framework/pom.xml index 59abb1a926..f94dfa757d 100644 --- a/operator-framework/pom.xml +++ b/operator-framework/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.3.6-SNAPSHOT + 999-SNAPSHOT operator-framework diff --git a/pom.xml b/pom.xml index e1e6bdd299..a34f080692 100644 --- a/pom.xml +++ b/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.3.6-SNAPSHOT + 999-SNAPSHOT pom Operator SDK for Java Java SDK for implementing Kubernetes operators @@ -85,7 +85,7 @@ 3.2.4 0.9.14 2.22.0 - 4.17 + 4.16 2.11 3.15.0 diff --git a/sample-operators/controller-namespace-deletion/pom.xml b/sample-operators/controller-namespace-deletion/pom.xml index 430ebe1d46..af4be01972 100644 --- a/sample-operators/controller-namespace-deletion/pom.xml +++ b/sample-operators/controller-namespace-deletion/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.3.6-SNAPSHOT + 999-SNAPSHOT sample-controller-namespace-deletion diff --git a/sample-operators/leader-election/pom.xml b/sample-operators/leader-election/pom.xml index 4354bd3d09..4f896485d1 100644 --- a/sample-operators/leader-election/pom.xml +++ b/sample-operators/leader-election/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.3.6-SNAPSHOT + 999-SNAPSHOT sample-leader-election diff --git a/sample-operators/mysql-schema/pom.xml b/sample-operators/mysql-schema/pom.xml index 63d57a215b..d2872c921a 100644 --- a/sample-operators/mysql-schema/pom.xml +++ b/sample-operators/mysql-schema/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.3.6-SNAPSHOT + 999-SNAPSHOT sample-mysql-schema-operator diff --git a/sample-operators/operations/pom.xml b/sample-operators/operations/pom.xml index 4c78a9614b..1786cf39d0 100644 --- a/sample-operators/operations/pom.xml +++ b/sample-operators/operations/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.3.6-SNAPSHOT + 999-SNAPSHOT sample-operations @@ -106,11 +106,6 @@ operations-operator - - - -Dlog4j.configurationFile=/config/log4j2.xml - - diff --git a/sample-operators/pom.xml b/sample-operators/pom.xml index e7aca4b8db..9313095584 100644 --- a/sample-operators/pom.xml +++ b/sample-operators/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk java-operator-sdk - 5.3.6-SNAPSHOT + 999-SNAPSHOT sample-operators diff --git a/sample-operators/tomcat-operator/pom.xml b/sample-operators/tomcat-operator/pom.xml index 9aae55ef26..ea964a2b07 100644 --- a/sample-operators/tomcat-operator/pom.xml +++ b/sample-operators/tomcat-operator/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.3.6-SNAPSHOT + 999-SNAPSHOT sample-tomcat-operator diff --git a/sample-operators/webpage/pom.xml b/sample-operators/webpage/pom.xml index 3b8ce0ac49..d50e5ef03c 100644 --- a/sample-operators/webpage/pom.xml +++ b/sample-operators/webpage/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk sample-operators - 5.3.6-SNAPSHOT + 999-SNAPSHOT sample-webpage-operator diff --git a/test-index-processor/pom.xml b/test-index-processor/pom.xml index 11cd3b476b..2ae7c5f454 100644 --- a/test-index-processor/pom.xml +++ b/test-index-processor/pom.xml @@ -22,7 +22,7 @@ io.javaoperatorsdk java-operator-sdk - 5.3.6-SNAPSHOT + 999-SNAPSHOT test-index-processor From 3f769bd71cf7798d10cfb3344ebb58771c3f1950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Mon, 30 Mar 2026 13:55:55 +0200 Subject: [PATCH 02/15] fix: set migration module version to correct one (#3263) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- migration/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migration/pom.xml b/migration/pom.xml index 62fccbd857..ce272eba9e 100644 --- a/migration/pom.xml +++ b/migration/pom.xml @@ -21,7 +21,7 @@ io.javaoperatorsdk java-operator-sdk - 5.3.6-SNAPSHOT + 999-SNAPSHOT migration From 02de8ee108e12ce0500e850cf0a6a104d56ebbe9 Mon Sep 17 00:00:00 2001 From: Antonio <122279781+afalhambra-hivemq@users.noreply.github.com> Date: Mon, 25 May 2026 19:44:36 +0200 Subject: [PATCH 03/15] feat(core): add by-name secondary resource lookup on Context (#3373) --- .../operator/api/reconciler/Context.java | 83 ++++++ .../api/reconciler/DefaultContext.java | 69 ++++- .../api/reconciler/DefaultContextTest.java | 236 ++++++++++++++++++ 3 files changed, 377 insertions(+), 11 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 2df74d4298..75480dedb5 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 @@ -114,6 +114,89 @@ default Stream getSecondaryResourcesAsStream(Class expectedType) { Optional getSecondaryResource(Class expectedType, String eventSourceName); + /** + * Retrieves a specific secondary resource by name and namespace from the event source identified + * by the given name. + * + *

This is a typed convenience over manually retrieving the {@link + * io.javaoperatorsdk.operator.processing.event.source.EventSource} and calling its cache. When + * the underlying event source implements {@link + * io.javaoperatorsdk.operator.processing.event.source.Cache}, the lookup is a direct cache lookup + * and read-cache-after-write consistent. + * + *

{@code eventSourceName} may be {@code null}. When {@code null} and {@code expectedType} is + * part of a managed workflow whose activation condition may not have registered the event source, + * an empty {@link Optional} is returned instead of throwing {@link + * io.javaoperatorsdk.operator.processing.event.NoEventSourceForClassException}. + * + * @param expectedType the class representing the type of secondary resource to retrieve + * @param eventSourceName the name of the event source to look in (may be {@code null}) + * @param name the name of the secondary resource + * @param namespace the namespace of the secondary resource (may be {@code null} for + * cluster-scoped resources) + * @param the type of secondary resource to retrieve + * @return an {@link Optional} containing the matching secondary resource, or {@link + * Optional#empty()} if none matches + * @throws io.javaoperatorsdk.operator.processing.event.NoEventSourceForClassException if no event + * source is registered for the given type and name (and no workflow activation condition + * accounts for it) + * @since 5.4.0 + */ + Optional getSecondaryResource( + Class expectedType, String eventSourceName, String name, String namespace); + + /** + * Convenience overload of {@link #getSecondaryResource(Class, String, String, String)} that uses + * the primary resource's namespace. + * + *

If the primary resource is cluster-scoped (no namespace), the lookup is performed against + * the cluster scope. To target a specific namespace from a cluster-scoped primary, use {@link + * #getSecondaryResource(Class, String, String, String)} directly. + * + *

{@code eventSourceName} may be {@code null} with the same semantics as in {@link + * #getSecondaryResource(Class, String, String, String)}. + * + * @param expectedType the class representing the type of secondary resource to retrieve + * @param eventSourceName the name of the event source to look in (may be {@code null}) + * @param name the name of the secondary resource (namespace inferred from the primary) + * @param the type of secondary resource to retrieve + * @return an {@link Optional} containing the matching secondary resource, or {@link + * Optional#empty()} if none matches + * @since 5.4.0 + */ + default Optional getSecondaryResource( + Class expectedType, String eventSourceName, String name) { + return getSecondaryResource( + expectedType, eventSourceName, name, getPrimaryResource().getMetadata().getNamespace()); + } + + /** + * Retrieves a {@link Stream} of the secondary resources of the specified type from the event + * source identified by the given name. Useful when several event sources are registered for the + * same type and you need to scope retrieval to one of them, or when you want to apply a custom + * filter at the call site. + * + *

When the underlying event source implements {@link ResourceCache}, the stream is + * read-cache-after-write consistent. + * + *

{@code eventSourceName} may be {@code null} with the same semantics as in {@link + * #getSecondaryResource(Class, String, String, String)}: when {@code null} and {@code + * expectedType} is part of a managed workflow whose activation condition may not have registered + * the event source, an empty {@link Stream} is returned instead of throwing {@link + * io.javaoperatorsdk.operator.processing.event.NoEventSourceForClassException}. + * + * @param expectedType the class representing the type of secondary resources to retrieve + * @param eventSourceName the name of the event source to look in (may be {@code null}) + * @param the type of secondary resources to retrieve + * @return a {@link Stream} of secondary resources of the specified type + * @throws io.javaoperatorsdk.operator.processing.event.NoEventSourceForClassException if no event + * source is registered for the given type and name (and no workflow activation condition + * accounts for it) + * @since 5.4.0 + */ + Stream getSecondaryResourcesAsStream( + Class expectedType, String eventSourceName); + ControllerConfiguration

getControllerConfiguration(); /** diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java index ac5a7b41b9..ea7bd21874 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java @@ -36,6 +36,7 @@ import io.javaoperatorsdk.operator.processing.event.EventSourceRetriever; import io.javaoperatorsdk.operator.processing.event.NoEventSourceForClassException; import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.Cache; public class DefaultContext

implements Context

{ private RetryInfo retryInfo; @@ -95,6 +96,20 @@ public Stream getSecondaryResourcesAsStream(Class expectedType, boolea } } + /** + * Whether a missing event source for the given type is the expected case, in which case callers + * should return an empty result instead of propagating the {@link + * NoEventSourceForClassException}. + * + *

If a workflow has an activation condition there can be event sources which are only + * registered if the activation condition holds, but to provide a consistent API we return an + * empty result instead of throwing an exception. Note that not only the resource which has an + * activation condition might not be registered but dependents which depend on it. + */ + private boolean isMissingEventSourceExpected(String eventSourceName, Class expectedType) { + return eventSourceName == null && controller.workflowContainsDependentForType(expectedType); + } + private Map deduplicatedMap(Stream stream) { return stream.collect( Collectors.toUnmodifiableMap( @@ -120,19 +135,51 @@ public Optional getSecondaryResource(Class expectedType, String eventS .getEventSourceFor(expectedType, eventSourceName) .getSecondaryResource(primaryResource); } catch (NoEventSourceForClassException e) { - /* - * If a workflow has an activation condition there can be event sources which are only - * registered if the activation condition holds, but to provide a consistent API we return an - * Optional instead of throwing an exception. - * - * Note that not only the resource which has an activation condition might not be registered - * but dependents which depend on it. - */ - if (eventSourceName == null && controller.workflowContainsDependentForType(expectedType)) { + if (isMissingEventSourceExpected(eventSourceName, expectedType)) { return Optional.empty(); - } else { - throw e; } + throw e; + } + } + + @Override + public Optional getSecondaryResource( + Class expectedType, String eventSourceName, String name, String namespace) { + try { + final var eventSource = + controller.getEventSourceManager().getEventSourceFor(expectedType, eventSourceName); + final var resourceID = new ResourceID(name, namespace); + if (eventSource instanceof Cache cache) { + return cache.get(resourceID).map(expectedType::cast); + } + return eventSource.getSecondaryResources(primaryResource).stream() + .filter(r -> ResourceID.fromResource(r).equals(resourceID)) + .findFirst(); + } catch (NoEventSourceForClassException e) { + if (isMissingEventSourceExpected(eventSourceName, expectedType)) { + return Optional.empty(); + } + throw e; + } + } + + @Override + public Stream getSecondaryResourcesAsStream( + Class expectedType, String eventSourceName) { + try { + final var eventSource = + controller.getEventSourceManager().getEventSourceFor(expectedType, eventSourceName); + if (eventSource instanceof ResourceCache resourceCache) { + final var ns = primaryResource.getMetadata().getNamespace(); + final Stream stream = ns == null ? resourceCache.list() : resourceCache.list(ns); + return stream.map(expectedType::cast); + } + return eventSource.getSecondaryResources(primaryResource).stream(); + } catch (NoEventSourceForClassException e) { + if (isMissingEventSourceExpected(eventSourceName, expectedType)) { + return Stream.empty(); + } + throw e; } } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContextTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContextTest.java index 4df8df385b..7b9658f98d 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContextTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContextTest.java @@ -16,26 +16,34 @@ package io.javaoperatorsdk.operator.api.reconciler; import java.util.List; +import java.util.Optional; import java.util.Set; +import java.util.stream.Stream; 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.HasMetadata; import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; import io.fabric8.kubernetes.api.model.Pod; import io.fabric8.kubernetes.api.model.PodBuilder; import io.fabric8.kubernetes.api.model.Secret; +import io.fabric8.kubernetes.api.model.SecretBuilder; import io.javaoperatorsdk.operator.processing.Controller; import io.javaoperatorsdk.operator.processing.event.EventSourceManager; import io.javaoperatorsdk.operator.processing.event.NoEventSourceForClassException; import io.javaoperatorsdk.operator.processing.event.ResourceID; import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.informer.ManagedInformerEventSource; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class DefaultContextTest { @@ -63,6 +71,234 @@ void getSecondaryResourceReturnsEmptyOptionalOnNonActivatedDRType() { assertThat(res).isEmpty(); } + @Test + void getSecondaryResourceByNameAndNamespaceReturnsFromCacheFastPath() { + final var cm = + new ConfigMapBuilder() + .withNewMetadata() + .withName("cm-foo") + .withNamespace("ns") + .endMetadata() + .build(); + + final ManagedInformerEventSource cachingEventSource = mock(); + when(cachingEventSource.get(new ResourceID("cm-foo", "ns"))).thenReturn(Optional.of(cm)); + when(mockManager.getEventSourceFor(ConfigMap.class, "es-name")).thenReturn(cachingEventSource); + + final var res = context.getSecondaryResource(ConfigMap.class, "es-name", "cm-foo", "ns"); + + assertThat(res).contains(cm); + verify(cachingEventSource).get(new ResourceID("cm-foo", "ns")); + } + + @Test + void getSecondaryResourceByNameAndNamespaceReturnsEmptyOnCacheMiss() { + final ManagedInformerEventSource cachingEventSource = mock(); + when(cachingEventSource.get(new ResourceID("missing", "ns"))).thenReturn(Optional.empty()); + when(mockManager.getEventSourceFor(ConfigMap.class, "es-name")).thenReturn(cachingEventSource); + + assertThat(context.getSecondaryResource(ConfigMap.class, "es-name", "missing", "ns")).isEmpty(); + } + + @Test + void getSecondaryResourceByNameAndNamespaceFallsBackToGetSecondaryResources() { + final var match = + new ConfigMapBuilder() + .withNewMetadata() + .withName("cm-foo") + .withNamespace("ns") + .endMetadata() + .build(); + final var other = + new ConfigMapBuilder() + .withNewMetadata() + .withName("cm-bar") + .withNamespace("ns") + .endMetadata() + .build(); + + final EventSource nonCachingEventSource = mock(); + when(nonCachingEventSource.getSecondaryResources(any())).thenReturn(Set.of(match, other)); + when(mockManager.getEventSourceFor(ConfigMap.class, "es-name")) + .thenReturn(nonCachingEventSource); + + final var res = context.getSecondaryResource(ConfigMap.class, "es-name", "cm-foo", "ns"); + + assertThat(res).contains(match); + } + + @Test + void getSecondaryResourceByNameAndNamespaceFallbackReturnsEmptyWhenNoMatch() { + final var other = + new ConfigMapBuilder() + .withNewMetadata() + .withName("cm-other") + .withNamespace("ns") + .endMetadata() + .build(); + + final EventSource nonCachingEventSource = mock(); + when(nonCachingEventSource.getSecondaryResources(any())).thenReturn(Set.of(other)); + when(mockManager.getEventSourceFor(ConfigMap.class, "es-name")) + .thenReturn(nonCachingEventSource); + + assertThat(context.getSecondaryResource(ConfigMap.class, "es-name", "missing", "ns")).isEmpty(); + } + + @Test + void getSecondaryResourceByNameAndNamespaceRethrowsWhenNoEventSourceAndNotWorkflowManaged() { + when(mockManager.getEventSourceFor(ConfigMap.class, "es-name")) + .thenThrow(new NoEventSourceForClassException(ConfigMap.class)); + + assertThatThrownBy( + () -> context.getSecondaryResource(ConfigMap.class, "es-name", "cm-foo", "ns")) + .isInstanceOf(NoEventSourceForClassException.class); + } + + @Test + void getSecondaryResourceByNameAndNamespaceReturnsEmptyWhenNoEventSourceButWorkflowManaged() { + when(mockManager.getEventSourceFor(ConfigMap.class, null)) + .thenThrow(new NoEventSourceForClassException(ConfigMap.class)); + when(mockController.workflowContainsDependentForType(ConfigMap.class)).thenReturn(true); + + final var res = context.getSecondaryResource(ConfigMap.class, null, "cm-foo", "ns"); + + assertThat(res).isEmpty(); + } + + @Test + void getSecondaryResourceByNameUsesPrimaryNamespace() { + final var primaryNamespace = "primary-ns"; + final var namespacedPrimary = + new SecretBuilder() + .withNewMetadata() + .withName("primary") + .withNamespace(primaryNamespace) + .endMetadata() + .build(); + final DefaultContext namespacedContext = + new DefaultContext<>(null, mockController, namespacedPrimary, false, false); + + final var cm = + new ConfigMapBuilder() + .withNewMetadata() + .withName("cm-foo") + .withNamespace(primaryNamespace) + .endMetadata() + .build(); + + final ManagedInformerEventSource cachingEventSource = mock(); + when(cachingEventSource.get(new ResourceID("cm-foo", primaryNamespace))) + .thenReturn(Optional.of(cm)); + when(mockManager.getEventSourceFor(ConfigMap.class, "es-name")).thenReturn(cachingEventSource); + + final var res = namespacedContext.getSecondaryResource(ConfigMap.class, "es-name", "cm-foo"); + + assertThat(res).contains(cm); + } + + @Test + void getSecondaryResourcesAsStreamByEventSourceUsesResourceCacheFastPath() { + final var primaryNamespace = "primary-ns"; + final var namespacedPrimary = + new SecretBuilder() + .withNewMetadata() + .withName("primary") + .withNamespace(primaryNamespace) + .endMetadata() + .build(); + final DefaultContext namespacedContext = + new DefaultContext<>(null, mockController, namespacedPrimary, false, false); + + final var cm1 = + new ConfigMapBuilder() + .withNewMetadata() + .withName("cm-1") + .withNamespace(primaryNamespace) + .endMetadata() + .build(); + final var cm2 = + new ConfigMapBuilder() + .withNewMetadata() + .withName("cm-2") + .withNamespace(primaryNamespace) + .endMetadata() + .build(); + + final ManagedInformerEventSource resourceCacheEventSource = mock(); + when(resourceCacheEventSource.list(primaryNamespace)).thenReturn(Stream.of(cm1, cm2)); + when(mockManager.getEventSourceFor(ConfigMap.class, "es-name")) + .thenReturn(resourceCacheEventSource); + + final var res = + namespacedContext.getSecondaryResourcesAsStream(ConfigMap.class, "es-name").toList(); + + assertThat(res).containsExactlyInAnyOrder(cm1, cm2); + verify(resourceCacheEventSource).list(primaryNamespace); + } + + @Test + void getSecondaryResourcesAsStreamByEventSourceFastPathOnClusterScopedPrimary() { + // cluster-scoped primary: has metadata but no namespace set. + final var clusterScopedPrimary = + new SecretBuilder().withNewMetadata().withName("primary").endMetadata().build(); + final DefaultContext clusterScopedContext = + new DefaultContext<>(null, mockController, clusterScopedPrimary, false, false); + + final var cm1 = new ConfigMapBuilder().withNewMetadata().withName("cm-1").endMetadata().build(); + + final ManagedInformerEventSource resourceCacheEventSource = mock(); + when(resourceCacheEventSource.list()).thenReturn(Stream.of(cm1)); + when(mockManager.getEventSourceFor(ConfigMap.class, "es-name")) + .thenReturn(resourceCacheEventSource); + + final var res = + clusterScopedContext.getSecondaryResourcesAsStream(ConfigMap.class, "es-name").toList(); + + assertThat(res).containsExactly(cm1); + verify(resourceCacheEventSource).list(); + verify(resourceCacheEventSource, never()).list(any(String.class)); + } + + @Test + void getSecondaryResourcesAsStreamByEventSourceFallsBackToGetSecondaryResources() { + final var cm1 = + new ConfigMapBuilder() + .withNewMetadata() + .withName("cm-1") + .withNamespace("ns") + .endMetadata() + .build(); + + final EventSource nonCacheEventSource = mock(); + when(nonCacheEventSource.getSecondaryResources(any())).thenReturn(Set.of(cm1)); + when(mockManager.getEventSourceFor(ConfigMap.class, "es-name")).thenReturn(nonCacheEventSource); + + final var res = context.getSecondaryResourcesAsStream(ConfigMap.class, "es-name").toList(); + + assertThat(res).containsExactly(cm1); + } + + @Test + void getSecondaryResourcesAsStreamByEventSourceRethrowsWhenNotWorkflowManaged() { + when(mockManager.getEventSourceFor(ConfigMap.class, "es-name")) + .thenThrow(new NoEventSourceForClassException(ConfigMap.class)); + + assertThatThrownBy(() -> context.getSecondaryResourcesAsStream(ConfigMap.class, "es-name")) + .isInstanceOf(NoEventSourceForClassException.class); + } + + @Test + void getSecondaryResourcesAsStreamByEventSourceReturnsEmptyWhenWorkflowManaged() { + when(mockManager.getEventSourceFor(ConfigMap.class, null)) + .thenThrow(new NoEventSourceForClassException(ConfigMap.class)); + when(mockController.workflowContainsDependentForType(ConfigMap.class)).thenReturn(true); + + final var res = context.getSecondaryResourcesAsStream(ConfigMap.class, null).toList(); + + assertThat(res).isEmpty(); + } + @Test void setRetryInfo() { RetryInfo retryInfo = mock(); From 4cc21d9f40d854ce1029f6adc9ddf151be3bcd40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 26 May 2026 10:34:02 +0200 Subject: [PATCH 04/15] improve: getSecondaryResourcesAsStream(expectedType,eventSourceName) does not have to be Kubernetes resources (#3377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../io/javaoperatorsdk/operator/api/reconciler/Context.java | 3 +-- .../operator/api/reconciler/DefaultContext.java | 2 +- 2 files changed, 2 insertions(+), 3 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 75480dedb5..75d12eb1ad 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 @@ -194,8 +194,7 @@ default Optional getSecondaryResource( * accounts for it) * @since 5.4.0 */ - Stream getSecondaryResourcesAsStream( - Class expectedType, String eventSourceName); + Stream getSecondaryResourcesAsStream(Class expectedType, String eventSourceName); ControllerConfiguration

getControllerConfiguration(); diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java index ea7bd21874..2d9a22b6fa 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/DefaultContext.java @@ -164,7 +164,7 @@ public Optional getSecondaryResource( } @Override - public Stream getSecondaryResourcesAsStream( + public Stream getSecondaryResourcesAsStream( Class expectedType, String eventSourceName) { try { final var eventSource = From da0d47d565fe197d3f48824248af2d820853c598 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 28 May 2026 12:07:16 +0200 Subject: [PATCH 05/15] improve: reconciliation counts as retry attempt only if close to retry deadline (#3380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../documentation/error-handling-retries.md | 3 + .../processing/event/EventProcessor.java | 16 +++ .../retry/GenericRetryExecution.java | 15 +++ .../processing/retry/RetryExecution.java | 12 ++ .../processing/event/EventProcessorTest.java | 92 +++++++++++++++ .../retry/GenericRetryExecutionTest.java | 66 +++++++++-- ...etryIntervalHonoredOnFrequentEventsIT.java | 107 ++++++++++++++++++ .../retry/RetryTestCustomReconciler.java | 11 ++ 8 files changed, 313 insertions(+), 9 deletions(-) create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryIntervalHonoredOnFrequentEventsIT.java diff --git a/docs/content/en/docs/documentation/error-handling-retries.md b/docs/content/en/docs/documentation/error-handling-retries.md index eeecf54751..7bd4ad2e22 100644 --- a/docs/content/en/docs/documentation/error-handling-retries.md +++ b/docs/content/en/docs/documentation/error-handling-retries.md @@ -135,6 +135,9 @@ these features: 2. In case an exception is thrown, a retry is initiated. However, if an event is received meanwhile, it will be reconciled instantly, and this execution won't count as a retry attempt. + If that event-triggered reconciliation also fails inside the current retry window, the + existing retry deadline is preserved rather than reset — the failure does not advance the + retry counter unless the original deadline is imminent. 3. If the retry limit is reached (so no more automatic retry would happen), but a new event received, the reconciliation will still happen, but won't reset the retry, and will still be marked as the last attempt in the retry info. The point (1) still holds - thus successful reconciliation will reset the retry - but no retry will happen in case of an error. 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 5af48a1694..c8322e47e5 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 @@ -49,6 +49,13 @@ public class EventProcessor

implements EventHandler, Life private static final Logger log = LoggerFactory.getLogger(EventProcessor.class); private static final long MINIMAL_RATE_LIMIT_RESCHEDULE_DURATION = 50; + /** + * Threshold below which an event-driven failed reconciliation that lands inside the current retry + * window is allowed to consume a retry attempt (i.e. advance the retry counter). Above this + * threshold the existing retry deadline is preserved instead. + */ + private static final long RETRY_DEADLINE_PRESERVE_THRESHOLD_MILLIS = 5_000; + private volatile boolean running; private final ControllerConfiguration controllerConfiguration; private final ReconciliationDispatcher

reconciliationDispatcher; @@ -377,6 +384,15 @@ private void handleRetryOnException(ExecutionScope

executionScope, Exception submitReconciliationExecution(state); return; } + Optional remaining = state.getRetry().remainingDurationUntilNextRetry(); + if (remaining.isPresent() + && remaining.get().toMillis() > RETRY_DEADLINE_PRESERVE_THRESHOLD_MILLIS) { + log.debug( + "Preserving existing retry deadline; remaining: {} ms. Not consuming a retry attempt.", + remaining.get().toMillis()); + retryEventSource().scheduleOnce(resourceID, remaining.get().toMillis()); + return; + } Optional nextDelay = state.getRetry().nextDelay(); nextDelay.ifPresentOrElse( delay -> { diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GenericRetryExecution.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GenericRetryExecution.java index 4bdce57a77..fadc022de7 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GenericRetryExecution.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/GenericRetryExecution.java @@ -15,6 +15,7 @@ */ package io.javaoperatorsdk.operator.processing.retry; +import java.time.Duration; import java.util.Optional; public class GenericRetryExecution implements RetryExecution { @@ -23,6 +24,7 @@ public class GenericRetryExecution implements RetryExecution { private int lastAttemptIndex = 0; private long currentInterval; + private Long lastNextDelayCallEpochMillis; public GenericRetryExecution(GenericRetry genericRetry) { this.genericRetry = genericRetry; @@ -40,6 +42,7 @@ public Optional nextDelay() { } } lastAttemptIndex++; + lastNextDelayCallEpochMillis = System.currentTimeMillis(); return Optional.of(currentInterval); } @@ -52,4 +55,16 @@ public boolean isLastAttempt() { public int getAttemptCount() { return lastAttemptIndex; } + + @Override + public Optional remainingDurationUntilNextRetry() { + if (lastNextDelayCallEpochMillis == null) { + return Optional.empty(); + } + long remaining = (lastNextDelayCallEpochMillis + currentInterval) - System.currentTimeMillis(); + if (remaining <= 0) { + return Optional.empty(); + } + return Optional.of(Duration.ofMillis(remaining)); + } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/RetryExecution.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/RetryExecution.java index caf71d7a33..a644a274ba 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/RetryExecution.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/retry/RetryExecution.java @@ -15,6 +15,7 @@ */ package io.javaoperatorsdk.operator.processing.retry; +import java.time.Duration; import java.util.Optional; import io.javaoperatorsdk.operator.api.reconciler.RetryInfo; @@ -25,4 +26,15 @@ public interface RetryExecution extends RetryInfo { * @return the time to wait until the next execution in milliseconds */ Optional nextDelay(); + + /** + * Remaining time of the currently scheduled retry interval, i.e. the time until the previously + * computed retry delay would elapse. Returns an empty {@link Optional} if no retry has been + * scheduled yet (i.e. {@link #nextDelay()} has never been called) or if the deadline has already + * passed. + * + *

Used to decide whether an event-driven failed reconciliation that lands well inside the + * retry window should consume a retry attempt or simply be re-scheduled on the original deadline. + */ + Optional remainingDurationUntilNextRetry(); } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventProcessorTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventProcessorTest.java index fb8f7c0805..f7864f2f16 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventProcessorTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/EventProcessorTest.java @@ -465,6 +465,98 @@ void schedulesRetryForMarReconciliationIntervalIfRetryExhausted() { verify(retryTimerEventSourceMock, times(1)).scheduleOnce((ResourceID) any(), anyLong()); } + @Test + void preservesRetryDeadlineWhenRemainingDurationAboveThreshold() { + RetryExecution mockRetryExecution = mock(RetryExecution.class); + when(mockRetryExecution.nextDelay()).thenReturn(Optional.of(60_000L)); + when(mockRetryExecution.remainingDurationUntilNextRetry()) + .thenReturn(Optional.of(Duration.ofMillis(50_000))); + Retry retry = mock(Retry.class); + when(retry.initExecution()).thenReturn(mockRetryExecution); + eventProcessorWithRetry = + spy( + new EventProcessor( + controllerConfiguration(retry, LinearRateLimiter.deactivatedRateLimiter()), + reconciliationDispatcherMock, + eventSourceManagerMock, + metricsMock)); + eventProcessorWithRetry.start(); + when(eventProcessorWithRetry.retryEventSource()).thenReturn(retryTimerEventSourceMock); + + TestCustomResource customResource = testCustomResource(); + ExecutionScope executionScope = + new ExecutionScope(null, null, false, false).setResource(customResource); + PostExecutionControl postExecutionControl = + PostExecutionControl.exceptionDuringExecution(new RuntimeException("test")); + + eventProcessorWithRetry.eventProcessingFinished(executionScope, postExecutionControl); + + verify(mockRetryExecution, never()).nextDelay(); + verify(retryTimerEventSourceMock, times(1)) + .scheduleOnce(eq(ResourceID.fromResource(customResource)), eq(50_000L)); + } + + @Test + void consumesRetryAttemptWhenRemainingDurationAtOrBelowThreshold() { + RetryExecution mockRetryExecution = mock(RetryExecution.class); + when(mockRetryExecution.nextDelay()).thenReturn(Optional.of(60_000L)); + when(mockRetryExecution.remainingDurationUntilNextRetry()) + .thenReturn(Optional.of(Duration.ofMillis(2_000))); + Retry retry = mock(Retry.class); + when(retry.initExecution()).thenReturn(mockRetryExecution); + eventProcessorWithRetry = + spy( + new EventProcessor( + controllerConfiguration(retry, LinearRateLimiter.deactivatedRateLimiter()), + reconciliationDispatcherMock, + eventSourceManagerMock, + metricsMock)); + eventProcessorWithRetry.start(); + when(eventProcessorWithRetry.retryEventSource()).thenReturn(retryTimerEventSourceMock); + + TestCustomResource customResource = testCustomResource(); + ExecutionScope executionScope = + new ExecutionScope(null, null, false, false).setResource(customResource); + PostExecutionControl postExecutionControl = + PostExecutionControl.exceptionDuringExecution(new RuntimeException("test")); + + eventProcessorWithRetry.eventProcessingFinished(executionScope, postExecutionControl); + + verify(mockRetryExecution, times(1)).nextDelay(); + verify(retryTimerEventSourceMock, times(1)) + .scheduleOnce(eq(ResourceID.fromResource(customResource)), eq(60_000L)); + } + + @Test + void firstFailureSchedulesUsingNextDelayWhenNoRemainingDuration() { + RetryExecution mockRetryExecution = mock(RetryExecution.class); + when(mockRetryExecution.nextDelay()).thenReturn(Optional.of(60_000L)); + when(mockRetryExecution.remainingDurationUntilNextRetry()).thenReturn(Optional.empty()); + Retry retry = mock(Retry.class); + when(retry.initExecution()).thenReturn(mockRetryExecution); + eventProcessorWithRetry = + spy( + new EventProcessor( + controllerConfiguration(retry, LinearRateLimiter.deactivatedRateLimiter()), + reconciliationDispatcherMock, + eventSourceManagerMock, + metricsMock)); + eventProcessorWithRetry.start(); + when(eventProcessorWithRetry.retryEventSource()).thenReturn(retryTimerEventSourceMock); + + TestCustomResource customResource = testCustomResource(); + ExecutionScope executionScope = + new ExecutionScope(null, null, false, false).setResource(customResource); + PostExecutionControl postExecutionControl = + PostExecutionControl.exceptionDuringExecution(new RuntimeException("test")); + + eventProcessorWithRetry.eventProcessingFinished(executionScope, postExecutionControl); + + verify(mockRetryExecution, times(1)).nextDelay(); + verify(retryTimerEventSourceMock, times(1)) + .scheduleOnce(eq(ResourceID.fromResource(customResource)), eq(60_000L)); + } + @Test void executionOfReconciliationShouldNotStartIfProcessorStopped() throws InterruptedException { when(reconciliationDispatcherMock.handleExecution(any())) diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/retry/GenericRetryExecutionTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/retry/GenericRetryExecutionTest.java index 8f5a446788..8d7ec55e37 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/retry/GenericRetryExecutionTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/retry/GenericRetryExecutionTest.java @@ -21,10 +21,10 @@ import static org.assertj.core.api.Assertions.assertThat; -public class GenericRetryExecutionTest { +class GenericRetryExecutionTest { @Test - public void noNextDelayIfMaxAttemptLimitReached() { + void noNextDelayIfMaxAttemptLimitReached() { RetryExecution retryExecution = GenericRetry.defaultLimitedExponentialRetry().setMaxAttempts(3).initExecution(); Optional res = callNextDelayNTimes(retryExecution, 2); @@ -35,7 +35,7 @@ public void noNextDelayIfMaxAttemptLimitReached() { } @Test - public void canLimitMaxIntervalLength() { + void canLimitMaxIntervalLength() { RetryExecution retryExecution = GenericRetry.defaultLimitedExponentialRetry() .setInitialInterval(2000) @@ -49,13 +49,13 @@ public void canLimitMaxIntervalLength() { } @Test - public void supportsNoRetry() { + void supportsNoRetry() { RetryExecution retryExecution = GenericRetry.noRetry().initExecution(); assertThat(retryExecution.nextDelay()).isEmpty(); } @Test - public void supportsIsLastExecution() { + void supportsIsLastExecution() { GenericRetryExecution execution = new GenericRetry().setMaxAttempts(2).initExecution(); assertThat(execution.isLastAttempt()).isFalse(); @@ -65,7 +65,7 @@ public void supportsIsLastExecution() { } @Test - public void returnAttemptIndex() { + void returnAttemptIndex() { RetryExecution retryExecution = GenericRetry.defaultLimitedExponentialRetry().initExecution(); assertThat(retryExecution.getAttemptCount()).isEqualTo(0); @@ -73,11 +73,59 @@ public void returnAttemptIndex() { assertThat(retryExecution.getAttemptCount()).isEqualTo(1); } - private RetryExecution getDefaultRetryExecution() { - return GenericRetry.defaultLimitedExponentialRetry().initExecution(); + @Test + void remainingDurationEmptyBeforeFirstNextDelay() { + RetryExecution retryExecution = GenericRetry.defaultLimitedExponentialRetry().initExecution(); + + assertThat(retryExecution.remainingDurationUntilNextRetry()).isEmpty(); + } + + @Test + void remainingDurationPresentAfterNextDelay() { + long interval = 60_000L; + RetryExecution retryExecution = new GenericRetry().setInitialInterval(interval).initExecution(); + + retryExecution.nextDelay(); + + Optional remaining = retryExecution.remainingDurationUntilNextRetry(); + assertThat(remaining).isPresent(); + assertThat(remaining.get().toMillis()).isPositive().isLessThanOrEqualTo(interval); + } + + @Test + void remainingDurationEmptyAfterIntervalElapsed() throws InterruptedException { + RetryExecution retryExecution = new GenericRetry().setInitialInterval(20).initExecution(); + + retryExecution.nextDelay(); + Thread.sleep(60); + + assertThat(retryExecution.remainingDurationUntilNextRetry()).isEmpty(); + } + + @Test + void remainingDurationReflectsUpdatedIntervalAfterSubsequentNextDelay() { + long initialInterval = 1000L; + double multiplier = 2.0; + RetryExecution retryExecution = + new GenericRetry() + .setInitialInterval(initialInterval) + .setIntervalMultiplier(multiplier) + .initExecution(); + + // first two calls keep the initial interval (multiplier only kicks in after attempt 1) + retryExecution.nextDelay(); + retryExecution.nextDelay(); + // third call doubles the interval to 2000ms + retryExecution.nextDelay(); + + Optional remaining = retryExecution.remainingDurationUntilNextRetry(); + assertThat(remaining).isPresent(); + assertThat(remaining.get().toMillis()) + .isPositive() + .isLessThanOrEqualTo((long) (initialInterval * multiplier)); } - public Optional callNextDelayNTimes(RetryExecution retryExecution, int n) { + Optional callNextDelayNTimes(RetryExecution retryExecution, int n) { for (int i = 0; i < n; i++) { retryExecution.nextDelay(); } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryIntervalHonoredOnFrequentEventsIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryIntervalHonoredOnFrequentEventsIT.java new file mode 100644 index 0000000000..df525e8056 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryIntervalHonoredOnFrequentEventsIT.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.retry; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.javaoperatorsdk.annotation.Sample; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; +import io.javaoperatorsdk.operator.processing.retry.GenericRetry; + +import static io.javaoperatorsdk.operator.baseapi.retry.RetryIT.createTestCustomResource; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@Sample( + tldr = "Retry Interval Honored Despite Frequent Reconciliation Triggers", + description = + """ + Verifies that with a low max attempts (3) and a high retry interval (1 minute), \ + reconciliations triggered by external events (e.g. resource updates) during the retry \ + window do not consume retry attempts. The retry counter should only advance when the \ + scheduled retry deadline is approached, so the configured interval is honored. + """) +class RetryIntervalHonoredOnFrequentEventsIT { + + private static final Logger log = + LoggerFactory.getLogger(RetryIntervalHonoredOnFrequentEventsIT.class); + + public static final int MAX_RETRY_ATTEMPTS = 3; + public static final int RETRY_INTERVAL_MILLIS = 60_000; + public static final int ALL_EXECUTIONS_TO_FAIL = 99; + public static final int NUMBER_OF_UPDATES = 5; + + RetryTestCustomReconciler reconciler = new RetryTestCustomReconciler(ALL_EXECUTIONS_TO_FAIL); + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler( + reconciler, + new GenericRetry() + .setInitialInterval(RETRY_INTERVAL_MILLIS) + .withLinearRetry() + .setMaxAttempts(MAX_RETRY_ATTEMPTS)) + .build(); + + @Test + void frequentEventsDuringRetryWindowDoNotExhaustRetryCounter() { + RetryTestCustomResource resource = createTestCustomResource("frequent-events"); + var created = operator.create(resource); + + // Wait until the initial reconciliation has been executed and failed; the retry timer is now + // armed for RETRY_INTERVAL_MILLIS in the future, retry counter is at 1. + await() + .pollInterval(Duration.ofMillis(50)) + .atMost(5, TimeUnit.SECONDS) + .untilAsserted( + () -> assertThat(reconciler.getNumberOfExecutions()).isGreaterThanOrEqualTo(1)); + + // Trigger several updates spaced apart so each results in its own reconciliation cycle. Each + // failed reconciliation lands well inside the 1 minute retry window, so the retry counter + // must NOT advance — only the original retry deadline matters. + IntStream.rangeClosed(1, NUMBER_OF_UPDATES) + .forEach( + i -> { + log.debug("replacing resource, iteration: {}", i); + var latest = + operator.get(RetryTestCustomResource.class, created.getMetadata().getName()); + latest.getSpec().setValue("update-" + i); + operator.replace(latest); + int expectedExecutions = i + 1; + await() + .pollInterval(Duration.ofMillis(50)) + .atMost(5, TimeUnit.SECONDS) + .untilAsserted( + () -> + assertThat(reconciler.getNumberOfExecutions()) + .isGreaterThanOrEqualTo(expectedExecutions)); + }); + + // Reconciliations did happen for every event (so events are not lost) but the retry counter + // observed inside the reconciler never went past 1: the configured 1 minute interval is + // honored even under a steady stream of external events. + assertThat(reconciler.getNumberOfExecutions()).isGreaterThanOrEqualTo(NUMBER_OF_UPDATES + 1); + assertThat(reconciler.getMaxObservedRetryAttempt()).isEqualTo(1); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryTestCustomReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryTestCustomReconciler.java index 30a339fc4d..f981b9e1cb 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryTestCustomReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/retry/RetryTestCustomReconciler.java @@ -32,6 +32,7 @@ public class RetryTestCustomReconciler private static final Logger log = LoggerFactory.getLogger(RetryTestCustomReconciler.class); private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + private final AtomicInteger maxObservedRetryAttempt = new AtomicInteger(0); private final AtomicInteger numberOfExecutionFails; @@ -43,6 +44,12 @@ public RetryTestCustomReconciler(int numberOfExecutionFails) { public UpdateControl reconcile( RetryTestCustomResource resource, Context context) { numberOfExecutions.addAndGet(1); + context + .getRetryInfo() + .ifPresent( + info -> + maxObservedRetryAttempt.updateAndGet( + prev -> Math.max(prev, info.getAttemptCount()))); log.info("Value: " + resource.getSpec().getValue()); @@ -70,4 +77,8 @@ private void ensureStatusExists(RetryTestCustomResource resource) { public int getNumberOfExecutions() { return numberOfExecutions.get(); } + + public int getMaxObservedRetryAttempt() { + return maxObservedRetryAttempt.get(); + } } From 31392b914fe8710b4739cdb55d358de5fe22e1dc Mon Sep 17 00:00:00 2001 From: Martin Stefanko Date: Mon, 29 Jun 2026 17:36:27 +0200 Subject: [PATCH 06/15] feat: add defaultFilters option to skip JOSDK internal update filters (#3438) Signed-off-by: xstefank --- .../api/config/BaseConfigurationService.java | 5 +- .../api/config/ControllerConfiguration.java | 4 + .../ControllerConfigurationOverrider.java | 8 ++ .../ResolvedControllerConfiguration.java | 18 +++- .../reconciler/ControllerConfiguration.java | 11 +++ .../controller/ControllerEventSource.java | 22 +++-- .../controller/InternalEventFilters.java | 13 ++- .../controller/ControllerEventSourceTest.java | 56 +++++++++--- .../filter/WithoutDefaultFiltersIT.java | 86 +++++++++++++++++++ .../WithoutDefaultFiltersReconciler.java | 45 ++++++++++ .../WithoutDefaultFiltersUpdateFilter.java | 39 +++++++++ 11 files changed, 281 insertions(+), 26 deletions(-) create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/WithoutDefaultFiltersIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/WithoutDefaultFiltersReconciler.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/WithoutDefaultFiltersUpdateFilter.java diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/BaseConfigurationService.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/BaseConfigurationService.java index c27b13714e..93e296924b 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/BaseConfigurationService.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/BaseConfigurationService.java @@ -321,6 +321,8 @@ private

ResolvedControllerConfiguration

controllerCon var triggerReconcilerOnAllEvents = annotation != null && annotation.triggerReconcilerOnAllEvents(); + var defaultFilters = annotation == null || annotation.defaultFilters(); + InformerConfiguration

informerConfig = InformerConfiguration.builder(resourceClass) .initFromAnnotation(annotation != null ? annotation.informer() : null, context) @@ -341,7 +343,8 @@ private

ResolvedControllerConfiguration

controllerCon dependentFieldManager, this, informerConfig, - triggerReconcilerOnAllEvents); + triggerReconcilerOnAllEvents, + defaultFilters); } /** diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfiguration.java index 63177b614f..d3c4c60082 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfiguration.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfiguration.java @@ -121,4 +121,8 @@ default boolean triggerReconcilerOnAllEvent() { default boolean triggerReconcilerOnAllEvents() { return false; } + + default boolean isDefaultFilters() { + return true; + } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverrider.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverrider.java index 7856654f1e..555c2ec3d4 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverrider.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverrider.java @@ -46,6 +46,7 @@ public class ControllerConfigurationOverrider { private Map configurations; private final InformerConfiguration.Builder config; private boolean triggerReconcilerOnAllEvents; + private boolean defaultFilters; private ControllerConfigurationOverrider(ControllerConfiguration original) { this.finalizer = original.getFinalizerName(); @@ -59,6 +60,7 @@ private ControllerConfigurationOverrider(ControllerConfiguration original) { this.name = original.getName(); this.fieldManager = original.fieldManager(); this.triggerReconcilerOnAllEvents = original.triggerReconcilerOnAllEvents(); + this.defaultFilters = original.isDefaultFilters(); } public ControllerConfigurationOverrider withFinalizer(String finalizer) { @@ -186,6 +188,11 @@ public ControllerConfigurationOverrider withTriggerReconcilerOnAllEvents( return this; } + public ControllerConfigurationOverrider withDefaultFilters(boolean defaultFilters) { + this.defaultFilters = defaultFilters; + return this; + } + /** * Sets a max page size limit when starting the informer. This will result in pagination while * populating the cache. This means that longer lists will take multiple requests to fetch. See @@ -231,6 +238,7 @@ public ControllerConfiguration build() { original.getConfigurationService(), config.buildForController(), triggerReconcilerOnAllEvents, + defaultFilters, original.getWorkflowSpec().orElse(null)); } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ResolvedControllerConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ResolvedControllerConfiguration.java index 3e620f8f91..91cfaafa8f 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ResolvedControllerConfiguration.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ResolvedControllerConfiguration.java @@ -45,6 +45,7 @@ public class ResolvedControllerConfiguration

private final ConfigurationService configurationService; private final String fieldManager; private final boolean triggerReconcilerOnAllEvents; + private final boolean defaultFilters; private WorkflowSpec workflowSpec; public ResolvedControllerConfiguration(ControllerConfiguration

other) { @@ -61,6 +62,7 @@ public ResolvedControllerConfiguration(ControllerConfiguration

other) { other.getConfigurationService(), other.getInformerConfig(), other.triggerReconcilerOnAllEvents(), + other.isDefaultFilters(), other.getWorkflowSpec().orElse(null)); } @@ -77,6 +79,7 @@ public ResolvedControllerConfiguration( ConfigurationService configurationService, InformerConfiguration

informerConfig, boolean triggerReconcilerOnAllEvents, + boolean defaultFilters, WorkflowSpec workflowSpec) { this( name, @@ -90,7 +93,8 @@ public ResolvedControllerConfiguration( fieldManager, configurationService, informerConfig, - triggerReconcilerOnAllEvents); + triggerReconcilerOnAllEvents, + defaultFilters); setWorkflowSpec(workflowSpec); } @@ -106,7 +110,8 @@ protected ResolvedControllerConfiguration( String fieldManager, ConfigurationService configurationService, InformerConfiguration

informerConfig, - boolean triggerReconcilerOnAllEvents) { + boolean triggerReconcilerOnAllEvents, + boolean defaultFilters) { this.informerConfig = informerConfig; this.configurationService = configurationService; this.name = ControllerConfiguration.ensureValidName(name, associatedReconcilerClassName); @@ -120,6 +125,7 @@ protected ResolvedControllerConfiguration( ControllerConfiguration.ensureValidFinalizerName(finalizer, getResourceTypeName()); this.fieldManager = fieldManager; this.triggerReconcilerOnAllEvents = triggerReconcilerOnAllEvents; + this.defaultFilters = defaultFilters; } protected ResolvedControllerConfiguration( @@ -139,7 +145,8 @@ protected ResolvedControllerConfiguration( null, configurationService, InformerConfiguration.builder(resourceClass).buildForController(), - false); + false, + true); } @Override @@ -234,4 +241,9 @@ public String fieldManager() { public boolean triggerReconcilerOnAllEvents() { return triggerReconcilerOnAllEvents; } + + @Override + public boolean isDefaultFilters() { + return defaultFilters; + } } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ControllerConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ControllerConfiguration.java index d305c28824..70ae7435d1 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ControllerConfiguration.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/reconciler/ControllerConfiguration.java @@ -105,4 +105,15 @@ MaxReconciliationInterval maxReconciliationInterval() default * documentation for further details. */ boolean triggerReconcilerOnAllEvents() default false; + + /** + * When set to {@code false}, JOSDK will not apply its default internal update filters + * (generation- aware, finalizer-needed, marked-for-deletion) to the controller's event source. + * The user's {@link Informer#onUpdateFilter()} becomes the sole filter and has full control. To + * keep any of the default behavior, compose it explicitly using the static methods on {@link + * io.javaoperatorsdk.operator.processing.event.source.controller.InternalEventFilters}. + * + * @return whether JOSDK's internal update filters are applied + */ + boolean defaultFilters() default true; } 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 dfa94577f7..1f5f638144 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 @@ -52,18 +52,24 @@ public ControllerEventSource(Controller controller) { this.controller = controller; final var config = controller.getConfiguration(); - OnUpdateFilter internalOnUpdateFilter = - onUpdateFinalizerNeededAndApplied(controller.useFinalizer(), config.getFinalizerName()) - .or(onUpdateGenerationAware(config.isGenerationAware())) - .or(onUpdateMarkedForDeletion()); // by default the on add should be processed in all cases regarding internal filters final var informerConfig = config.getInformerConfig(); Optional.ofNullable(informerConfig.getOnAddFilter()).ifPresent(this::setOnAddFilter); - Optional.ofNullable(informerConfig.getOnUpdateFilter()) - .ifPresentOrElse( - filter -> setOnUpdateFilter(filter.and(internalOnUpdateFilter)), - () -> setOnUpdateFilter(internalOnUpdateFilter)); + + if (config.isDefaultFilters()) { + OnUpdateFilter internalOnUpdateFilter = + defaultFilters( + controller.useFinalizer(), config.getFinalizerName(), config.isGenerationAware()); + Optional.ofNullable(informerConfig.getOnUpdateFilter()) + .ifPresentOrElse( + filter -> setOnUpdateFilter(filter.and(internalOnUpdateFilter)), + () -> setOnUpdateFilter(internalOnUpdateFilter)); + } else { + var userFilter = informerConfig.getOnUpdateFilter(); + setOnUpdateFilter(userFilter != null ? userFilter : (newResource, oldResource) -> true); + } + Optional.ofNullable(informerConfig.getGenericFilter()).ifPresent(this::setGenericFilter); setControllerConfiguration(config); } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/InternalEventFilters.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/InternalEventFilters.java index 747f9f860c..20bea0106a 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/InternalEventFilters.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/controller/InternalEventFilters.java @@ -22,7 +22,7 @@ public class InternalEventFilters { private InternalEventFilters() {} - static OnUpdateFilter onUpdateMarkedForDeletion() { + public static OnUpdateFilter onUpdateMarkedForDeletion() { // the old resource is checked since in corner cases users might still want to update the status // for a resource that is marked for deletion @@ -30,7 +30,7 @@ static OnUpdateFilter onUpdateMarkedForDeletion() { !oldResource.isMarkedForDeletion() && newResource.isMarkedForDeletion(); } - static OnUpdateFilter onUpdateGenerationAware( + public static OnUpdateFilter onUpdateGenerationAware( boolean generationAware) { return (newResource, oldResource) -> { @@ -46,7 +46,7 @@ static OnUpdateFilter onUpdateGenerationAware( }; } - static OnUpdateFilter onUpdateFinalizerNeededAndApplied( + public static OnUpdateFilter onUpdateFinalizerNeededAndApplied( boolean useFinalizer, String finalizerName) { return (newResource, oldResource) -> { if (useFinalizer) { @@ -61,4 +61,11 @@ static OnUpdateFilter onUpdateFinalizerNeededAndAppli } }; } + + public static OnUpdateFilter defaultFilters( + boolean useFinalizer, String finalizerName, boolean generationAware) { + return InternalEventFilters.onUpdateFinalizerNeededAndApplied(useFinalizer, finalizerName) + .or(onUpdateGenerationAware(generationAware)) + .or(onUpdateMarkedForDeletion()); + } } 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 f8cb54f68e..71ad7314fe 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 @@ -140,13 +140,44 @@ void callsBroadcastsOnResourceEvents() { eq(ResourceAction.UPDATED), eq(customResource1), eq(customResource1)); } + @Test + void withoutDefaultFiltersUserFilterIsAppliedDirectly() { + TestCustomResource cr = TestUtils.testCustomResource(); + cr.getMetadata().setFinalizers(List.of(FINALIZER)); + cr.getMetadata().setGeneration(1L); + + // Without default filters, only the user filter runs — no internal generation/finalizer checks. + // User filter accepts unconditionally, so the event passes even with same generation. + OnUpdateFilter userFilter = (newRes, oldRes) -> true; + source = new ControllerEventSource<>(new TestController(null, userFilter, null, false)); + setUpSource(source, true, controllerConfig); + + source.handleEvent(ResourceAction.UPDATED, cr, cr, null); + + verify(eventHandler, times(1)).handleEvent(any()); + } + + @Test + void withoutDefaultFiltersUserFilterCanRejectEvents() { + TestCustomResource cr = TestUtils.testCustomResource(); + + OnUpdateFilter userFilter = (newRes, oldRes) -> false; + source = new ControllerEventSource<>(new TestController(null, userFilter, null, false)); + setUpSource(source, true, controllerConfig); + + source.handleEvent(ResourceAction.UPDATED, cr, cr, null); + + verify(eventHandler, never()).handleEvent(any()); + } + @Test void filtersOutEventsOnAddAndUpdate() { TestCustomResource cr = TestUtils.testCustomResource(); OnAddFilter onAddFilter = (res) -> false; OnUpdateFilter onUpdatePredicate = (res, res2) -> false; - source = new ControllerEventSource<>(new TestController(onAddFilter, onUpdatePredicate, null)); + source = + new ControllerEventSource<>(new TestController(onAddFilter, onUpdatePredicate, null, true)); setUpSource(source, true, controllerConfig); source.handleEvent(ResourceAction.ADDED, cr, null, null); @@ -159,7 +190,7 @@ void filtersOutEventsOnAddAndUpdate() { void genericFilterFiltersOutAddUpdateAndDeleteEvents() { TestCustomResource cr = TestUtils.testCustomResource(); - source = new ControllerEventSource<>(new TestController(null, null, res -> false)); + source = new ControllerEventSource<>(new TestController(null, null, res -> false, true)); setUpSource(source, true, controllerConfig); source.handleEvent(ResourceAction.ADDED, cr, null, null); @@ -174,7 +205,7 @@ void ownUpdateEchoIsFilteredOutByEventFilter() throws InterruptedException { // End-to-end smoke for the event-filter wiring on the controller path: an event for our // own write must not propagate. Detail-level filter scenarios are covered in // EventingDetailTest / EventFilterSupportTest. - source = spy(new ControllerEventSource<>(new TestController(null, null, null))); + source = spy(new ControllerEventSource<>(new TestController(null, null, null, true))); setUpSource(source, true, controllerConfig); doReturn(Optional.empty()).when(source).get(any()); @@ -189,7 +220,7 @@ void ownUpdateEchoIsFilteredOutByEventFilter() throws InterruptedException { @Test void foreignUpdateDuringFilteringPropagatesAsUpdate() { // An external event during the filter window must surface (not be filtered as own). - source = spy(new ControllerEventSource<>(new TestController(null, null, null))); + source = spy(new ControllerEventSource<>(new TestController(null, null, null, true))); setUpSource(source, true, controllerConfig); var latch = sendForEventFilteringUpdate(2); @@ -203,7 +234,7 @@ void foreignUpdateDuringFilteringPropagatesAsUpdate() { void deleteEventDuringFilteringPropagatesAsDelete() { // A DELETE arriving during the filter window must surface — the resource has gone, // so the filter must not silence it just because our own write is still tracking RVs. - source = spy(new ControllerEventSource<>(new TestController(null, null, null))); + source = spy(new ControllerEventSource<>(new TestController(null, null, null, true))); setUpSource(source, true, controllerConfig); var latch = sendForEventFilteringUpdate(2); @@ -223,7 +254,7 @@ void deleteEventDuringFilteringPropagatesAsDelete() { void multipleForeignEventsDuringFilteringMergeIntoSingleEvent() { // Several external events during one filter window collapse into a single // synthesized event spanning prev → latest seen. - source = spy(new ControllerEventSource<>(new TestController(null, null, null))); + source = spy(new ControllerEventSource<>(new TestController(null, null, null, true))); setUpSource(source, true, controllerConfig); var latch = sendForEventFilteringUpdate(2); @@ -266,17 +297,18 @@ private static class TestController extends Controller { public TestController( OnAddFilter onAddFilter, OnUpdateFilter onUpdateFilter, - GenericFilter genericFilter) { + GenericFilter genericFilter, + boolean defaultFilters) { super( reconciler, - new TestConfiguration(true, onAddFilter, onUpdateFilter, genericFilter), + new TestConfiguration(true, onAddFilter, onUpdateFilter, genericFilter, defaultFilters), MockKubernetesClient.client(TestCustomResource.class)); } public TestController(boolean generationAware) { super( reconciler, - new TestConfiguration(generationAware, null, null, null), + new TestConfiguration(generationAware, null, null, null, true), MockKubernetesClient.client(TestCustomResource.class)); } @@ -298,7 +330,8 @@ public TestConfiguration( boolean generationAware, OnAddFilter onAddFilter, OnUpdateFilter onUpdateFilter, - GenericFilter genericFilter) { + GenericFilter genericFilter, + boolean defaultFilters) { super( "test", generationAware, @@ -316,7 +349,8 @@ public TestConfiguration( .withGenericFilter(genericFilter) .withComparableResourceVersions(true) .buildForController(), - false); + false, + defaultFilters); } } } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/WithoutDefaultFiltersIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/WithoutDefaultFiltersIT.java new file mode 100644 index 0000000000..d305610f9b --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/WithoutDefaultFiltersIT.java @@ -0,0 +1,86 @@ +/* + * 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.filter; + +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.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class WithoutDefaultFiltersIT { + + public static final String RESOURCE_NAME = "without-default-filters-test1"; + public static final int POLL_DELAY = 150; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler(new WithoutDefaultFiltersReconciler()) + .build(); + + @Test + void userFilterFullyControlsUpdateEvents() { + var res = operator.create(createResource()); + + await() + .pollDelay(Duration.ofMillis(POLL_DELAY)) + .untilAsserted(() -> assertThat(reconciler().getNumberOfExecutions()).isEqualTo(1)); + + res = operator.get(FilterTestCustomResource.class, RESOURCE_NAME); + res.getSpec().setValue("updated"); + operator.replace(res); + + await() + .pollDelay(Duration.ofMillis(POLL_DELAY)) + .untilAsserted(() -> assertThat(reconciler().getNumberOfExecutions()).isEqualTo(2)); + + res = operator.get(FilterTestCustomResource.class, RESOURCE_NAME); + res.getMetadata() + .setAnnotations(Map.of(WithoutDefaultFiltersReconciler.TRIGGER_ANNOTATION, "true")); + operator.replace(res); + + await() + .pollDelay(Duration.ofMillis(POLL_DELAY)) + .untilAsserted(() -> assertThat(reconciler().getNumberOfExecutions()).isEqualTo(3)); + + res = operator.get(FilterTestCustomResource.class, RESOURCE_NAME); + res.getMetadata().getAnnotations().remove(WithoutDefaultFiltersReconciler.TRIGGER_ANNOTATION); + operator.replace(res); + + await() + .pollDelay(Duration.ofMillis(POLL_DELAY)) + .untilAsserted(() -> assertThat(reconciler().getNumberOfExecutions()).isEqualTo(3)); + } + + private WithoutDefaultFiltersReconciler reconciler() { + return operator.getReconcilerOfType(WithoutDefaultFiltersReconciler.class); + } + + FilterTestCustomResource createResource() { + var resource = new FilterTestCustomResource(); + resource.setMetadata(new ObjectMetaBuilder().withName(RESOURCE_NAME).build()); + resource.setSpec(new FilterTestResourceSpec()); + resource.getSpec().setValue("initial"); + return resource; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/WithoutDefaultFiltersReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/WithoutDefaultFiltersReconciler.java new file mode 100644 index 0000000000..a87e9feaa6 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/WithoutDefaultFiltersReconciler.java @@ -0,0 +1,45 @@ +/* + * 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.filter; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.config.informer.Informer; +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( + defaultFilters = false, + informer = @Informer(onUpdateFilter = WithoutDefaultFiltersUpdateFilter.class)) +public class WithoutDefaultFiltersReconciler implements Reconciler { + + public static final String TRIGGER_ANNOTATION = "trigger-without-default-filters"; + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + FilterTestCustomResource resource, Context context) { + numberOfExecutions.incrementAndGet(); + return UpdateControl.noUpdate(); + } + + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/WithoutDefaultFiltersUpdateFilter.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/WithoutDefaultFiltersUpdateFilter.java new file mode 100644 index 0000000000..8281689f5a --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/filter/WithoutDefaultFiltersUpdateFilter.java @@ -0,0 +1,39 @@ +/* + * 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.filter; + +import io.javaoperatorsdk.operator.processing.event.source.controller.InternalEventFilters; +import io.javaoperatorsdk.operator.processing.event.source.filter.OnUpdateFilter; + +public class WithoutDefaultFiltersUpdateFilter implements OnUpdateFilter { + + private final OnUpdateFilter composed = + InternalEventFilters.onUpdateGenerationAware(true) + .or( + (newResource, oldResource) -> { + var annotations = newResource.getMetadata().getAnnotations(); + return annotations != null + && "true" + .equals( + annotations.get(WithoutDefaultFiltersReconciler.TRIGGER_ANNOTATION)); + }); + + @Override + public boolean accept( + FilterTestCustomResource newResource, FilterTestCustomResource oldResource) { + return composed.accept(newResource, oldResource); + } +} From ccbebf6e1e7ee4e35bb3125e92d51d498cb7b430 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 30 Jun 2026 13:15:24 +0200 Subject: [PATCH 07/15] improve: using fabric8 re-list callback to cover event filtering edge cases (#3448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fabric8 client now supports callback onBeforeList in Informers, that allows to enhance edge case support for event filtering: Since on re-list some events could be lost, we will trigger the reconciliation in such scenarios. Signed-off-by: Attila Mészáros --- .../informer/ManagedInformerEventSource.java | 12 +-- .../informer/TemporaryResourceCache.java | 4 +- .../informer/EventFilterWindowTest.java | 20 ---- .../onrelistfilter/OnRelistFilterIT.java | 2 - .../OnRelistFilterReconciler.java | 94 +++++++++++++++---- .../OwnSecondaryUpdateIT.java | 2 - 6 files changed, 84 insertions(+), 50 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 a9c6818565..bb28e153d1 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 @@ -155,16 +155,14 @@ public synchronized void stop() { @Override public void onList(String resourceVersion, boolean remainedEmpty) { - // re-list supported by fabric8 client https://github.com/fabric8io/kubernetes-client/pull/7899 - // temporaryResourceCache.setRelistFinished(resourceVersion); + temporaryResourceCache.setRelistFinished(); temporaryResourceCache.checkGhostResources(); } - // @Override (enable when - // re-list supported by fabric8 client https://github.com/fabric8io/kubernetes-client/pull/7899 - // public void onBeforeList(String lastSyncResourceVersion) { - // temporaryResourceCache.setOngoingRelist(lastSyncResourceVersion); - // } + @Override + public void onBeforeList(String lastSyncResourceVersion) { + temporaryResourceCache.setOngoingRelist(); + } @Override public void handleRecentResourceUpdate( 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 8879493a2a..e7d6f6f55d 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 @@ -265,11 +265,11 @@ EventFilterSupport getEventFilterSupport() { return eventFilteringSupport; } - public void setOngoingRelist(String lastKnownSyncVersion) { + public void setOngoingRelist() { eventFilteringSupport.setStartingReList(); } - public void setRelistFinished(String syncResourceVersions) { + public void setRelistFinished() { eventFilteringSupport.setRelistFinished(); } } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterWindowTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterWindowTest.java index 70f6d1621c..367c4fa4f3 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterWindowTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterWindowTest.java @@ -15,7 +15,6 @@ */ package io.javaoperatorsdk.operator.processing.event.source.informer; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import io.fabric8.kubernetes.api.model.ConfigMap; @@ -385,25 +384,6 @@ void additionalEventAndDeleteEvent() { assertThat(eventFilterWindow.canBeRemoved()).isTrue(); } - @Test - @Disabled("should be part of event filter support") - void additionalEventAndDeleteEventNoUpdate() { - eventFilterWindow.increaseActiveUpdates(); - eventFilterWindow.addToOwnUpdateVersions(s(FIRST_OWN_VERSION)); - eventFilterWindow.addRelatedEvent(updateEvent(FIRST_OWN_VERSION)); - eventFilterWindow.addRelatedEvent(updateEvent(FIRST_OWN_VERSION + 1)); - eventFilterWindow.addRelatedEvent(deleteEvent(FIRST_OWN_VERSION + 2)); - - assertThat(eventFilterWindow.check()) - .hasValueSatisfying(e -> assertDeleteEvent(e, FIRST_OWN_VERSION + 2)); - assertThat(eventFilterWindow.check()).isEmpty(); - - assertEmptyState(); - eventFilterWindow.decreaseActiveUpdates(); - - assertThat(eventFilterWindow.canBeRemoved()).isTrue(); - } - @Test void deleteEventInMiddleTwoUpdates() { eventFilterWindow.increaseActiveUpdates(); diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterIT.java index df8d7c2591..8d2ff01cb7 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterIT.java @@ -17,7 +17,6 @@ import java.time.Duration; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -37,7 +36,6 @@ *

  • re-list starts WHILE the update window is open — own write is propagated * */ -@Disabled("enable when fabric8 supports relist") class OnRelistFilterIT { static final String RESOURCE_NAME = "test-resource"; diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java index 287141e4d1..13e8e72d74 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/onrelistfilter/OnRelistFilterReconciler.java @@ -15,8 +15,11 @@ */ package io.javaoperatorsdk.operator.baseapi.readcacheafterwrite.onrelistfilter; +import java.time.Duration; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; @@ -32,6 +35,7 @@ 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; @@ -78,7 +82,13 @@ public UpdateControl reconcile( case NO_RELIST -> context.resourceOperations().serverSideApply(cm, configMapEventSource); case RELIST_AROUND_UPDATE -> { configMapEventSource.simulateOnBeforeList(); - context.resourceOperations().serverSideApply(cm, configMapEventSource); + var applied = context.resourceOperations().serverSideApply(cm, configMapEventSource); + // Make the simulation deterministic: the own-write watch event arrives asynchronously, + // so we must wait for it to be received (and buffered into the still-open re-list + // window, where it is tagged as part of the re-list) BEFORE the re-list finishes. + // Otherwise onList may clear the window's re-list flag before the event lands and the + // event would be filtered as an own write — the race this test originally flaked on. + configMapEventSource.awaitWatchEventReceived(applied); configMapEventSource.simulateOnList(); } case RELIST_COMPLETES_BEFORE_UPDATE -> { @@ -90,20 +100,24 @@ public UpdateControl reconcile( // Drive the event-filtering update path manually so we can fire onBeforeList AFTER the // window has been opened by startEventFilteringModify but BEFORE the SSA hits the API. var fieldManager = context.getControllerConfiguration().fieldManager(); - configMapEventSource.eventFilteringUpdateAndCacheResource( - cm, - r -> { - configMapEventSource.simulateOnBeforeList(); - return context - .getClient() - .resource(r) - .patch( - new PatchContext.Builder() - .withForce(true) - .withFieldManager(fieldManager) - .withPatchType(PatchType.SERVER_SIDE_APPLY) - .build()); - }); + var applied = + configMapEventSource.eventFilteringUpdateAndCacheResource( + cm, + r -> { + configMapEventSource.simulateOnBeforeList(); + return context + .getClient() + .resource(r) + .patch( + new PatchContext.Builder() + .withForce(true) + .withFieldManager(fieldManager) + .withPatchType(PatchType.SERVER_SIDE_APPLY) + .build()); + }); + // See RELIST_AROUND_UPDATE: wait for the own-write event to be buffered while the + // re-list is still in progress, so it is tagged as part of the re-list and propagated. + configMapEventSource.awaitWatchEventReceived(applied); configMapEventSource.simulateOnList(); } } @@ -154,14 +168,60 @@ private static ConfigMap prepareConfigMap(OnRelistFilterCustomResource p) { static class RelistAwareInformerEventSource extends InformerEventSource { + // Highest resourceVersion the informer has actually delivered (as a watch event) per resource. + // Lets a test block until the event for its own write has been received and processed. + private final ConcurrentMap latestReceivedVersion = new ConcurrentHashMap<>(); + RelistAwareInformerEventSource( InformerEventSourceConfiguration configuration, EventSourceContext

    context) { super(configuration, context); } + @Override + public void onAdd(R newResource) { + super.onAdd(newResource); + recordReceived(newResource); + } + + @Override + public void onUpdate(R oldResource, R newResource) { + super.onUpdate(oldResource, newResource); + recordReceived(newResource); + } + + private void recordReceived(R resource) { + latestReceivedVersion.merge( + ResourceID.fromResource(resource), + Long.parseLong(resource.getMetadata().getResourceVersion()), + Math::max); + } + + /** + * Blocks until the informer has delivered a watch event for the given resource at a + * resourceVersion at least as recent as the one supplied (i.e. our own write has come back + * through the watch). Calling {@code super.onAdd/onUpdate} before recording guarantees the + * event is already buffered in the event-filter window by the time this returns. + */ + void awaitWatchEventReceived(R resource) { + var id = ResourceID.fromResource(resource); + var target = Long.parseLong(resource.getMetadata().getResourceVersion()); + var deadline = System.nanoTime() + Duration.ofSeconds(10).toNanos(); + while (latestReceivedVersion.getOrDefault(id, -1L) < target) { + if (System.nanoTime() > deadline) { + throw new IllegalStateException( + "Timed out waiting for watch event with rv>=" + target + " for " + id); + } + try { + Thread.sleep(20); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + } + void simulateOnBeforeList() { - // uncomment when fabric8 supports re-list - // onBeforeList(null); + onBeforeList(null); } void simulateOnList() { diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateIT.java index dfa5b899fe..eaa8f14c69 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/readcacheafterwrite/ownsecondaryupdate/OwnSecondaryUpdateIT.java @@ -17,7 +17,6 @@ import java.time.Duration; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -33,7 +32,6 @@ * the secondary are filtered and do NOT trigger additional reconciliations. Counterpart to {@code * ExternalSecondaryUpdateIT}, which asserts the opposite for third-party updates. */ -@Disabled("enable if re-list notification supported by fabric8 client") class OwnSecondaryUpdateIT { static final String RESOURCE_NAME = "test-resource"; From 694a5c9e82c89b467a4fedbf899f9e548760d707 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Tue, 30 Jun 2026 13:50:56 +0200 Subject: [PATCH 08/15] feat: shard selector support (#3429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../documentation/operations/configuration.md | 2 ++ .../ControllerConfigurationOverrider.java | 5 +++ .../api/config/informer/Informer.java | 12 +++++++ .../informer/InformerConfiguration.java | 35 +++++++++++++++---- .../InformerEventSourceConfiguration.java | 6 ++++ .../source/informer/InformerManager.java | 15 +++++--- .../operator/MockKubernetesClient.java | 1 + .../ControllerConfigurationOverriderTest.java | 14 ++++++++ .../api/config/InformerConfigurationTest.java | 13 +++++++ .../operator/config/loader/ConfigLoader.java | 6 ++++ .../config/loader/ConfigLoaderTest.java | 2 ++ 11 files changed, 101 insertions(+), 10 deletions(-) diff --git a/docs/content/en/docs/documentation/operations/configuration.md b/docs/content/en/docs/documentation/operations/configuration.md index cae4ef686e..513cc432d8 100644 --- a/docs/content/en/docs/documentation/operations/configuration.md +++ b/docs/content/en/docs/documentation/operations/configuration.md @@ -324,6 +324,7 @@ All controller-level keys are prefixed with `josdk.controller.. | `josdk.controller..finalizer` | `String` | Finalizer string added to managed resources | | `josdk.controller..generation-aware` | `Boolean` | Skip reconciliation when the resource generation has not changed | | `josdk.controller..label-selector` | `String` | Label selector to filter watched resources | +| `josdk.controller..shard-selector` | `String` | Shard selector to filter watched resources for sharding across operator replicas | | `josdk.controller..max-reconciliation-interval` | `Duration` | Maximum interval between reconciliations even without events | | `josdk.controller..field-manager` | `String` | Field manager name used for SSA operations | | `josdk.controller..trigger-reconciler-on-all-events` | `Boolean` | Trigger reconciliation on every event, not only meaningful changes | @@ -333,6 +334,7 @@ All controller-level keys are prefixed with `josdk.controller.. | Key | Type | Description | |---|---|---| | `josdk.controller..informer.label-selector` | `String` | Label selector for the primary resource informer (alias for `label-selector`) | +| `josdk.controller..informer.shard-selector` | `String` | Shard selector for the primary resource informer (alias for `shard-selector`) | | `josdk.controller..informer.list-limit` | `Long` | Page size for paginated informer list requests; omit for no pagination | #### Retry diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverrider.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverrider.java index 555c2ec3d4..1c1e03c870 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverrider.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverrider.java @@ -136,6 +136,11 @@ public ControllerConfigurationOverrider withLabelSelector(String labelSelecto return this; } + public ControllerConfigurationOverrider withShardSelector(String shardSelector) { + config.withShardSelector(shardSelector); + return this; + } + public ControllerConfigurationOverrider withReconciliationMaxInterval( Duration reconciliationMaxInterval) { this.reconciliationMaxInterval = reconciliationMaxInterval; diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/Informer.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/Informer.java index 7f0d266684..04f97902d3 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/Informer.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/Informer.java @@ -71,6 +71,18 @@ */ String labelSelector() default NO_VALUE_SET; + /** + * Optional shard selector used to restrict the set of resources the associated informer will act + * upon to a single shard, typically when the same workload is split across several operator + * instances. Just like {@link #labelSelector()} it is expressed as a label selector and can be + * made of multiple comma separated requirements that act as a logical AND operator. When both a + * label selector and a shard selector are set, the resulting informer only watches resources + * matching both (the two selectors are combined with a logical AND). + * + * @return the shard selector + */ + String shardSelector() default NO_VALUE_SET; + /** * Optional {@link OnAddFilter} to filter add events sent to the associated informer * diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerConfiguration.java index 20d7df7136..6c92dcdcc1 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerConfiguration.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerConfiguration.java @@ -47,6 +47,7 @@ public class InformerConfiguration { private Set namespaces; private Boolean followControllerNamespaceChanges; private String labelSelector; + private String shardSelector; private OnAddFilter onAddFilter; private OnUpdateFilter onUpdateFilter; private OnDeleteFilter onDeleteFilter; @@ -62,6 +63,7 @@ protected InformerConfiguration( Set namespaces, boolean followControllerNamespaceChanges, String labelSelector, + String shardSelector, OnAddFilter onAddFilter, OnUpdateFilter onUpdateFilter, OnDeleteFilter onDeleteFilter, @@ -77,6 +79,7 @@ protected InformerConfiguration( this.namespaces = namespaces; this.followControllerNamespaceChanges = followControllerNamespaceChanges; this.labelSelector = labelSelector; + this.shardSelector = shardSelector; this.onAddFilter = onAddFilter; this.onUpdateFilter = onUpdateFilter; this.onDeleteFilter = onDeleteFilter; @@ -113,6 +116,7 @@ public static InformerConfiguration.Builder builder( original.namespaces, original.followControllerNamespaceChanges, original.labelSelector, + original.shardSelector, original.onAddFilter, original.onUpdateFilter, original.onDeleteFilter, @@ -125,11 +129,6 @@ public static InformerConfiguration.Builder builder( .builder; } - public static String ensureValidLabelSelector(String labelSelector) { - // might want to implement validation here? - return labelSelector; - } - public static boolean allNamespacesWatched(Set namespaces) { failIfNotValid(namespaces); return DEFAULT_NAMESPACES_SET.equals(namespaces); @@ -251,6 +250,20 @@ public String getLabelSelector() { return labelSelector; } + /** + * Retrieves the shard selector that is used, in addition to the {@link #getLabelSelector() label + * selector}, to restrict which resources are actually watched by the associated informer. + * Typically used to assign a subset (shard) of the resources to a given operator instance. It is + * expressed using the same syntax as a label selector. See the official documentation on the topic for + * more details on syntax. + * + * @return the shard selector filtering watched resources + */ + public String getShardSelector() { + return shardSelector; + } + public OnAddFilter getOnAddFilter() { return onAddFilter; } @@ -353,6 +366,11 @@ public InformerConfiguration.Builder initFromAnnotation( var labelSelector = Constants.NO_VALUE_SET.equals(fromAnnotation) ? null : fromAnnotation; withLabelSelector(labelSelector); + final var shardFromAnnotation = informerConfig.shardSelector(); + var shardSelector = + Constants.NO_VALUE_SET.equals(shardFromAnnotation) ? null : shardFromAnnotation; + withShardSelector(shardSelector); + withOnAddFilter( Utils.instantiate(informerConfig.onAddFilter(), OnAddFilter.class, context)); @@ -442,7 +460,12 @@ public Builder withFollowControllerNamespacesChanges(boolean followChanges) { } public Builder withLabelSelector(String labelSelector) { - InformerConfiguration.this.labelSelector = ensureValidLabelSelector(labelSelector); + InformerConfiguration.this.labelSelector = labelSelector; + return this; + } + + public Builder withShardSelector(String shardSelector) { + InformerConfiguration.this.shardSelector = shardSelector; return this; } diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java index 1a1d8956fc..ab1ad2b8eb 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/api/config/informer/InformerEventSourceConfiguration.java @@ -251,6 +251,11 @@ public Builder withLabelSelector(String labelSelector) { return this; } + public Builder withShardSelector(String shardSelector) { + config.withShardSelector(shardSelector); + return this; + } + public Builder withOnAddFilter(OnAddFilter onAddFilter) { config.withOnAddFilter(onAddFilter); return this; @@ -308,6 +313,7 @@ public void updateFrom(InformerConfiguration informerConfig) { .withFollowControllerNamespacesChanges( informerConfig.getFollowControllerNamespaceChanges()) .withLabelSelector(informerConfig.getLabelSelector()) + .withShardSelector(informerConfig.getShardSelector()) .withItemStore(informerConfig.getItemStore()) .withOnAddFilter(informerConfig.getOnAddFilter()) .withOnUpdateFilter(informerConfig.getOnUpdateFilter()) diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerManager.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerManager.java index bfbe17c7c8..8e7054b231 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerManager.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/InformerManager.java @@ -136,13 +136,18 @@ public void changeNamespaces(Set namespaces) { private InformerWrapper createEventSourceForNamespace(String namespace) { final InformerWrapper source; final var labelSelector = configuration.getInformerConfig().getLabelSelector(); + final var shardSelector = configuration.getInformerConfig().getShardSelector(); if (namespace.equals(WATCH_ALL_NAMESPACES)) { - final var filteredBySelectorClient = client.inAnyNamespace().withLabelSelector(labelSelector); + final var filteredBySelectorClient = + client.inAnyNamespace().withLabelSelector(labelSelector).withShardSelector(shardSelector); source = createEventSource(filteredBySelectorClient, eventHandler, WATCH_ALL_NAMESPACES); } else { source = createEventSource( - client.inNamespace(namespace).withLabelSelector(labelSelector), + client + .inNamespace(namespace) + .withLabelSelector(labelSelector) + .withShardSelector(shardSelector), eventHandler, namespace); } @@ -275,12 +280,14 @@ public List byIndex(String indexName, String indexKey) { @Override public String toString() { final var informerConfig = configuration.getInformerConfig(); - final var selector = informerConfig.getLabelSelector(); + final var labelSelector = informerConfig.getLabelSelector(); + final var shardSelector = informerConfig.getShardSelector(); return "InformerManager [" + ReconcilerUtilsInternal.getResourceTypeNameWithVersion(configuration.getResourceClass()) + "] watching: " + informerConfig.getEffectiveNamespaces(controllerConfiguration) - + (selector != null ? " selector: " + selector : ""); + + (labelSelector != null ? " label selector: " + labelSelector : "") + + (shardSelector != null ? " shard selector: " + shardSelector : ""); } public Map informerHealthIndicators() { diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/MockKubernetesClient.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/MockKubernetesClient.java index 0000429c20..61b434c0c4 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/MockKubernetesClient.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/MockKubernetesClient.java @@ -84,6 +84,7 @@ public static KubernetesClient client( when(nonNamespaceOperation.withLabelSelector(nullable(String.class))).thenReturn(filterable); when(resources.inAnyNamespace()).thenReturn(inAnyNamespace); when(inAnyNamespace.withLabelSelector(nullable(String.class))).thenReturn(filterable); + when(filterable.withShardSelector(nullable(String.class))).thenReturn(filterable); SharedIndexInformer informer = mock(SharedIndexInformer.class); CompletableFuture informerStartRes = new CompletableFuture<>(); informerStartRes.complete(null); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverriderTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverriderTest.java index 06ea65803d..86b8de441b 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverriderTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/ControllerConfigurationOverriderTest.java @@ -181,6 +181,20 @@ void itemStorePreserved() { assertNotNull(configuration.getInformerConfig().getItemStore()); } + @Test + void shardSelectorShouldBePropagated() { + var configuration = createConfiguration(new WatchCurrentReconciler()); + assertNull(configuration.getInformerConfig().getShardSelector()); + + final var shardSelector = "shard=1"; + configuration = + ControllerConfigurationOverrider.override(configuration) + .withShardSelector(shardSelector) + .build(); + + assertEquals(shardSelector, configuration.getInformerConfig().getShardSelector()); + } + @Test void configuredDependentShouldNotChangeOnParentOverrideEvenWhenInitialConfigIsSame() { var configuration = createConfiguration(new OverriddenNSOnDepReconciler()); diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/InformerConfigurationTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/InformerConfigurationTest.java index 2631a1af82..95b8465706 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/InformerConfigurationTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/api/config/InformerConfigurationTest.java @@ -73,6 +73,19 @@ void nullLabelSelectorByDefault() { assertNull(informerConfig.getLabelSelector()); } + @Test + void nullShardSelectorByDefault() { + final var informerConfig = InformerConfiguration.builder(ConfigMap.class).build(); + assertNull(informerConfig.getShardSelector()); + } + + @Test + void shardSelectorIsSetOnBuilder() { + final var informerConfig = + InformerConfiguration.builder(ConfigMap.class).withShardSelector("shard=1").build(); + assertEquals("shard=1", informerConfig.getShardSelector()); + } + @Test void shouldWatchAllNamespacesByDefaultForControllers() { final var informerConfig = InformerConfiguration.builder(ConfigMap.class).buildForController(); diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java index d66b9139d4..a5b798190f 100644 --- a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java @@ -143,6 +143,8 @@ public static ConfigLoader getDefault() { ControllerConfigurationOverrider::withGenerationAware), new ConfigBinding<>( "label-selector", String.class, ControllerConfigurationOverrider::withLabelSelector), + new ConfigBinding<>( + "shard-selector", String.class, ControllerConfigurationOverrider::withShardSelector), new ConfigBinding<>( "max-reconciliation-interval", Duration.class, @@ -157,6 +159,10 @@ public static ConfigLoader getDefault() { "informer.label-selector", String.class, ControllerConfigurationOverrider::withLabelSelector), + new ConfigBinding<>( + "informer.shard-selector", + String.class, + ControllerConfigurationOverrider::withShardSelector), new ConfigBinding<>( "informer.list-limit", Long.class, diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java index 1144e1c4f3..fedaf81eb6 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java @@ -198,10 +198,12 @@ public Optional getValue(String key, Class type) { "josdk.controller.ctrl.finalizer", "josdk.controller.ctrl.generation-aware", "josdk.controller.ctrl.label-selector", + "josdk.controller.ctrl.shard-selector", "josdk.controller.ctrl.max-reconciliation-interval", "josdk.controller.ctrl.field-manager", "josdk.controller.ctrl.trigger-reconciler-on-all-events", "josdk.controller.ctrl.informer.label-selector", + "josdk.controller.ctrl.informer.shard-selector", "josdk.controller.ctrl.informer.list-limit", "josdk.controller.ctrl.rate-limiter.refresh-period", "josdk.controller.ctrl.rate-limiter.limit-for-period"); From b3f54e86414b0aea8508a4558753a446325e68e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 1 Jul 2026 10:57:02 +0200 Subject: [PATCH 09/15] improve: related resource reference change (#3425) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../content/en/docs/documentation/eventing.md | 8 ++ .../source/SecondaryToPrimaryMapper.java | 10 +- .../controller/ControllerEventSource.java | 10 +- .../DefaultPrimaryToSecondaryIndex.java | 31 ++++- .../source/informer/InformerEventSource.java | 49 +++++--- .../informer/ManagedInformerEventSource.java | 61 +++++++--- .../informer/NOOPPrimaryToSecondaryIndex.java | 7 +- .../informer/PrimaryToSecondaryIndex.java | 4 +- .../informer/TemporaryResourceCache.java | 3 +- .../controller/ControllerEventSourceTest.java | 37 +++--- .../informer/InformerEventSourceTest.java | 88 +++++++++++--- .../informer/PrimaryToSecondaryIndexTest.java | 90 ++++++++++++++- .../informer/TemporaryResourceCacheTest.java | 4 +- .../ConfigCustomResource.java | 33 ++++++ .../ConfigSpec.java | 48 ++++++++ .../ConfigToTargetMapper.java | 48 ++++++++ .../SecondaryToPrimaryReferenceChangeIT.java | 109 ++++++++++++++++++ .../TargetCustomResource.java | 35 ++++++ .../TargetReconciler.java | 78 +++++++++++++ .../TargetStatus.java | 30 +++++ 20 files changed, 694 insertions(+), 89 deletions(-) create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigCustomResource.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigSpec.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigToTargetMapper.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/SecondaryToPrimaryReferenceChangeIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetCustomResource.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetStatus.java diff --git a/docs/content/en/docs/documentation/eventing.md b/docs/content/en/docs/documentation/eventing.md index 06b8ccf9e9..d2a104737b 100644 --- a/docs/content/en/docs/documentation/eventing.md +++ b/docs/content/en/docs/documentation/eventing.md @@ -139,6 +139,14 @@ rare corner cases. Returning an empty set means that the mapper considered the s resource event as irrelevant and the SDK will thus not trigger a reconciliation of the primary resource in that situation. +On an update event, the SDK calls `toPrimaryResourceIDs` for **both the old and the new version** +of the secondary resource. This way it can reconcile not only the primaries that the secondary +currently maps to, but also those it previously mapped to and no longer does. So when a reference +changes — including when only a subset of the referenced primaries changes — both the newly +referenced and the dropped primaries are reconciled, and a dropped primary can revert to its +default state. Because the mapper can be invoked for an older version of a resource, keep your +implementation a pure function of the resource passed to it. + Adding a `SecondaryToPrimaryMapper` is typically sufficient when there is a one-to-many relationship between primary and secondary resources. The secondary resources can be mapped to its primary owner, and this is enough information to also get these secondary resources from the `Context` diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java index 0c6126105c..d1f79a7981 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/SecondaryToPrimaryMapper.java @@ -26,9 +26,15 @@ */ @FunctionalInterface public interface SecondaryToPrimaryMapper { + /** - * @param resource - secondary - * @return set of primary resource IDs + * Maps a secondary resource to the set of primary resources that should be reconciled in + * response. + * + * @param resource the secondary resource for which an event was received + * @return set of primary resource IDs to enqueue for reconciliation; an empty set means the event + * is irrelevant and no reconciliation is triggered. On update events, this method is invoked + * for both the old and the new versions of the resource. */ Set toPrimaryResourceIDs(R resource); } 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 1f5f638144..2f624d1150 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 @@ -86,7 +86,12 @@ public synchronized void start() { @Override protected synchronized void handleEvent( - ResourceAction action, T resource, T oldResource, Boolean deletedFinalStateUnknown) { + ResourceAction action, + T resource, + T oldResource, + Boolean deletedFinalStateUnknown, + // not relevant for controller event source + Set relatedPrimaryIDs) { try { if (log.isDebugEnabled()) { log.debug("Event received with action: {}", action); @@ -162,7 +167,8 @@ private void handleEvent(ExtendedResourceEvent r) { r.getAction(), (T) r.getResource().orElseThrow(), (T) r.getPreviousResource().orElse(null), - r.isLastStateUnknown()); + r.isLastStateUnknown(), + null); } @Override diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java index 2b4f3814b3..2ec8aa9372 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/DefaultPrimaryToSecondaryIndex.java @@ -32,18 +32,42 @@ public DefaultPrimaryToSecondaryIndex(SecondaryToPrimaryMapper secondaryToPri } @Override - public synchronized void onAddOrUpdate(R resource) { + public synchronized Set onAddOrUpdate(R resource, R oldResource) { + Set primaryResources = secondaryToPrimaryMapper.toPrimaryResourceIDs(resource); + + var secondaryId = ResourceID.fromResource(resource); + primaryResources.forEach( primaryResource -> { var resourceSet = index.computeIfAbsent(primaryResource, pr -> ConcurrentHashMap.newKeySet()); - resourceSet.add(ResourceID.fromResource(resource)); + resourceSet.add(secondaryId); }); + + if (oldResource != null) { + var obsoletePrimaries = + new HashSet<>(secondaryToPrimaryMapper.toPrimaryResourceIDs(oldResource)); + if (!primaryResources.containsAll(obsoletePrimaries)) { + var result = new HashSet<>(primaryResources); + obsoletePrimaries.removeAll(primaryResources); + obsoletePrimaries.forEach( + p -> + index.computeIfPresent( + p, + (id, currentSet) -> { + currentSet.remove(secondaryId); + return currentSet.isEmpty() ? null : currentSet; + })); + result.addAll(obsoletePrimaries); + return result; + } + } + return primaryResources; } @Override - public synchronized void onDelete(R resource) { + public synchronized Set onDelete(R resource) { Set primaryResources = secondaryToPrimaryMapper.toPrimaryResourceIDs(resource); primaryResources.forEach( primaryResource -> { @@ -58,6 +82,7 @@ public synchronized void onDelete(R resource) { } } }); + return primaryResources; } @Override 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 c425a4d413..b03a22e894 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 @@ -15,6 +15,7 @@ */ package io.javaoperatorsdk.operator.processing.event.source.informer; +import java.util.HashSet; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -127,17 +128,21 @@ public synchronized void onDelete(R resource, boolean deletedFinalStateUnknown) if (resultEvent.isEmpty()) { return; } - primaryToSecondaryIndex.onDelete(resource); + var primaryIds = primaryToSecondaryIndex.onDelete(resource); if (eventAcceptedByFilter( ResourceAction.DELETED, resource, null, deletedFinalStateUnknown)) { - propagateEvent(resource); + propagateEvent(resource, null, primaryIds); } }); } @Override protected void handleEvent( - ResourceAction action, R resource, R oldResource, Boolean deletedFinalStateUnknown) { + ResourceAction action, + R resource, + R oldResource, + Boolean deletedFinalStateUnknown, + Set relatedPrimaryIds) { // Called from ManagedInformerEventSource#eventFilteringUpdateAndCacheResource after the temp // cache decided to surface a (possibly synthesized) event. The user-level filters // (onAdd/onUpdate/onDelete/genericFilter) still apply, so this path mirrors the direct @@ -148,7 +153,7 @@ protected void handleEvent( log.debug( "handleEvent: removing from primaryToSecondaryIndex. id={}", ResourceID.fromResource(resource)); - primaryToSecondaryIndex.onDelete(resource); + relatedPrimaryIds = primaryToSecondaryIndex.onDelete(resource); } if (!eventAcceptedByFilter(action, resource, oldResource, deletedFinalStateUnknown)) { if (log.isDebugEnabled()) { @@ -166,7 +171,7 @@ protected void handleEvent( action, resource.getMetadata().getResourceVersion()); } - propagateEvent(resource); + propagateEvent(resource, oldResource, relatedPrimaryIds); } @Override @@ -177,12 +182,12 @@ public synchronized void start() { super.start(); // this makes sure that on first reconciliation all resources are // present on the index - manager().list().forEach(primaryToSecondaryIndex::onAddOrUpdate); + manager().list().forEach(r -> primaryToSecondaryIndex.onAddOrUpdate(r, null)); } @SuppressWarnings("unchecked") private synchronized void onAddOrUpdate(ResourceAction action, R newObject, R oldObject) { - primaryToSecondaryIndex.onAddOrUpdate(newObject); + var primaryIds = primaryToSecondaryIndex.onAddOrUpdate(newObject, oldObject); var resourceID = ResourceID.fromResource(newObject); var resultEvent = temporaryResourceCache.onAddOrUpdateEvent(action, newObject, oldObject); @@ -194,15 +199,22 @@ private synchronized void onAddOrUpdate(ResourceAction action, R newObject, R ol "Propagating event for {}, resource with same version not result of a our update.", action); var event = resultEvent.get(); - propagateEvent((R) event.getResource().orElseThrow()); + propagateEvent((R) event.getResource().orElseThrow(), oldObject, primaryIds); } else { log.debug("Event filtered out for operation: {}, resourceID: {}", action, resourceID); } } - protected void propagateEvent(R object) { - var primaryResourceIdSet = - configuration().getSecondaryToPrimaryMapper().toPrimaryResourceIDs(object); + protected void propagateEvent(R resource, R oldResource, Set primaryResourceIdSet) { + if (primaryResourceIdSet == null) { + primaryResourceIdSet = new HashSet<>(); + primaryResourceIdSet.addAll( + configuration().getSecondaryToPrimaryMapper().toPrimaryResourceIDs(resource)); + if (oldResource != null) { + primaryResourceIdSet.addAll( + configuration().getSecondaryToPrimaryMapper().toPrimaryResourceIDs(oldResource)); + } + } if (primaryResourceIdSet.isEmpty()) { return; } @@ -249,17 +261,24 @@ public Set getSecondaryResources(P primary) { @Override public void handleRecentResourceUpdate( ResourceID resourceID, R resource, R previousVersionOfResource) { - handleRecentCreateOrUpdate(resource); + handleRecentCreateOrUpdate(resource, previousVersionOfResource); } @Override public void handleRecentResourceCreate(ResourceID resourceID, R resource) { - handleRecentCreateOrUpdate(resource); + handleRecentCreateOrUpdate(resource, null); + } + + @Override + protected Set cacheUpdateAndGetRelatedPrimaryIDs( + R updatedResource, R previousResource) { + return handleRecentCreateOrUpdate(updatedResource, previousResource); } - private void handleRecentCreateOrUpdate(R newResource) { - primaryToSecondaryIndex.onAddOrUpdate(newResource); + private Set handleRecentCreateOrUpdate(R newResource, R previousVersion) { + var relatedPrimaryIds = primaryToSecondaryIndex.onAddOrUpdate(newResource, previousVersion); temporaryResourceCache.putResource(newResource); + return relatedPrimaryIds; } private boolean useSecondaryToPrimaryIndex() { 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 bb28e153d1..5a239a7377 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 @@ -15,6 +15,7 @@ */ package io.javaoperatorsdk.operator.processing.event.source.informer; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -97,10 +98,11 @@ public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator< ResourceID id = ResourceID.fromResource(resourceToUpdate); log.debug("Starting event filtering and caching update for id={}", id); R updatedResource = null; + Set relatedPrimaryIds = null; try { temporaryResourceCache.startEventFilteringModify(id); updatedResource = updateMethod.apply(resourceToUpdate); - handleRecentResourceUpdate(id, updatedResource, resourceToUpdate); + relatedPrimaryIds = cacheUpdateAndGetRelatedPrimaryIDs(updatedResource, resourceToUpdate); log.debug( "Caching resource update successful. id={}, rv={}", id, @@ -108,27 +110,36 @@ public R eventFilteringUpdateAndCacheResource(R resourceToUpdate, UnaryOperator< return updatedResource; } finally { var res = temporaryResourceCache.doneEventFilterModify(id); - res.ifPresentOrElse( - r -> { - log.debug( - "Propagating not own event after filtering update. id={}, action={}, rv={}", - id, - r.getAction(), - r.getResource() - .map(rr -> rr.getMetadata().getResourceVersion()) - .orElse("[not set]")); - handleEvent( - r.getAction(), - (R) r.getResource().orElseThrow(), - (R) r.getPreviousResource().orElse(null), - r.isLastStateUnknown()); - }, - () -> log.debug("No new event present after the filtering update. id={}", id)); + if (res.isPresent()) { + var event = res.orElseThrow(); + if (log.isDebugEnabled()) { + log.debug( + "Propagating not own event after filtering update. id={}, action={}, rv={}", + id, + event.getAction(), + event + .getResource() + .map(rr -> rr.getMetadata().getResourceVersion()) + .orElse("[not set]")); + } + handleEvent( + event.getAction(), + (R) event.getResource().orElseThrow(), + (R) event.getPreviousResource().orElse(null), + event.isLastStateUnknown(), + relatedPrimaryIds); + } else { + log.debug("No new event present after the filtering update. id={}", id); + } } } protected abstract void handleEvent( - ResourceAction action, R resource, R oldResource, Boolean deletedFinalStateUnknown); + ResourceAction action, + R resource, + R oldResource, + Boolean deletedFinalStateUnknown, + Set relatedPrimaryIDs); @SuppressWarnings("unchecked") @Override @@ -175,6 +186,20 @@ public void handleRecentResourceCreate(ResourceID resourceID, R resource) { temporaryResourceCache.putResource(resource); } + /** + * Caches the resource updated through {@link #eventFilteringUpdateAndCacheResource} and returns + * the primary resource IDs related to that update, so they can be propagated to {@link + * #handleEvent}. The base implementation just fills the temporary cache and reports no related + * primaries. Subclasses that maintain a primary-to-secondary index override this to surface the + * affected primaries even after the secondary's references have changed, keeping that concern + * internal to those event sources instead of leaking it into {@link RecentOperationCacheFiller}. + */ + protected Set cacheUpdateAndGetRelatedPrimaryIDs( + R updatedResource, R previousResource) { + handleRecentResourceUpdate(null, updatedResource, previousResource); + return Collections.emptySet(); + } + @Override public Optional get(ResourceID resourceID) { // The order of reading from these caches matters diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/NOOPPrimaryToSecondaryIndex.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/NOOPPrimaryToSecondaryIndex.java index ce217a5543..b22b958fc2 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/NOOPPrimaryToSecondaryIndex.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/NOOPPrimaryToSecondaryIndex.java @@ -33,13 +33,14 @@ public static NOOPPrimaryToSecondaryIndex getInstance private NOOPPrimaryToSecondaryIndex() {} @Override - public void onAddOrUpdate(R resource) { - // empty method because of noop implementation + public Set onAddOrUpdate(R resource, R oldResource) { + return null; } @Override - public void onDelete(R resource) { + public Set onDelete(R resource) { // empty method because of noop implementation + return null; } @Override diff --git a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndex.java b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndex.java index f88e481316..65fd692b25 100644 --- a/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndex.java +++ b/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndex.java @@ -22,9 +22,9 @@ public interface PrimaryToSecondaryIndex { - void onAddOrUpdate(R resource); + Set onAddOrUpdate(R resource, R oldResource); - void onDelete(R resource); + Set onDelete(R resource); Set getSecondaryResources(ResourceID primary); } 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 e7d6f6f55d..a557fd1fc5 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 @@ -244,7 +244,8 @@ public synchronized void checkGhostResources() { log.debug("Removing ghost resource with ID: {}", e.getKey()); iterator.remove(); eventFilteringSupport.handleGhostResourceRemoval(e.getKey()); - managedInformerEventSource.handleEvent(ResourceAction.DELETED, e.getValue(), null, true); + managedInformerEventSource.handleEvent( + ResourceAction.DELETED, e.getValue(), null, true, null); } } } 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 71ad7314fe..38190a96dc 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 @@ -75,10 +75,10 @@ void skipsEventHandlingIfGenerationNotIncreased() { TestCustomResource oldCustomResource = TestUtils.testCustomResource(); oldCustomResource.getMetadata().setFinalizers(List.of(FINALIZER)); - source.handleEvent(ResourceAction.UPDATED, customResource, oldCustomResource, null); + source.handleEvent(ResourceAction.UPDATED, customResource, oldCustomResource, null, null); verify(eventHandler, times(1)).handleEvent(any()); - source.handleEvent(ResourceAction.UPDATED, customResource, customResource, null); + source.handleEvent(ResourceAction.UPDATED, customResource, customResource, null, null); verify(eventHandler, times(1)).handleEvent(any()); } @@ -86,11 +86,11 @@ void skipsEventHandlingIfGenerationNotIncreased() { void dontSkipEventHandlingIfMarkedForDeletion() { TestCustomResource customResource1 = TestUtils.testCustomResource(); - source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null, null); verify(eventHandler, times(1)).handleEvent(any()); customResource1.getMetadata().setDeletionTimestamp(LocalDateTime.now().toString()); - source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null, null); verify(eventHandler, times(2)).handleEvent(any()); } @@ -98,11 +98,11 @@ void dontSkipEventHandlingIfMarkedForDeletion() { void normalExecutionIfGenerationChanges() { TestCustomResource customResource1 = TestUtils.testCustomResource(); - source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null, null); verify(eventHandler, times(1)).handleEvent(any()); customResource1.getMetadata().setGeneration(2L); - source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null, null); verify(eventHandler, times(2)).handleEvent(any()); } @@ -113,10 +113,10 @@ void handlesAllEventIfNotGenerationAware() { TestCustomResource customResource1 = TestUtils.testCustomResource(); - source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null, null); verify(eventHandler, times(1)).handleEvent(any()); - source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null, null); verify(eventHandler, times(2)).handleEvent(any()); } @@ -124,7 +124,7 @@ void handlesAllEventIfNotGenerationAware() { void eventWithNoGenerationProcessedIfNoFinalizer() { TestCustomResource customResource1 = TestUtils.testCustomResource(); - source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null, null); verify(eventHandler, times(1)).handleEvent(any()); } @@ -133,7 +133,7 @@ void eventWithNoGenerationProcessedIfNoFinalizer() { void callsBroadcastsOnResourceEvents() { TestCustomResource customResource1 = TestUtils.testCustomResource(); - source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null); + source.handleEvent(ResourceAction.UPDATED, customResource1, customResource1, null, null); verify(testController.getEventSourceManager(), times(1)) .broadcastOnResourceEvent( @@ -152,7 +152,7 @@ void withoutDefaultFiltersUserFilterIsAppliedDirectly() { source = new ControllerEventSource<>(new TestController(null, userFilter, null, false)); setUpSource(source, true, controllerConfig); - source.handleEvent(ResourceAction.UPDATED, cr, cr, null); + source.handleEvent(ResourceAction.UPDATED, cr, cr, null, null); verify(eventHandler, times(1)).handleEvent(any()); } @@ -165,7 +165,7 @@ void withoutDefaultFiltersUserFilterCanRejectEvents() { source = new ControllerEventSource<>(new TestController(null, userFilter, null, false)); setUpSource(source, true, controllerConfig); - source.handleEvent(ResourceAction.UPDATED, cr, cr, null); + source.handleEvent(ResourceAction.UPDATED, cr, cr, null, null); verify(eventHandler, never()).handleEvent(any()); } @@ -180,8 +180,8 @@ void filtersOutEventsOnAddAndUpdate() { new ControllerEventSource<>(new TestController(onAddFilter, onUpdatePredicate, null, true)); setUpSource(source, true, controllerConfig); - source.handleEvent(ResourceAction.ADDED, cr, null, null); - source.handleEvent(ResourceAction.UPDATED, cr, cr, null); + source.handleEvent(ResourceAction.ADDED, cr, null, null, null); + source.handleEvent(ResourceAction.UPDATED, cr, cr, null, null); verify(eventHandler, never()).handleEvent(any()); } @@ -193,9 +193,9 @@ void genericFilterFiltersOutAddUpdateAndDeleteEvents() { source = new ControllerEventSource<>(new TestController(null, null, res -> false, true)); setUpSource(source, true, controllerConfig); - source.handleEvent(ResourceAction.ADDED, cr, null, null); - source.handleEvent(ResourceAction.UPDATED, cr, cr, null); - source.handleEvent(ResourceAction.DELETED, cr, cr, true); + source.handleEvent(ResourceAction.ADDED, cr, null, null, null); + source.handleEvent(ResourceAction.UPDATED, cr, cr, null, null); + source.handleEvent(ResourceAction.DELETED, cr, cr, true, null); verify(eventHandler, never()).handleEvent(any()); } @@ -246,7 +246,7 @@ void deleteEventDuringFilteringPropagatesAsDelete() { () -> { verify(eventHandler, atLeastOnce()).handleEvent(any()); verify(source, atLeastOnce()) - .handleEvent(eq(ResourceAction.DELETED), any(), any(), any()); + .handleEvent(eq(ResourceAction.DELETED), any(), any(), any(), any()); }); } @@ -272,6 +272,7 @@ private void expectHandleEvent(int newResourceVersion, int oldResourceVersion) { eq(ResourceAction.UPDATED), argThat(r -> ("" + newResourceVersion).equals(r.getMetadata().getResourceVersion())), argThat(r -> ("" + oldResourceVersion).equals(r.getMetadata().getResourceVersion())), + any(), 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 dda08a7c98..c62a1d1a3a 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 @@ -44,6 +44,7 @@ import io.javaoperatorsdk.operator.processing.event.ResourceID; import io.javaoperatorsdk.operator.processing.event.source.Cache; import io.javaoperatorsdk.operator.processing.event.source.EventFilterTestUtils; +import io.javaoperatorsdk.operator.processing.event.source.PrimaryToSecondaryMapper; import io.javaoperatorsdk.operator.processing.event.source.ResourceAction; import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper; import io.javaoperatorsdk.operator.sample.simple.TestCustomResource; @@ -78,11 +79,12 @@ class InformerEventSourceTest { private final EventHandler eventHandlerMock = mock(EventHandler.class); private final InformerEventSourceConfiguration informerEventSourceConfiguration = mock(InformerEventSourceConfiguration.class); + private SecondaryToPrimaryMapper secondaryToPrimaryMapper; @BeforeEach void setup() { final var informerConfig = mock(InformerConfiguration.class); - SecondaryToPrimaryMapper secondaryToPrimaryMapper = mock(SecondaryToPrimaryMapper.class); + secondaryToPrimaryMapper = mock(SecondaryToPrimaryMapper.class); when(informerEventSourceConfiguration.getSecondaryToPrimaryMapper()) .thenReturn(secondaryToPrimaryMapper); when(secondaryToPrimaryMapper.toPrimaryResourceIDs(any())) @@ -93,7 +95,11 @@ void setup() { when(informerConfig.isComparableResourceVersions()).thenReturn(true); when(informerConfig.getEffectiveNamespaces(any())).thenReturn(DEFAULT_NAMESPACES_SET); - informerEventSource = + informerEventSource = buildInformerEventSource(); + } + + private InformerEventSource buildInformerEventSource() { + InformerEventSource eventSource = spy( new InformerEventSource<>(informerEventSourceConfiguration, clientMock) { // mocking start @@ -104,10 +110,11 @@ public synchronized void start() {} var mockControllerConfig = mock(ControllerConfiguration.class); when(mockControllerConfig.getConfigurationService()).thenReturn(new BaseConfigurationService()); - informerEventSource.setEventHandler(eventHandlerMock); - informerEventSource.setControllerConfiguration(mockControllerConfig); - informerEventSource.start(); - informerEventSource.setTemporalResourceCache(temporaryResourceCache); + eventSource.setEventHandler(eventHandlerMock); + eventSource.setControllerConfiguration(mockControllerConfig); + eventSource.start(); + eventSource.setTemporalResourceCache(temporaryResourceCache); + return eventSource; } @Test @@ -655,11 +662,13 @@ void handleEventUpdatesIndexWhenDeletePropagatesFromTempCache() throws Exception // and getSecondaryResources keeps returning a tombstone. var indexMock = injectIndexMock(); var resource = testDeployment(); + // onDelete now returns the primaries to reconcile; propagateEvent uses that set directly + when(indexMock.onDelete(resource)).thenReturn(Set.of(ResourceID.fromResource(resource))); - informerEventSource.handleEvent(ResourceAction.DELETED, resource, null, false); + informerEventSource.handleEvent(ResourceAction.DELETED, resource, null, false, null); verify(indexMock, times(1)).onDelete(resource); - verify(indexMock, never()).onAddOrUpdate(any()); + verify(indexMock, never()).onAddOrUpdate(any(), any()); verify(eventHandlerMock, times(1)).handleEvent(any()); } @@ -670,10 +679,10 @@ void handleEventDoesNotTouchIndexForNonDeleteAction() throws Exception { var indexMock = injectIndexMock(); informerEventSource.handleEvent( - ResourceAction.UPDATED, testDeployment(), testDeployment(), null); + ResourceAction.UPDATED, testDeployment(), testDeployment(), null, null); verify(indexMock, never()).onDelete(any()); - verify(indexMock, never()).onAddOrUpdate(any()); + verify(indexMock, never()).onAddOrUpdate(any(), any()); verify(eventHandlerMock, times(1)).handleEvent(any()); } @@ -686,7 +695,7 @@ void handleEventRespectsOnDeleteFilter() throws Exception { informerEventSource.setOnDeleteFilter((r, b) -> false); var resource = testDeployment(); - informerEventSource.handleEvent(ResourceAction.DELETED, resource, null, false); + informerEventSource.handleEvent(ResourceAction.DELETED, resource, null, false, null); verify(indexMock, times(1)).onDelete(resource); verify(eventHandlerMock, never()).handleEvent(any()); @@ -698,7 +707,7 @@ void handleEventRespectsOnUpdateFilter() throws Exception { informerEventSource.setOnUpdateFilter((n, o) -> false); informerEventSource.handleEvent( - ResourceAction.UPDATED, testDeployment(), testDeployment(), null); + ResourceAction.UPDATED, testDeployment(), testDeployment(), null, null); verify(indexMock, never()).onDelete(any()); verify(eventHandlerMock, never()).handleEvent(any()); @@ -709,7 +718,7 @@ void handleEventRespectsOnAddFilter() throws Exception { var indexMock = injectIndexMock(); informerEventSource.setOnAddFilter(r -> false); - informerEventSource.handleEvent(ResourceAction.ADDED, testDeployment(), null, null); + informerEventSource.handleEvent(ResourceAction.ADDED, testDeployment(), null, null, null); verify(indexMock, never()).onDelete(any()); verify(eventHandlerMock, never()).handleEvent(any()); @@ -724,14 +733,53 @@ void handleEventRespectsGenericFilter() throws Exception { informerEventSource.setGenericFilter(r -> false); var resource = testDeployment(); - informerEventSource.handleEvent(ResourceAction.DELETED, resource, null, true); - informerEventSource.handleEvent(ResourceAction.UPDATED, resource, resource, null); - informerEventSource.handleEvent(ResourceAction.ADDED, resource, null, null); + informerEventSource.handleEvent(ResourceAction.DELETED, resource, null, true, null); + informerEventSource.handleEvent(ResourceAction.UPDATED, resource, resource, null, null); + informerEventSource.handleEvent(ResourceAction.ADDED, resource, null, null, null); verify(indexMock, times(1)).onDelete(resource); verify(eventHandlerMock, never()).handleEvent(any()); } + @Test + void filteringUpdateMapsUpdatedResourceToPrimariesOnlyOnce() { + var resourceToUpdate = deploymentWithResourceVersion(2); + var updated = deploymentWithResourceVersion(3); + + when(temporaryResourceCache.doneEventFilterModify(any())) + .thenReturn( + Optional.of( + new ExtendedResourceEvent( + ResourceAction.UPDATED, updated, resourceToUpdate, false))); + + informerEventSource.eventFilteringUpdateAndCacheResource(resourceToUpdate, r -> updated); + + verify(secondaryToPrimaryMapper, times(1)).toPrimaryResourceIDs(updated); + verify(eventHandlerMock, times(1)).handleEvent(any()); + } + + @Test + void filteringUpdateFallsBackToMapperWhenNoPrimaryToSecondaryIndex() { + when(informerEventSourceConfiguration.getPrimaryToSecondaryMapper()) + .thenReturn(mock(PrimaryToSecondaryMapper.class)); + informerEventSource = buildInformerEventSource(); + + var resourceToUpdate = deploymentWithResourceVersion(2); + var updated = deploymentWithResourceVersion(3); + + when(temporaryResourceCache.doneEventFilterModify(any())) + .thenReturn( + Optional.of( + new ExtendedResourceEvent( + ResourceAction.UPDATED, updated, resourceToUpdate, false))); + + informerEventSource.eventFilteringUpdateAndCacheResource(resourceToUpdate, r -> updated); + + verify(secondaryToPrimaryMapper, times(1)).toPrimaryResourceIDs(updated); + verify(secondaryToPrimaryMapper, times(1)).toPrimaryResourceIDs(resourceToUpdate); + verify(eventHandlerMock, times(1)).handleEvent(any()); + } + private PrimaryToSecondaryIndex injectIndexMock() throws Exception { @SuppressWarnings("unchecked") PrimaryToSecondaryIndex indexMock = mock(PrimaryToSecondaryIndex.class); @@ -745,14 +793,17 @@ private void assertNoEventProduced() { await() .pollDelay(Duration.ofMillis(70)) .timeout(Duration.ofMillis(150)) - .untilAsserted(() -> verify(informerEventSource, never()).propagateEvent(any())); + .untilAsserted( + () -> verify(informerEventSource, never()).propagateEvent(any(), any(), any())); } private void expectPropagateEvent(Deployment newResourceVersion) { await() .atMost(Duration.ofSeconds(1)) .untilAsserted( - () -> verify(informerEventSource, times(1)).propagateEvent(newResourceVersion)); + () -> + verify(informerEventSource, times(1)) + .propagateEvent(eq(newResourceVersion), any(), any())); } private void expectHandleUpdateEvent(int newResourceVersion, int oldResourceVersion) { @@ -771,6 +822,7 @@ private void expectHandleUpdateEvent(int newResourceVersion, int oldResourceVers r -> ("" + oldResourceVersion) .equals(r.getMetadata().getResourceVersion())), + any(), any())); } diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndexTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndexTest.java index 91bca3708c..d298bce8bb 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndexTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/PrimaryToSecondaryIndexTest.java @@ -15,6 +15,7 @@ */ package io.javaoperatorsdk.operator.processing.event.source.informer; +import java.util.Map; import java.util.Set; import org.junit.jupiter.api.BeforeEach; @@ -27,6 +28,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -58,7 +60,7 @@ void returnsEmptySetOnEmptyIndex() { @Test void indexesNewResources() { - primaryToSecondaryIndex.onAddOrUpdate(secondary1); + primaryToSecondaryIndex.onAddOrUpdate(secondary1, null); var secondaryResources1 = primaryToSecondaryIndex.getSecondaryResources(primaryID1); var secondaryResources2 = primaryToSecondaryIndex.getSecondaryResources(primaryID2); @@ -69,8 +71,8 @@ void indexesNewResources() { @Test void indexesAdditionalResources() { - primaryToSecondaryIndex.onAddOrUpdate(secondary1); - primaryToSecondaryIndex.onAddOrUpdate(secondary2); + primaryToSecondaryIndex.onAddOrUpdate(secondary1, null); + primaryToSecondaryIndex.onAddOrUpdate(secondary2, null); var secondaryResources1 = primaryToSecondaryIndex.getSecondaryResources(primaryID1); var secondaryResources2 = primaryToSecondaryIndex.getSecondaryResources(primaryID2); @@ -83,8 +85,8 @@ void indexesAdditionalResources() { @Test void removingResourceFromIndex() { - primaryToSecondaryIndex.onAddOrUpdate(secondary1); - primaryToSecondaryIndex.onAddOrUpdate(secondary2); + primaryToSecondaryIndex.onAddOrUpdate(secondary1, null); + primaryToSecondaryIndex.onAddOrUpdate(secondary2, null); primaryToSecondaryIndex.onDelete(secondary1); var secondaryResources1 = primaryToSecondaryIndex.getSecondaryResources(primaryID1); @@ -102,6 +104,74 @@ void removingResourceFromIndex() { assertThat(secondaryResources2).isEmpty(); } + @Test + void updateRemovesObsoletePrimaryWhenReferenceNarrows() { + // initial version references both primaries (default stub) + primaryToSecondaryIndex.onAddOrUpdate(secondary1, null); + + // updated version references only primaryID1 + var updated = updatedVersionOf("secondary1"); + when(secondaryToPrimaryMapperMock.toPrimaryResourceIDs(eq(updated))) + .thenReturn(Set.of(primaryID1)); + primaryToSecondaryIndex.onAddOrUpdate(updated, secondary1); + + assertThat(primaryToSecondaryIndex.getSecondaryResources(primaryID1)) + .containsOnly(ResourceID.fromResource(secondary1)); + // primaryID2 is no longer referenced, so its (now empty) entry is removed + assertThat(primaryToSecondaryIndex.getSecondaryResources(primaryID2)).isEmpty(); + } + + @Test + void updateMovesSecondaryBetweenPrimaries() { + // initial version references only primaryID1 + when(secondaryToPrimaryMapperMock.toPrimaryResourceIDs(eq(secondary1))) + .thenReturn(Set.of(primaryID1)); + primaryToSecondaryIndex.onAddOrUpdate(secondary1, null); + + // updated version moves the reference to primaryID2 + var updated = updatedVersionOf("secondary1"); + when(secondaryToPrimaryMapperMock.toPrimaryResourceIDs(eq(updated))) + .thenReturn(Set.of(primaryID2)); + primaryToSecondaryIndex.onAddOrUpdate(updated, secondary1); + + assertThat(primaryToSecondaryIndex.getSecondaryResources(primaryID1)).isEmpty(); + assertThat(primaryToSecondaryIndex.getSecondaryResources(primaryID2)) + .containsOnly(ResourceID.fromResource(secondary1)); + } + + @Test + void updateOnlyRemovesUpdatedSecondaryFromObsoletePrimary() { + // two secondaries, each referencing both primaries (default stub) + primaryToSecondaryIndex.onAddOrUpdate(secondary1, null); + primaryToSecondaryIndex.onAddOrUpdate(secondary2, null); + + // secondary1 stops referencing primaryID2 + var updated = updatedVersionOf("secondary1"); + when(secondaryToPrimaryMapperMock.toPrimaryResourceIDs(eq(updated))) + .thenReturn(Set.of(primaryID1)); + primaryToSecondaryIndex.onAddOrUpdate(updated, secondary1); + + assertThat(primaryToSecondaryIndex.getSecondaryResources(primaryID1)) + .containsOnly(ResourceID.fromResource(secondary1), ResourceID.fromResource(secondary2)); + // primaryID2 is still referenced by secondary2, so only secondary1 is removed from it + assertThat(primaryToSecondaryIndex.getSecondaryResources(primaryID2)) + .containsOnly(ResourceID.fromResource(secondary2)); + } + + @Test + void updateKeepsIndexUnchangedWhenReferencedPrimariesDoNotChange() { + primaryToSecondaryIndex.onAddOrUpdate(secondary1, null); + + // updated version still references both primaries (default stub applies to it as well) + var updated = updatedVersionOf("secondary1"); + primaryToSecondaryIndex.onAddOrUpdate(updated, secondary1); + + assertThat(primaryToSecondaryIndex.getSecondaryResources(primaryID1)) + .containsOnly(ResourceID.fromResource(secondary1)); + assertThat(primaryToSecondaryIndex.getSecondaryResources(primaryID2)) + .containsOnly(ResourceID.fromResource(secondary1)); + } + ConfigMap secondary(String name) { ConfigMap configMap = new ConfigMap(); configMap.setMetadata(new ObjectMeta()); @@ -109,4 +179,14 @@ ConfigMap secondary(String name) { configMap.getMetadata().setNamespace("default"); return configMap; } + + /** + * Returns a new version of a secondary with the same {@link ResourceID} but a different content, + * so it represents an updated resource that the mapper mock can be stubbed for independently. + */ + ConfigMap updatedVersionOf(String name) { + ConfigMap configMap = secondary(name); + configMap.getMetadata().setLabels(Map.of("version", "updated")); + return configMap; + } } 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 0baef35e82..158e42180f 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 @@ -319,7 +319,7 @@ void removalOfGhostResources() { temporaryResourceCache.checkGhostResources(); assertThat(temporaryResourceCache.getResourceFromCache(ResourceID.fromResource(tr))).isEmpty(); verify(managedInformerEventSource, times(1)) - .handleEvent(eq(ResourceAction.DELETED), eq(tr), isNull(), eq(true)); + .handleEvent(eq(ResourceAction.DELETED), eq(tr), isNull(), eq(true), any()); } @Test @@ -349,7 +349,7 @@ void ghostRemovalRemovesResourcesOnNotFollowedNamespaces() { // no delete event should be fired for resources removed due to namespace change verify(managedInformerEventSource, times(0)) - .handleEvent(any(), any(), any(), any(Boolean.class)); + .handleEvent(any(), any(), any(), any(Boolean.class), any()); } @Test diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigCustomResource.java new file mode 100644 index 0000000000..f598017c4c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigCustomResource.java @@ -0,0 +1,33 @@ +/* + * 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.secondarytoprimaryreferencechange; + +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.Kind; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +/** + * Secondary resource that references one or more {@link TargetCustomResource}s via {@code + * spec.targetNames} and serves as input for them. + */ +@Group("sample.javaoperatorsdk") +@Version("v1") +@Kind("SecondaryToPrimaryRefConfig") +@ShortNames("s2pconfig") +public class ConfigCustomResource extends CustomResource implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigSpec.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigSpec.java new file mode 100644 index 0000000000..9d2e5139e6 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigSpec.java @@ -0,0 +1,48 @@ +/* + * 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.secondarytoprimaryreferencechange; + +import java.util.List; + +public class ConfigSpec { + + /** + * Names of the {@link TargetCustomResource}s (in the same namespace) this config provides input + * for. A single config can reference multiple targets. + */ + private List targetNames; + + /** Value to be applied to the referenced targets' status. */ + private String value; + + public List getTargetNames() { + return targetNames; + } + + public ConfigSpec setTargetNames(List targetNames) { + this.targetNames = targetNames; + return this; + } + + public String getValue() { + return value; + } + + public ConfigSpec setValue(String value) { + this.value = value; + return this; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigToTargetMapper.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigToTargetMapper.java new file mode 100644 index 0000000000..72930261de --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/ConfigToTargetMapper.java @@ -0,0 +1,48 @@ +/* + * 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.secondarytoprimaryreferencechange; + +import java.util.Set; +import java.util.stream.Collectors; + +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper; + +/** + * Maps a {@link ConfigCustomResource} (secondary) to the {@link TargetCustomResource}s (primaries) + * it references via {@code spec.targetNames}. A config can reference multiple targets. + * + *

    The mapper only reports the current references. When the referenced set changes — for + * example when a subset of the targets is replaced — the framework's primary-to-secondary index + * reconciles both the newly referenced targets and the ones that are no longer referenced, so a + * dropped target reverts to its default value. The mapper therefore does not need to know about the + * previous version of the resource. + */ +public class ConfigToTargetMapper implements SecondaryToPrimaryMapper { + + @Override + public Set toPrimaryResourceIDs(ConfigCustomResource config) { + var targetNames = config.getSpec().getTargetNames(); + if (targetNames == null || targetNames.isEmpty()) { + return Set.of(); + } + var namespace = config.getMetadata().getNamespace(); + return targetNames.stream() + .filter(name -> name != null && !name.isBlank()) + .map(name -> new ResourceID(name, namespace)) + .collect(Collectors.toSet()); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/SecondaryToPrimaryReferenceChangeIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/SecondaryToPrimaryReferenceChangeIT.java new file mode 100644 index 0000000000..37ade3a174 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/SecondaryToPrimaryReferenceChangeIT.java @@ -0,0 +1,109 @@ +/* + * 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.secondarytoprimaryreferencechange; + +import java.time.Duration; +import java.util.List; + +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.secondarytoprimaryreferencechange.TargetReconciler.DEFAULT_VALUE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@Sample( + tldr = "Handling a Secondary Resource Whose References to Primaries Change", + description = + """ + Demonstrates a configuration custom resource (the secondary) that references multiple \ + target custom resources (the primaries) via a spec field and serves as their input. Each \ + target is reconciled so that, if a config references it, it takes the value from that \ + config; otherwise it falls back to a default. The test shows how to handle a change of the \ + referenced set where only a subset changes: a target that is dropped from the references \ + reverts to the default, a target that stays keeps the value, and a newly referenced target \ + picks it up. + """) +class SecondaryToPrimaryReferenceChangeIT { + + static final String TARGET_A = "target-a"; + static final String TARGET_B = "target-b"; + static final String TARGET_C = "target-c"; + static final String CONFIG_NAME = "config"; + static final String CONFIG_VALUE = "value-from-config"; + + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withAdditionalCustomResourceDefinition(ConfigCustomResource.class) + .withReconciler(new TargetReconciler()) + .build(); + + @Test + void targetsTakeValueFromReferencingConfigAndHandleSubsetReferenceChange() { + operator.create(target(TARGET_A)); + operator.create(target(TARGET_B)); + operator.create(target(TARGET_C)); + + // With no config, all targets fall back to the default value. + awaitTargetValue(TARGET_A, DEFAULT_VALUE); + awaitTargetValue(TARGET_B, DEFAULT_VALUE); + awaitTargetValue(TARGET_C, DEFAULT_VALUE); + + // A config referencing targets A and B makes both take the config's value; C stays default. + var config = operator.create(config(TARGET_A, TARGET_B)); + awaitTargetValue(TARGET_A, CONFIG_VALUE); + awaitTargetValue(TARGET_B, CONFIG_VALUE); + awaitTargetValue(TARGET_C, DEFAULT_VALUE); + + // Change a subset of the references: drop A, keep B, add C. A reverts to the default, B keeps + // the value, and C now picks it up. + config.getSpec().setTargetNames(List.of(TARGET_B, TARGET_C)); + operator.replace(config); + + awaitTargetValue(TARGET_C, CONFIG_VALUE); + awaitTargetValue(TARGET_A, DEFAULT_VALUE); + awaitTargetValue(TARGET_B, CONFIG_VALUE); + } + + private void awaitTargetValue(String name, String expectedValue) { + await() + .atMost(Duration.ofSeconds(10)) + .untilAsserted( + () -> { + var target = operator.get(TargetCustomResource.class, name); + assertThat(target.getStatus()).isNotNull(); + assertThat(target.getStatus().getValue()).isEqualTo(expectedValue); + }); + } + + private TargetCustomResource target(String name) { + var target = new TargetCustomResource(); + target.setMetadata(new ObjectMetaBuilder().withName(name).build()); + return target; + } + + private ConfigCustomResource config(String... targetNames) { + var config = new ConfigCustomResource(); + config.setMetadata(new ObjectMetaBuilder().withName(CONFIG_NAME).build()); + config.setSpec(new ConfigSpec().setTargetNames(List.of(targetNames)).setValue(CONFIG_VALUE)); + return config; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetCustomResource.java new file mode 100644 index 0000000000..b3bcae6ccb --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetCustomResource.java @@ -0,0 +1,35 @@ +/* + * 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.secondarytoprimaryreferencechange; + +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.Kind; +import io.fabric8.kubernetes.model.annotation.ShortNames; +import io.fabric8.kubernetes.model.annotation.Version; + +/** + * Primary resource that is reconciled. Its desired status value is provided by a {@link + * ConfigCustomResource} that references it (see {@link TargetReconciler}); when no config + * references it, a default value is used. + */ +@Group("sample.javaoperatorsdk") +@Version("v1") +@Kind("SecondaryToPrimaryRefTarget") +@ShortNames("s2ptarget") +public class TargetCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java new file mode 100644 index 0000000000..ee8d11e9d4 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetReconciler.java @@ -0,0 +1,78 @@ +/* + * 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.secondarytoprimaryreferencechange; + +import java.util.List; + +import io.javaoperatorsdk.annotation.Sample; +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; + +@Sample( + tldr = "Reconciling Primaries Driven by a Referencing Secondary Custom Resource", + description = + """ + A configuration custom resource (the secondary) references one or more target custom \ + resources (the primaries) through a spec field and acts as their input. This reconciler \ + watches those config resources with an InformerEventSource and, on each reconciliation, \ + sets the target's value from the config that currently references it, falling back to a \ + default when none does. When a config's set of references changes — including when only a \ + subset of the referenced targets is replaced — the framework's primary-to-secondary index \ + reconciles both the newly referenced targets and the ones that are no longer referenced, so \ + a dropped target reverts to its default. + """) +@ControllerConfiguration +public class TargetReconciler implements Reconciler { + + public static final String DEFAULT_VALUE = "default"; + + @Override + public List> prepareEventSources( + EventSourceContext context) { + + var configuration = + InformerEventSourceConfiguration.from( + ConfigCustomResource.class, TargetCustomResource.class) + .withSecondaryToPrimaryMapper(new ConfigToTargetMapper()) + .build(); + + var ies = new InformerEventSource<>(configuration, context); + return List.of(ies); + } + + @Override + public UpdateControl reconcile( + TargetCustomResource target, Context context) { + + // The framework keeps the primary-to-secondary index up to date on reference changes, so a + // config is only associated with the target it currently references. We take the value from + // the referencing config, or fall back to the default when none references this target. + var value = + context + .getSecondaryResource(ConfigCustomResource.class) + .map(config -> config.getSpec().getValue()) + .orElse(DEFAULT_VALUE); + + target.setStatus(new TargetStatus().setValue(value)); + return UpdateControl.patchStatus(target); + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetStatus.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetStatus.java new file mode 100644 index 0000000000..3679813070 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/secondarytoprimaryreferencechange/TargetStatus.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.secondarytoprimaryreferencechange; + +public class TargetStatus { + + private String value; + + public String getValue() { + return value; + } + + public TargetStatus setValue(String value) { + this.value = value; + return this; + } +} From a1919fe17d4516db633df767718eba74bb793ac6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 1 Jul 2026 12:24:26 +0200 Subject: [PATCH 10/15] chore: bump fabric8 client to 7.8.0 (#3457) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index a34f080692..9e2dba2220 100644 --- a/pom.xml +++ b/pom.xml @@ -71,7 +71,7 @@ https://sonarcloud.io jdk 6.1.1 - 7.7.0 + 7.8.0 2.0.18 2.26.0 5.23.0 From c209dfd6b547ce41e6aec9b99d7562da207a6e1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 1 Jul 2026 15:27:59 +0200 Subject: [PATCH 11/15] improve: add sharding sample as integration test (#3460) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds sample integration test for sharding. Signed-off-by: Attila Mészáros --- .../shardselector/ShardSelectorIT.java | 123 ++++++++++++++++++ .../ShardSelectorTestCustomResource.java | 28 ++++ .../ShardSelectorTestReconciler.java | 46 +++++++ 3 files changed, 197 insertions(+) create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/shardselector/ShardSelectorIT.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/shardselector/ShardSelectorTestCustomResource.java create mode 100644 operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/shardselector/ShardSelectorTestReconciler.java diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/shardselector/ShardSelectorIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/shardselector/ShardSelectorIT.java new file mode 100644 index 0000000000..35d9f9fc2c --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/shardselector/ShardSelectorIT.java @@ -0,0 +1,123 @@ +/* + * 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.shardselector; + +import java.time.Duration; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.fabric8.kubeapitest.junit.EnableKubeAPIServer; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; +import io.javaoperatorsdk.annotation.Sample; +import io.javaoperatorsdk.operator.Operator; +import io.javaoperatorsdk.operator.junit.LocallyRunOperatorExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@Sample( + tldr = "Shard Selector for Splitting Resources Across Operator Instances", + description = + """ + Demonstrates how to shard custom resources across multiple operator instances using shard \ + selectors. Two operators watch the same resource type, each configured with a shard \ + selector covering one evenly split half of the UID hash space. The test verifies that a \ + given custom resource is reconciled by exactly one instance, never by both and never by \ + neither. Sharding relies on the Kubernetes API server 'ShardedListAndWatch' alpha feature. + """) +@EnableKubeAPIServer( + kubeAPIVersion = "1.36.*", + apiServerFlags = {"--feature-gates=ShardedListAndWatch=true"}, + updateKubeConfigFile = true) +class ShardSelectorIT { + + // The two selectors split the 64-bit UID hash space in half: [0x0, 0x8000000000000000) and + // [0x8000000000000000, 0x10000000000000000). Together they cover the whole space with no overlap, + // so every resource is owned by exactly one shard. + private static final String SHARD1 = + "shardRange(object.metadata.uid, '0x0000000000000000', '0x8000000000000000')"; + private static final String SHARD2 = + "shardRange(object.metadata.uid, '0x8000000000000000', '0x10000000000000000')"; + + private static final String TEST_RESOURCE_NAME = "shard-test1"; + private static final Duration EVENT_SETTLE_WINDOW = Duration.ofMillis(500); + + private final KubernetesClient adminClient = new KubernetesClientBuilder().build(); + + private Operator operator1; + private Operator operator2; + private ShardSelectorTestReconciler reconciler1; + private ShardSelectorTestReconciler reconciler2; + + @BeforeEach + void beforeEach() { + LocallyRunOperatorExtension.applyCrd(ShardSelectorTestCustomResource.class, adminClient); + reconciler1 = startOperatorForShard(SHARD1); + reconciler2 = startOperatorForShard(SHARD2); + } + + @AfterEach + void cleanup() { + if (operator1 != null) { + operator1.stop(); + } + if (operator2 != null) { + operator2.stop(); + } + adminClient.resource(testCustomResource()).delete(); + } + + @Test + void onlyOneShardReconcilesTheResource() { + adminClient.resource(testCustomResource()).create(); + + // The condition must hold for the whole settle window: exactly one shard ever reconciles the + // resource, so the other shard has no chance to (incorrectly) pick it up later. + await() + .atMost(Duration.ofSeconds(30)) + .during(EVENT_SETTLE_WINDOW) + .untilAsserted( + () -> { + int executions1 = reconciler1.getNumberOfExecutions(); + int executions2 = reconciler2.getNumberOfExecutions(); + // exactly one shard owns the resource + assertThat((executions1 == 0) ^ (executions2 == 0)).isTrue(); + }); + } + + private ShardSelectorTestReconciler startOperatorForShard(String shardSelector) { + var reconciler = new ShardSelectorTestReconciler(); + var operator = new Operator(o -> o.withKubernetesClient(new KubernetesClientBuilder().build())); + operator.register(reconciler, o -> o.withShardSelector(shardSelector)); + operator.start(); + if (operator1 == null) { + operator1 = operator; + } else { + operator2 = operator; + } + return reconciler; + } + + private ShardSelectorTestCustomResource testCustomResource() { + var resource = new ShardSelectorTestCustomResource(); + resource.setMetadata(new ObjectMetaBuilder().withName(TEST_RESOURCE_NAME).build()); + return resource; + } +} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/shardselector/ShardSelectorTestCustomResource.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/shardselector/ShardSelectorTestCustomResource.java new file mode 100644 index 0000000000..c2d1fe4449 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/shardselector/ShardSelectorTestCustomResource.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.shardselector; + +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("sst") +public class ShardSelectorTestCustomResource extends CustomResource + implements Namespaced {} diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/shardselector/ShardSelectorTestReconciler.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/shardselector/ShardSelectorTestReconciler.java new file mode 100644 index 0000000000..a98d6faec4 --- /dev/null +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/shardselector/ShardSelectorTestReconciler.java @@ -0,0 +1,46 @@ +/* + * 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.shardselector; + +import java.util.concurrent.atomic.AtomicInteger; + +import io.javaoperatorsdk.operator.api.reconciler.*; +import io.javaoperatorsdk.operator.support.TestExecutionInfoProvider; + +/** + * The shard selector is intentionally not set through the {@link + * io.javaoperatorsdk.operator.api.config.informer.Informer} annotation: the test registers the same + * reconciler on two operator instances and overrides the shard selector per instance so that the + * two shards split the resources evenly. + */ +@ControllerConfiguration +public class ShardSelectorTestReconciler + implements Reconciler, TestExecutionInfoProvider { + + private final AtomicInteger numberOfExecutions = new AtomicInteger(0); + + @Override + public UpdateControl reconcile( + ShardSelectorTestCustomResource resource, Context context) { + numberOfExecutions.addAndGet(1); + return UpdateControl.noUpdate(); + } + + @Override + public int getNumberOfExecutions() { + return numberOfExecutions.get(); + } +} From ee17085110937e56ec1a435d1e58755692914ade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 1 Jul 2026 16:14:42 +0200 Subject: [PATCH 12/15] improve: additional tests for event filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../informer/EventFilterSupportTest.java | 70 ++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterSupportTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterSupportTest.java index 239794e1b1..53208fc241 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterSupportTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterSupportTest.java @@ -34,6 +34,12 @@ class EventFilterSupportTest { EventFilterSupport support = new EventFilterSupport(); + @Test + void trivialCaseOnlyEvent() { + assertThat(support.processEvent(RESOURCE_ID, updateEvent(FIRST_OWN_VERSION))) + .hasValueSatisfying(e -> assertThat(e.getAction()).isEqualTo(UPDATED)); + } + @Test void startEventFilteringCreatesEventingDetail() { support.startEventFilteringModify(RESOURCE_ID); @@ -586,7 +592,69 @@ void ghostRemovalDuringReListAffectsOnlyTargetResource() { assertThat(support.isActiveUpdateFor(OTHER_RESOURCE_ID)).isTrue(); } - // -------- end of replicated tests -------- + @Test + void deleteWhileUpdateActiveButNoOwnVersionRecorded() { + // Update in flight (active > 0) but our own resourceVersion hasn't been recorded yet. + // The delete short-circuit (EventFilterWindow#doCheck, ownUpdateVersions empty && first + // is DELETED) emits the delete immediately while the window stays active. + support.startEventFilteringModify(RESOURCE_ID); + + assertThat(support.processEvent(RESOURCE_ID, deleteEvent(FIRST_OWN_VERSION))) + .hasValueSatisfying(e -> assertThat(e.getAction()).isEqualTo(DELETED)); + assertThat(support.isActiveUpdateFor(RESOURCE_ID)).isTrue(); + + assertThat(support.doneEventFilterModify(RESOURCE_ID)).isEmpty(); + assertThat(support.isActiveUpdateFor(RESOURCE_ID)).isFalse(); + } + + @Test + void synthEventBuiltFromLeadingAddEvent() { + // Two buffered foreign events with no own version recorded; the first is an ADD (no previous + // resource). On done the synth event's "before" side falls back to the ADD's own resource + // (EventFilterWindow synth construction: getPreviousResource().isEmpty() branch). + support.startEventFilteringModify(RESOURCE_ID); + + assertThat(support.processEvent(RESOURCE_ID, addEvent(FIRST_OWN_VERSION))).isEmpty(); + assertThat(support.processEvent(RESOURCE_ID, updateEvent(FIRST_OWN_VERSION + 2))).isEmpty(); + + assertThat(support.doneEventFilterModify(RESOURCE_ID)) + .hasValueSatisfying(e -> assertThat(e.getAction()).isEqualTo(UPDATED)); + assertThat(support.isActiveUpdateFor(RESOURCE_ID)).isFalse(); + } + + @Test + void leadingDeleteRemovedThenSynthEmitted() { + // A leading DELETE followed by foreign, non-own updates: the leading delete is dropped + // (EventFilterWindow#eventForRangeAndClear, size > 1 && first is DELETED) and the remaining + // range still differs from the own versions, so a synth UPDATED event is produced. + support.startEventFilteringModify(RESOURCE_ID); + support.addToOwnResourceVersions(RESOURCE_ID, s(FIRST_OWN_VERSION + 2)); + + assertThat(support.processEvent(RESOURCE_ID, deleteEvent(FIRST_OWN_VERSION - 1))).isEmpty(); + assertThat(support.processEvent(RESOURCE_ID, updateEvent(FIRST_OWN_VERSION))).isEmpty(); + assertThat(support.processEvent(RESOURCE_ID, updateEvent(FIRST_OWN_VERSION + 2))) + .hasValueSatisfying(e -> assertThat(e.getAction()).isEqualTo(UPDATED)); + + assertThat(support.doneEventFilterModify(RESOURCE_ID)).isEmpty(); + assertThat(support.isActiveUpdateFor(RESOURCE_ID)).isFalse(); + } + + @Test + void ghostRemovalOnLingeringWindowAfterDone() { + // After done the window lingers because an own version was recorded but its echo hasn't + // arrived (active = 0, own non-empty). A ghost removal in this state still drops the window. + support.startEventFilteringModify(RESOURCE_ID); + support.addToOwnResourceVersions(RESOURCE_ID, s(FIRST_OWN_VERSION)); + assertThat(support.doneEventFilterModify(RESOURCE_ID)).isEmpty(); + assertThat(support.isActiveUpdateFor(RESOURCE_ID)).isTrue(); + + support.handleGhostResourceRemoval(RESOURCE_ID); + + assertThat(support.isActiveUpdateFor(RESOURCE_ID)).isFalse(); + // No window left → a subsequent event is propagated verbatim. + var follow = updateEvent(FIRST_OWN_VERSION + 5); + assertThat(support.processEvent(RESOURCE_ID, follow)).contains(follow); + } ExtendedResourceEvent updateEvent(long version) { return new ExtendedResourceEvent( From b428c96e9a032087b03012e423a8247587668462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 1 Jul 2026 21:14:20 +0200 Subject: [PATCH 13/15] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../event/source/informer/EventFilterSupportTest.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterSupportTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterSupportTest.java index 53208fc241..759943c01b 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterSupportTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterSupportTest.java @@ -34,11 +34,6 @@ class EventFilterSupportTest { EventFilterSupport support = new EventFilterSupport(); - @Test - void trivialCaseOnlyEvent() { - assertThat(support.processEvent(RESOURCE_ID, updateEvent(FIRST_OWN_VERSION))) - .hasValueSatisfying(e -> assertThat(e.getAction()).isEqualTo(UPDATED)); - } @Test void startEventFilteringCreatesEventingDetail() { From 2c2c73f5988c281555dedd3d0ff02a3112f05659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Wed, 1 Jul 2026 21:25:54 +0200 Subject: [PATCH 14/15] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../processing/event/source/informer/EventFilterSupportTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterSupportTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterSupportTest.java index 759943c01b..e828e7d3dc 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterSupportTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterSupportTest.java @@ -34,7 +34,6 @@ class EventFilterSupportTest { EventFilterSupport support = new EventFilterSupport(); - @Test void startEventFilteringCreatesEventingDetail() { support.startEventFilteringModify(RESOURCE_ID); From 6b8c73bbe9231b34a3d5667619dfcfdf7eb94758 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 2 Jul 2026 08:33:33 +0200 Subject: [PATCH 15/15] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../source/informer/EventFilterSupportTest.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterSupportTest.java b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterSupportTest.java index e828e7d3dc..ccebb7fb99 100644 --- a/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterSupportTest.java +++ b/operator-framework-core/src/test/java/io/javaoperatorsdk/operator/processing/event/source/informer/EventFilterSupportTest.java @@ -612,8 +612,16 @@ void synthEventBuiltFromLeadingAddEvent() { assertThat(support.processEvent(RESOURCE_ID, updateEvent(FIRST_OWN_VERSION + 2))).isEmpty(); assertThat(support.doneEventFilterModify(RESOURCE_ID)) - .hasValueSatisfying(e -> assertThat(e.getAction()).isEqualTo(UPDATED)); - assertThat(support.isActiveUpdateFor(RESOURCE_ID)).isFalse(); + .hasValueSatisfying( + e -> { + assertThat(e.getAction()).isEqualTo(UPDATED); + assertThat(e.getResourceVersion()).isEqualTo(FIRST_OWN_VERSION + 2); + assertThat(e.getPreviousResource()) + .hasValueSatisfying( + r -> + assertThat(r.getMetadata().getResourceVersion()) + .isEqualTo(Long.toString(FIRST_OWN_VERSION))); + }); } @Test