From b7f82a20d1624ca434e00bdc9234e222aabd618b 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/12] 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 db4c23c394..b012dec3c6 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 04d75cd1e039d0798c2a68f235994b47ef70da5b 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/12] 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 7194c550fe..63cd6c0cfd 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 0f9237f502758681020ef4de3ac73d2889912584 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/12] 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 cef1dd52330612da59bf63ad136bc7d053169f56 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/12] 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 33567625b337b9cd0390332d4b372b05ef5f39b7 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/12] 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 8b6b9e0c6c6ae9de81230a94e06d8076214478a1 Mon Sep 17 00:00:00 2001 From: Martin Stefanko Date: Mon, 29 Jun 2026 17:36:27 +0200 Subject: [PATCH 06/12] 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 5325dbfcb8befbed804104f59c2da9dd1ac80cd7 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/12] 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 cba2337e6ec8e0c1676216c0ca8c62350d7d28f0 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/12] 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 e8088fd604e33f2906b25e4805fb386b78ceeca8 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/12] 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 fc1badc50b65e0f446fa9faf55d730d10455f250 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/12] 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 b012dec3c6..265cd12a79 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 b951100301145bd0e6ef36e3dbf94cd7c43e4f46 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/12] 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 1e91a47f1b422e4ff321f627c818e023ede23e36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Thu, 2 Jul 2026 08:32:01 +0200 Subject: [PATCH 12/12] docs: 5.4 release blog post (#3459) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- docs/content/en/blog/releases/v5-4-release.md | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 docs/content/en/blog/releases/v5-4-release.md diff --git a/docs/content/en/blog/releases/v5-4-release.md b/docs/content/en/blog/releases/v5-4-release.md new file mode 100644 index 0000000000..9913d75600 --- /dev/null +++ b/docs/content/en/blog/releases/v5-4-release.md @@ -0,0 +1,184 @@ +--- +title: Version 5.4 Released! +date: 2026-07-03 +author: >- + [Attila Mészáros](https://github.com/csviri) +--- + +We're pleased to announce the release of Java Operator SDK v5.4.0! This minor version adds +sharding support for horizontally splitting a workload across operator replicas, finer-grained +control over event filtering, richer secondary-resource lookups on `Context`, and a smarter retry +scheduler — along with a number of smaller improvements, deprecations, and a Fabric8 client upgrade. + +## Key Features + +### Shard Selector Support + +Large clusters sometimes need the same operator to run as multiple replicas, each responsible for a +subset ("shard") of the resources. From 5.4.0 you can configure a **shard selector** — a second, +Kubernetes-style label selector that is applied *in addition to* the normal label selector (the two +are combined with logical AND). + +```java +@ControllerConfiguration(informer = @Informer(shardSelector = "shardRange(object.metadata.uid, '0x0000000000000000', '0x8000000000000000')")) +public class MyReconciler implements Reconciler { ... } +``` + +It can also be set programmatically or via configuration: + +```java +ControllerConfigurationOverrider.override(config) + .withShardSelector("shardRange(object.metadata.uid, '0x0000000000000000', '0x8000000000000000')") + .build(); +``` + +`withShardSelector(String)` is available on `InformerConfiguration.Builder`, +`InformerEventSourceConfiguration.Builder`, and `ControllerConfigurationOverrider`, and via the +`josdk.controller..shard-selector` configuration key. This feature relies on Fabric8 client +support and requires the 7.8.0 baseline shipped with this release. + +### Opting Out of Default Event Filters + +By default, JOSDK applies a set of internal update filters to a controller's own event source: +generation-aware filtering, and finalizer-needed/marked-for-deletion handling. These are the right +default for most operators, but occasionally you need full control over exactly which updates +trigger a reconciliation. + +The new `defaultFilters` flag (default `true`) lets you turn them off. When set to `false`, your +`@Informer(onUpdateFilter = ...)` becomes the *sole* update filter — and if you don't set one, all +updates pass through: + +```java +@ControllerConfiguration( + defaultFilters = false, + informer = @Informer(onUpdateFilter = MyUpdateFilter.class)) +public class MyReconciler implements Reconciler { ... } +``` + +The internal filter building blocks in `InternalEventFilters` are now public, so you can re-compose +the parts you still want alongside your custom logic: + +```java +OnUpdateFilter composed = + InternalEventFilters.onUpdateGenerationAware(true) + .or((newRes, oldRes) -> /* custom trigger, e.g. a specific annotation present */); +``` + +`withDefaultFilters(boolean)` is also available on `ControllerConfigurationOverrider`. + +### By-name Secondary Resource Lookup on Context + +Looking up a single secondary resource by name previously meant streaming all secondaries and +filtering them yourself. `Context` now offers direct by-name lookups: + +```java +// name + namespace from a specific named event source +Optional secret = + context.getSecondaryResource(Secret.class, "my-event-source", "cred-secret", "my-ns"); + +// namespace inferred from the primary resource +Optional secret2 = + context.getSecondaryResource(Secret.class, "my-event-source", "cred-secret"); + +// stream all secondaries of a type from a specific named event source +Stream configMaps = + context.getSecondaryResourcesAsStream(ConfigMap.class, "cm-event-source"); +``` + +When the underlying event source is a cache, these hit the cache directly (and are read-cache-after-write +consistent); otherwise they fall back to filtering the full secondary set. The stream overload works +for **non-Kubernetes** secondary types too — its type bound was relaxed so it is no longer restricted +to `HasMetadata` — making it usable with external/polling event sources. + +### Retry Interval Honored Under Frequent Events + +Previously, when a failed reconciliation was triggered by an incoming event while a retry was already +scheduled, the incoming reconciliation could consume a retry attempt and advance the retry counter. +Under a steady stream of external events this meant the configured retry interval was effectively +ignored and the operator could exhaust its retries far too quickly. + +From 5.4.0, an event-driven reconciliation that fails while there is still plenty of time left in +the current retry window **preserves the existing retry deadline** and does *not* consume a retry +attempt — it simply re-schedules on the original deadline. A retry attempt is only counted once the +scheduled deadline is imminent (within 5 seconds). This is transparent to users; the configured +retry interval is now genuinely honored even under frequent events. + +A new `RetryExecution#remainingDurationUntilNextRetry()` returning `Optional` supports this +behavior. + +### Reconciling Dropped Secondary References + +On an **update** event for a secondary resource, the framework now invokes your +`SecondaryToPrimaryMapper` for **both the old and the new version** of the resource and reconciles +the union of the results. This means primaries that the secondary *used to* reference but no longer +does — including partial subset changes — are also reconciled and can revert to their expected state. + +> **Note**: Because the mapper may now be called on an *older* version of a resource, a +> `SecondaryToPrimaryMapper` implementation must be a **pure function of the resource passed to it** +> and must not rely on external "current" state. + +## Additional Improvements + +- **Owner-reference mappers match on group only**: `Mappers.fromOwnerReferences(apiVersion, kind, ...)` + now matches on kind + group and ignores the version. Owner references written under one CRD version + (e.g. `.../v1`) still resolve correctly after the served/storage version changes (e.g. to `.../v2`). +- **`GenericRetry#setMaxInterval(Duration)`**: a `Duration`-based overload alongside the existing + millis-based one, e.g. `new GenericRetry().setMaxInterval(Duration.ofMinutes(5))`. + +## Migration Notes + +### Shutdown hook: `installShutdownHook(Duration)` deprecated + +`Operator#installShutdownHook(Duration)` is deprecated for removal. Its `Duration` argument is now +ignored. This also fixes a deadlock that could occur when `stop()` was called from a JVM shutdown +hook while leader election was active. + +```java +// before +operator.installShutdownHook(Duration.ofSeconds(30)); + +// after — timeout comes from ConfigurationService +operator.installShutdownHook(); +``` + +Configure the graceful shutdown timeout via +`ConfigurationServiceOverrider#withReconciliationTerminationTimeout(Duration)`. Unlike the old +variant, the no-arg `installShutdownHook()` installs regardless of whether leader election is enabled, +and is idempotent. + +### Instance-based `Mappers.fromOwnerReferences` deprecated + +The overloads taking a `HasMetadata` **instance** are deprecated for removal. Pass the primary +resource **class** instead: + +```java +// before +Mappers.fromOwnerReferences(primaryResource); +Mappers.fromOwnerReferences(primaryResource, clusterScoped); + +// after +Mappers.fromOwnerReferences(MyPrimary.class); +Mappers.fromOwnerReferences(MyPrimary.class, clusterScoped); +``` + +## Getting Started + +```xml + + io.javaoperatorsdk + operator-framework + 5.4.0 + +``` + +## All Changes + +See the [comparison view](https://github.com/operator-framework/java-operator-sdk/compare/v5.3.0...v5.4.0) +for the full list of changes. + +## Feedback + +Please report issues or suggest improvements on our +[GitHub repository](https://github.com/operator-framework/java-operator-sdk/issues). + +Happy operator building! 🚀