This is an automated email from the ASF dual-hosted git repository.

gyfora pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-kubernetes-operator.git


The following commit(s) were added to refs/heads/main by this push:
     new 59a6ffde [FLINK-40587] Recreating a FlinkDeployment throws an 
autoscaler exception
59a6ffde is described below

commit 59a6ffde793c2eaf91fe1c47b0b82c5dc9214f01
Author: Dennis-Mircea Ciupitu <[email protected]>
AuthorDate: Mon Sep 14 17:33:34 2026 +0200

    [FLINK-40587] Recreating a FlinkDeployment throws an autoscaler exception
---
 .../operator/autoscaler/state/ConfigMapStore.java  |  29 +++-
 .../operator/autoscaler/state/ConfigMapView.java   |  68 ++++++++-
 .../autoscaler/state/ConfigMapStoreTest.java       |  82 +++++++++++
 .../autoscaler/state/ConfigMapViewTest.java        | 156 +++++++++++++++++++++
 4 files changed, 324 insertions(+), 11 deletions(-)

diff --git 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/autoscaler/state/ConfigMapStore.java
 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/autoscaler/state/ConfigMapStore.java
index 2abb1420..ad41c8b2 100644
--- 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/autoscaler/state/ConfigMapStore.java
+++ 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/autoscaler/state/ConfigMapStore.java
@@ -25,12 +25,15 @@ import io.fabric8.kubernetes.api.model.ConfigMap;
 import io.fabric8.kubernetes.api.model.HasMetadata;
 import io.fabric8.kubernetes.api.model.ObjectMeta;
 import io.fabric8.kubernetes.client.KubernetesClient;
+import io.fabric8.kubernetes.client.KubernetesClientException;
 import io.javaoperatorsdk.operator.processing.event.ResourceID;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.net.HttpURLConnection;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.concurrent.ConcurrentHashMap;
 
@@ -75,16 +78,22 @@ public class ConfigMapStore {
     public void flush(KubernetesJobAutoScalerContext jobContext) {
         ConfigMapView configMapView = cache.get(jobContext.getJobKey());
         if (configMapView == null) {
-            LOG.debug("The configMap doesn't exist, so skip the flush.");
+            LOG.debug("The autoscaler ConfigMap doesn't exist, so skip the 
flush.");
             return;
         }
         try {
             configMapView.flush();
         } catch (Exception e) {
+            removeInfoFromCache(jobContext.getJobKey());
+            if (isNotFound(e)) {
+                LOG.warn(
+                        "Autoscaler ConfigMap of {} no longer exists, it will 
be recreated on the next cycle.",
+                        jobContext.getJobKey().getName());
+                return;
+            }
             LOG.error(
-                    "Error while updating autoscaler info configmap, 
invalidating to clear the cache",
+                    "Error while updating autoscaler ConfigMap, invalidating 
to clear the cache.",
                     e);
-            removeInfoFromCache(jobContext.getJobKey());
             throw e;
         }
     }
@@ -93,9 +102,19 @@ public class ConfigMapStore {
         cache.remove(resourceID);
     }
 
+    private static boolean isNotFound(Exception e) {
+        return e instanceof KubernetesClientException
+                && ((KubernetesClientException) e).getCode() == 
HttpURLConnection.HTTP_NOT_FOUND;
+    }
+
     private ConfigMapView getConfigMap(KubernetesJobAutoScalerContext 
jobContext) {
-        return cache.computeIfAbsent(
-                jobContext.getJobKey(), (id) -> 
getConfigMapFromKubernetes(jobContext));
+        var ownerUid = jobContext.getResource().getMetadata().getUid();
+        return cache.compute(
+                jobContext.getJobKey(),
+                (id, cached) ->
+                        cached != null && Objects.equals(ownerUid, 
cached.getOwnerUid())
+                                ? cached
+                                : getConfigMapFromKubernetes(jobContext));
     }
 
     @VisibleForTesting
diff --git 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/autoscaler/state/ConfigMapView.java
 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/autoscaler/state/ConfigMapView.java
index 8a6a37c6..88a0eccb 100644
--- 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/autoscaler/state/ConfigMapView.java
+++ 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/autoscaler/state/ConfigMapView.java
@@ -21,12 +21,15 @@ import org.apache.flink.annotation.VisibleForTesting;
 import org.apache.flink.util.Preconditions;
 
 import io.fabric8.kubernetes.api.model.ConfigMap;
+import io.fabric8.kubernetes.client.KubernetesClientException;
 import io.fabric8.kubernetes.client.dsl.Resource;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.net.HttpURLConnection;
 import java.util.Collections;
 import java.util.Map;
+import java.util.Objects;
 import java.util.function.Function;
 
 class ConfigMapView {
@@ -36,7 +39,10 @@ class ConfigMapView {
     enum State {
         /** ConfigMap is only stored locally, not created in Kubernetes yet. */
         NEEDS_CREATE,
-        /** ConfigMap exists in Kubernetes but there are newer local changes. 
*/
+        /**
+         * ConfigMap exists in Kubernetes but there are newer local changes. 
Falls back to
+         * NEEDS_CREATE if the ConfigMap was already deleted when flushing.
+         */
         NEEDS_UPDATE,
         /** ConfigMap view reflects the actual contents of Kubernetes 
ConfigMap. */
         UP_TO_DATE
@@ -46,14 +52,18 @@ class ConfigMapView {
 
     private ConfigMap configMap;
 
+    private final String ownerUid;
+
     private final Function<ConfigMap, Resource<ConfigMap>> resourceRetriever;
 
     public ConfigMapView(
             ConfigMap configMapSkeleton,
             Function<ConfigMap, Resource<ConfigMap>> resourceRetriever) {
+        this.ownerUid = ownerUidOf(configMapSkeleton);
         var existingConfigMap = 
resourceRetriever.apply(configMapSkeleton).get();
         if (existingConfigMap != null) {
             refreshConfigMap(existingConfigMap);
+            takeOverIfLeftByPreviousOwner(configMapSkeleton);
         } else {
             this.configMap = configMapSkeleton;
             this.state = State.NEEDS_CREATE;
@@ -89,13 +99,54 @@ class ConfigMapView {
         if (state == State.UP_TO_DATE) {
             return;
         }
-        Resource<ConfigMap> resource = resourceRetriever.apply(configMap);
         if (state == State.NEEDS_UPDATE) {
-            refreshConfigMap(resource.update());
-        } else if (state == State.NEEDS_CREATE) {
-            LOG.info("Creating config map {}", 
configMap.getMetadata().getName());
-            refreshConfigMap(resource.create());
+            try {
+                refreshConfigMap(resourceRetriever.apply(configMap).update());
+                return;
+            } catch (KubernetesClientException e) {
+                if (e.getCode() != HttpURLConnection.HTTP_NOT_FOUND) {
+                    throw e;
+                }
+                // The ConfigMap was deleted in the meantime, typically by the 
Kubernetes garbage
+                // collector after the owning resource was replaced. So, we 
fallback to create it
+                // with the current local state in order to not lose the 
current autoscaling
+                // cycle of computations.
+                LOG.info(
+                        "ConfigMap {} no longer exists, recreating it",
+                        configMap.getMetadata().getName());
+                configMap.getMetadata().setResourceVersion(null);
+                configMap.getMetadata().setUid(null);
+                state = State.NEEDS_CREATE;
+            }
         }
+        LOG.info("Creating ConfigMap {}", configMap.getMetadata().getName());
+        refreshConfigMap(resourceRetriever.apply(configMap).create());
+    }
+
+    /**
+     * A ConfigMap left behind by a previous instance of the resource is taken 
over rather than left
+     * to the Kubernetes garbage collector, because in this way it may still 
reference a stale
+     * object that can generate a 404 error at flushing time. Its contents are 
also dropped because
+     * they reference a resource that is no longer existing.
+     */
+    private void takeOverIfLeftByPreviousOwner(ConfigMap configMapSkeleton) {
+        if (Objects.equals(ownerUid, ownerUidOf(configMap))) {
+            return;
+        }
+        configMap
+                .getMetadata()
+                
.setOwnerReferences(configMapSkeleton.getMetadata().getOwnerReferences());
+        configMap.getData().clear();
+        requireUpdate();
+    }
+
+    String getOwnerUid() {
+        return ownerUid;
+    }
+
+    private static String ownerUidOf(ConfigMap configMap) {
+        var ownerReferences = configMap.getMetadata().getOwnerReferences();
+        return ownerReferences.isEmpty() ? null : 
ownerReferences.get(0).getUid();
     }
 
     private void refreshConfigMap(ConfigMap configMap) {
@@ -114,4 +165,9 @@ class ConfigMapView {
     public Map<String, String> getDataReadOnly() {
         return Collections.unmodifiableMap(configMap.getData());
     }
+
+    @VisibleForTesting
+    protected ConfigMap getConfigMap() {
+        return configMap;
+    }
 }
diff --git 
a/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/autoscaler/state/ConfigMapStoreTest.java
 
b/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/autoscaler/state/ConfigMapStoreTest.java
index 9c6b8fb2..ff7f86c5 100644
--- 
a/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/autoscaler/state/ConfigMapStoreTest.java
+++ 
b/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/autoscaler/state/ConfigMapStoreTest.java
@@ -31,6 +31,7 @@ import org.junit.jupiter.api.Test;
 
 import static 
org.apache.flink.kubernetes.operator.autoscaler.TestingKubernetesAutoscalerUtils.createContext;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 
@@ -140,6 +141,87 @@ public class ConfigMapStoreTest {
         assertThat(configMapStore.getSerializedState(ctx, "a")).contains("2");
     }
 
+    @Test
+    void testCacheInvalidatedWhenOwnerUidChanges() {
+        var ctx = createContext("cr1", kubernetesClient);
+        ctx.getResource().getMetadata().setUid("uid-1");
+
+        configMapStore.putSerializedState(ctx, "a", "1");
+        configMapStore.flush(ctx);
+        var requestCount = mockWebServer.getRequestCount();
+
+        assertThat(configMapStore.getSerializedState(ctx, "a")).contains("1");
+        assertEquals(requestCount, mockWebServer.getRequestCount());
+
+        ctx.getResource().getMetadata().setUid("uid-2");
+        assertThat(configMapStore.getSerializedState(ctx, "a")).isEmpty();
+        assertEquals(requestCount + 1, mockWebServer.getRequestCount());
+    }
+
+    @Test
+    void testRecreatedResourceTakesOverTheConfigMapLeftBehind() {
+        var ctx = createContext("cr1", kubernetesClient);
+        ctx.getResource().getMetadata().setUid("uid-1");
+        configMapStore.putSerializedState(ctx, "old", "state");
+        configMapStore.flush(ctx);
+
+        ctx.getResource().getMetadata().setUid("uid-2");
+
+        configMapStore.putSerializedState(ctx, "x", "1");
+        configMapStore.putSerializedState(ctx, "y", "2");
+        configMapStore.flush(ctx);
+
+        var stored = configMapStore.getConfigMapFromKubernetes(ctx);
+        assertThat(stored.getDataReadOnly()).containsEntry("x", 
"1").containsEntry("y", "2");
+        assertThat(stored.getDataReadOnly()).doesNotContainKey("old");
+        assertThat(stored.getOwnerUid()).isEqualTo("uid-2");
+    }
+
+    @Test
+    void testFlushRecreatesConfigMapDeletedExternally() {
+        var ctx = createContext("cr1", kubernetesClient);
+
+        configMapStore.putSerializedState(ctx, "a", "1");
+        configMapStore.flush(ctx);
+
+        kubernetesClient
+                .configMaps()
+                .inNamespace(ctx.getResource().getMetadata().getNamespace())
+                .withName("autoscaler-cr1")
+                .delete();
+
+        configMapStore.putSerializedState(ctx, "a", "2");
+        assertDoesNotThrow(() -> configMapStore.flush(ctx));
+        assertThat(configMapStore.getCache()).containsKey(ctx.getJobKey());
+        
assertThat(configMapStore.getConfigMapFromKubernetes(ctx).getDataReadOnly())
+                .containsEntry("a", "2");
+    }
+
+    @Test
+    void testTakeOverSurvivesTheCollectorWinningTheRace() {
+        var ctx = createContext("cr1", kubernetesClient);
+        ctx.getResource().getMetadata().setUid("uid-1");
+        configMapStore.putSerializedState(ctx, "old", "state");
+        configMapStore.flush(ctx);
+
+        ctx.getResource().getMetadata().setUid("uid-2");
+        configMapStore.putSerializedState(ctx, "x", "1");
+
+        kubernetesClient
+                .configMaps()
+                .inNamespace(ctx.getResource().getMetadata().getNamespace())
+                .withName("autoscaler-cr1")
+                .delete();
+
+        configMapStore.putSerializedState(ctx, "y", "2");
+        assertDoesNotThrow(() -> configMapStore.flush(ctx));
+
+        var stored = configMapStore.getConfigMapFromKubernetes(ctx);
+        assertThat(stored.getDataReadOnly()).containsEntry("x", 
"1").containsEntry("y", "2");
+        assertThat(stored.getDataReadOnly()).doesNotContainKey("old");
+        assertThat(stored.getOwnerUid()).isEqualTo("uid-2");
+    }
+
     @Test
     void testMinimalAmountOfFlushing() {
         KubernetesJobAutoScalerContext ctx = createContext("cr1", 
kubernetesClient);
diff --git 
a/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/autoscaler/state/ConfigMapViewTest.java
 
b/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/autoscaler/state/ConfigMapViewTest.java
index f33e862f..b2821e2a 100644
--- 
a/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/autoscaler/state/ConfigMapViewTest.java
+++ 
b/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/autoscaler/state/ConfigMapViewTest.java
@@ -18,13 +18,27 @@
 package org.apache.flink.kubernetes.operator.autoscaler.state;
 
 import io.fabric8.kubernetes.api.model.ConfigMap;
+import io.fabric8.kubernetes.api.model.ConfigMapBuilder;
 import io.fabric8.kubernetes.api.model.ObjectMeta;
+import io.fabric8.kubernetes.api.model.OwnerReference;
 import io.fabric8.kubernetes.client.KubernetesClient;
+import io.fabric8.kubernetes.client.KubernetesClientException;
+import io.fabric8.kubernetes.client.dsl.Resource;
 import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient;
 import io.fabric8.kubernetes.client.server.mock.KubernetesMockServer;
 import org.junit.jupiter.api.Test;
 
+import java.lang.reflect.Proxy;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
 import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 
 /** Tests for ConfigMapView. */
 @EnableKubernetesMockClient(crud = true)
@@ -87,6 +101,148 @@ public class ConfigMapViewTest {
         assertThat(mockWebServer.getRequestCount()).isEqualTo(2);
     }
 
+    @Test
+    void testTakesOverTheConfigMapOfAPreviousOwner() {
+        var leftBehind = createConfigMapSkeleton();
+        
leftBehind.getMetadata().setOwnerReferences(List.of(ownerReference("uid-1")));
+        leftBehind.setData(Map.of("previous", "state"));
+        kubernetesClient.resource(leftBehind).create();
+
+        var skeleton = createConfigMapSkeleton();
+        
skeleton.getMetadata().setOwnerReferences(List.of(ownerReference("uid-2")));
+        var cmView = new ConfigMapView(skeleton, cm -> 
kubernetesClient.resource(cm));
+
+        assertThat(cmView.get("previous")).isNull();
+        assertThat(cmView.getOwnerUid()).isEqualTo("uid-2");
+
+        cmView.flush();
+        var persisted = 
kubernetesClient.resource(createConfigMapSkeleton()).get();
+        
assertThat(persisted.getMetadata().getOwnerReferences().get(0).getUid()).isEqualTo("uid-2");
+        assertThat(persisted.getData().isEmpty()).isTrue();
+    }
+
+    @Test
+    void testFlushRecreatesWhenTheConfigMapVanishedBeforeTheUpdate() {
+        var stored = createConfigMapSkeleton();
+        stored.getMetadata().setResourceVersion("1");
+        stored.getMetadata().setUid("cm-uid-1");
+        stored.setData(new HashMap<>(Map.of("a", "1")));
+
+        var sent = new AtomicReference<ConfigMap>();
+        var updates = new AtomicInteger();
+        var creates = new AtomicInteger();
+        Function<ConfigMap, Resource<ConfigMap>> apiServer =
+                cm -> {
+                    sent.set(cm);
+                    return mockResource(
+                            () -> stored,
+                            () -> {
+                                updates.incrementAndGet();
+                                throw new KubernetesClientException("Not 
Found", 404, null);
+                            },
+                            () -> {
+                                creates.incrementAndGet();
+                                var meta = sent.get().getMetadata();
+                                // A real API server refuses to create an 
object that still carries
+                                // the identity of the one it replaces. The 
mock server does not,
+                                // so this is the only place that check is 
enforced.
+                                if (meta.getResourceVersion() != null || 
meta.getUid() != null) {
+                                    throw new KubernetesClientException(
+                                            "resourceVersion should not be set 
on objects to be created",
+                                            400,
+                                            null);
+                                }
+                                return new ConfigMapBuilder(sent.get())
+                                        .editMetadata()
+                                        .withResourceVersion("2")
+                                        .withUid("cm-uid-2")
+                                        .endMetadata()
+                                        .build();
+                            });
+                };
+
+        var cmView = new ConfigMapView(createConfigMapSkeleton(), apiServer);
+        cmView.put("b", "2");
+
+        cmView.flush();
+        assertThat(updates.get()).isEqualTo(1);
+        assertThat(creates.get()).isEqualTo(1);
+        assertThat(cmView.get("a")).isEqualTo("1");
+        assertThat(cmView.get("b")).isEqualTo("2");
+        
assertThat(cmView.getConfigMap().getMetadata().getResourceVersion()).isEqualTo("2");
+        
assertThat(cmView.getConfigMap().getMetadata().getUid()).isEqualTo("cm-uid-2");
+
+        // perform one additional flush to check the state remains the same
+        cmView.flush();
+        assertThat(updates.get()).isEqualTo(1);
+        assertThat(creates.get()).isEqualTo(1);
+        assertThat(cmView.get("a")).isEqualTo("1");
+        assertThat(cmView.get("b")).isEqualTo("2");
+        
assertThat(cmView.getConfigMap().getMetadata().getResourceVersion()).isEqualTo("2");
+        
assertThat(cmView.getConfigMap().getMetadata().getUid()).isEqualTo("cm-uid-2");
+    }
+
+    @Test
+    void testFlushDoesNotRecreateOnOtherUpdateFailures() {
+        var stored = createConfigMapSkeleton();
+        stored.getMetadata().setResourceVersion("1");
+        stored.setData(new HashMap<>(Map.of("a", "1")));
+
+        var creates = new AtomicInteger();
+        Function<ConfigMap, Resource<ConfigMap>> apiServer =
+                cm ->
+                        mockResource(
+                                () -> stored,
+                                () -> {
+                                    throw new 
KubernetesClientException("Conflict", 409, null);
+                                },
+                                () -> {
+                                    creates.incrementAndGet();
+                                    return cm;
+                                });
+
+        var cmView = new ConfigMapView(createConfigMapSkeleton(), apiServer);
+        cmView.put("b", "2");
+
+        var failure = assertThrows(KubernetesClientException.class, 
cmView::flush);
+        assertThat(failure.getCode()).isEqualTo(409);
+        assertThat(creates.get()).isEqualTo(0);
+        // The pending write is kept for the retry on the next cycle
+        assertThat(cmView.get("b")).isEqualTo("2");
+    }
+
+    @SuppressWarnings("unchecked")
+    private static Resource<ConfigMap> mockResource(
+            Supplier<ConfigMap> get, Supplier<ConfigMap> update, 
Supplier<ConfigMap> create) {
+        return (Resource<ConfigMap>)
+                Proxy.newProxyInstance(
+                        Resource.class.getClassLoader(),
+                        new Class<?>[] {Resource.class},
+                        (proxy, method, args) -> {
+                            if (args != null && args.length > 0) {
+                                throw new 
UnsupportedOperationException(method.getName());
+                            }
+                            return switch (method.getName()) {
+                                case "get" -> get.get();
+                                case "update" -> update.get();
+                                case "create" -> create.get();
+                                case "toString" -> "mockResource";
+                                case "hashCode" -> 
System.identityHashCode(proxy);
+                                default -> throw new 
UnsupportedOperationException(
+                                        method.getName());
+                            };
+                        });
+    }
+
+    private static OwnerReference ownerReference(String uid) {
+        var ownerReference = new OwnerReference();
+        ownerReference.setApiVersion("flink.apache.org/v1beta1");
+        ownerReference.setKind("FlinkDeployment");
+        ownerReference.setName("cr");
+        ownerReference.setUid(uid);
+        return ownerReference;
+    }
+
     private boolean keyExistsInKubernetes(String key) {
         int requestCount = mockWebServer.getRequestCount();
         var cmView2 =

Reply via email to