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 c9d33c8e9 [FLINK-41222][autoscaler] Accept a conflicting ConfigMap 
write that Kubernetes already applied (#1234)
c9d33c8e9 is described below

commit c9d33c8e9e7bb21d0efc4801acf645d82effc03c
Author: Alex Kukhar <[email protected]>
AuthorDate: Thu Oct 29 11:17:56 2026 +0000

    [FLINK-41222][autoscaler] Accept a conflicting ConfigMap write that 
Kubernetes already applied (#1234)
---
 .../operator/autoscaler/state/ConfigMapView.java   |  35 +++-
 .../autoscaler/state/ConfigMapViewTest.java        | 187 ++++++++++++++++++++-
 2 files changed, 220 insertions(+), 2 deletions(-)

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 88a0eccbc..b66b7aed5 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
@@ -104,6 +104,9 @@ class ConfigMapView {
                 refreshConfigMap(resourceRetriever.apply(configMap).update());
                 return;
             } catch (KubernetesClientException e) {
+                if (e.getCode() == HttpURLConnection.HTTP_CONFLICT && 
adoptIfAlreadyStored()) {
+                    return;
+                }
                 if (e.getCode() != HttpURLConnection.HTTP_NOT_FOUND) {
                     throw e;
                 }
@@ -120,7 +123,37 @@ class ConfigMapView {
             }
         }
         LOG.info("Creating ConfigMap {}", configMap.getMetadata().getName());
-        refreshConfigMap(resourceRetriever.apply(configMap).create());
+        try {
+            refreshConfigMap(resourceRetriever.apply(configMap).create());
+        } catch (KubernetesClientException e) {
+            if (e.getCode() != HttpURLConnection.HTTP_CONFLICT || 
!adoptIfAlreadyStored()) {
+                throw e;
+            }
+        }
+    }
+
+    /**
+     * The Kubernetes client retries a write whose response did not arrive in 
time. If the first
+     * attempt was applied, the retry conflicts: an update on its stale 
resourceVersion, a create on
+     * the existing ConfigMap. When the stored ConfigMap holds the local 
state, it is adopted. Only
+     * what this view writes is compared: the data and the owner references.
+     */
+    private boolean adoptIfAlreadyStored() {
+        var stored = resourceRetriever.apply(configMap).get();
+        if (stored == null
+                || !configMap.getData().equals(stored.getData())
+                || !configMap
+                        .getMetadata()
+                        .getOwnerReferences()
+                        .equals(stored.getMetadata().getOwnerReferences())) {
+            return false;
+        }
+        LOG.info(
+                "ConfigMap {} already holds the local state, adopting 
resourceVersion {}",
+                configMap.getMetadata().getName(),
+                stored.getMetadata().getResourceVersion());
+        refreshConfigMap(stored);
+        return true;
     }
 
     /**
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 b2821e2ac..1470e33c4 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
@@ -21,17 +21,24 @@ 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.api.model.Status;
+import io.fabric8.kubernetes.api.model.StatusBuilder;
+import io.fabric8.kubernetes.client.ConfigBuilder;
 import io.fabric8.kubernetes.client.KubernetesClient;
+import io.fabric8.kubernetes.client.KubernetesClientBuilder;
 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 io.fabric8.mockwebserver.http.RecordedRequest;
 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.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.Function;
@@ -44,6 +51,10 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
 @EnableKubernetesMockClient(crud = true)
 public class ConfigMapViewTest {
 
+    private static final String CONFIG_MAPS_PATH = 
"/api/v1/namespaces/test/configmaps";
+
+    private static final String CONFIG_MAP_PATH = CONFIG_MAPS_PATH + 
"/configMapName";
+
     KubernetesClient kubernetesClient;
 
     KubernetesMockServer mockWebServer;
@@ -211,6 +222,180 @@ public class ConfigMapViewTest {
         assertThat(cmView.get("b")).isEqualTo("2");
     }
 
+    @Test
+    void testFlushAdoptsAnUpdateAppliedBeforeTheClientRetried() {
+        var stored = new AtomicReference<>(storedConfigMap("1", Map.of("a", 
"1")));
+        var sentVersions = new CopyOnWriteArrayList<String>();
+        mockWebServer
+                .expect()
+                .get()
+                .withPath(CONFIG_MAP_PATH)
+                .andReply(200, request -> stored.get())
+                .always();
+        // The API server applies the update but answers only after the 
client's request timeout.
+        mockWebServer
+                .expect()
+                .put()
+                .delay(2, TimeUnit.SECONDS)
+                .withPath(CONFIG_MAP_PATH)
+                .andReply(
+                        200,
+                        request -> {
+                            var applied = unmarshal(request);
+                            
sentVersions.add(applied.getMetadata().getResourceVersion());
+                            applied.getMetadata().setResourceVersion("2");
+                            stored.set(applied);
+                            return applied;
+                        })
+                .once();
+        // The client's retry still carries resourceVersion 1.
+        mockWebServer
+                .expect()
+                .put()
+                .withPath(CONFIG_MAP_PATH)
+                .andReply(
+                        409,
+                        request -> {
+                            
sentVersions.add(unmarshal(request).getMetadata().getResourceVersion());
+                            return status(409, "Conflict");
+                        })
+                .once();
+        expectUpdate(sentVersions);
+
+        try (var client = clientWithShortRequestTimeout()) {
+            var cmView = new ConfigMapView(createConfigMapSkeleton(), 
client::resource);
+            cmView.put("b", "2");
+            cmView.flush();
+            assertThat(cmView.get("b")).isEqualTo("2");
+
+            // The next update is sent with the adopted resourceVersion.
+            cmView.put("c", "3");
+            cmView.flush();
+        }
+        assertThat(sentVersions).isEqualTo(List.of("1", "1", "2"));
+    }
+
+    @Test
+    void testFlushAdoptsACreateAppliedBeforeTheClientRetried() {
+        var stored = new AtomicReference<ConfigMap>();
+        var sentVersions = new CopyOnWriteArrayList<String>();
+        mockWebServer
+                .expect()
+                .get()
+                .withPath(CONFIG_MAP_PATH)
+                .andReturn(404, status(404, "NotFound"))
+                .once();
+        mockWebServer
+                .expect()
+                .get()
+                .withPath(CONFIG_MAP_PATH)
+                .andReply(200, request -> stored.get())
+                .always();
+        // The API server creates the ConfigMap but answers only after the 
client's request timeout.
+        mockWebServer
+                .expect()
+                .post()
+                .delay(2, TimeUnit.SECONDS)
+                .withPath(CONFIG_MAPS_PATH)
+                .andReply(
+                        201,
+                        request -> {
+                            var created = unmarshal(request);
+                            created.getMetadata().setResourceVersion("1");
+                            stored.set(created);
+                            return created;
+                        })
+                .once();
+        // The client's retry finds the ConfigMap already created.
+        mockWebServer
+                .expect()
+                .post()
+                .withPath(CONFIG_MAPS_PATH)
+                .andReturn(409, status(409, "AlreadyExists"))
+                .once();
+        expectUpdate(sentVersions);
+
+        try (var client = clientWithShortRequestTimeout()) {
+            var cmView = new ConfigMapView(createConfigMapSkeleton(), 
client::resource);
+            cmView.put("a", "1");
+            cmView.flush();
+            assertThat(cmView.get("a")).isEqualTo("1");
+
+            // The next update is sent with the adopted resourceVersion.
+            cmView.put("b", "2");
+            cmView.flush();
+        }
+        assertThat(sentVersions).isEqualTo(List.of("1"));
+    }
+
+    @Test
+    void testFlushDoesNotAdoptAConflictingConfigMapOfAPreviousOwner() {
+        var stored = createConfigMapSkeleton();
+        stored.getMetadata().setResourceVersion("1");
+        
stored.getMetadata().setOwnerReferences(List.of(ownerReference("uid-1")));
+        Function<ConfigMap, Resource<ConfigMap>> apiServer =
+                cm ->
+                        mockResource(
+                                () -> stored,
+                                () -> {
+                                    throw new 
KubernetesClientException("Conflict", 409, null);
+                                },
+                                () -> {
+                                    throw new 
UnsupportedOperationException("create");
+                                });
+
+        var skeleton = createConfigMapSkeleton();
+        
skeleton.getMetadata().setOwnerReferences(List.of(ownerReference("uid-2")));
+        var cmView = new ConfigMapView(skeleton, apiServer);
+
+        // The stored data equals the local data, but the takeover was not 
applied.
+        var failure = assertThrows(KubernetesClientException.class, 
cmView::flush);
+        assertThat(failure.getCode()).isEqualTo(409);
+    }
+
+    /** Accepts one update and records the resourceVersion it was sent with. */
+    private void expectUpdate(List<String> sentVersions) {
+        mockWebServer
+                .expect()
+                .put()
+                .withPath(CONFIG_MAP_PATH)
+                .andReply(
+                        200,
+                        request -> {
+                            var updated = unmarshal(request);
+                            
sentVersions.add(updated.getMetadata().getResourceVersion());
+                            return updated;
+                        })
+                .once();
+    }
+
+    private KubernetesClient clientWithShortRequestTimeout() {
+        var config =
+                new ConfigBuilder(kubernetesClient.getConfiguration())
+                        .withRequestTimeout(500)
+                        .withRequestRetryBackoffLimit(1)
+                        .build();
+        return new KubernetesClientBuilder().withConfig(config).build();
+    }
+
+    private ConfigMap unmarshal(RecordedRequest request) {
+        return kubernetesClient
+                .getKubernetesSerialization()
+                .unmarshal(request.getUtf8Body(), ConfigMap.class);
+    }
+
+    private static Status status(int code, String reason) {
+        return new StatusBuilder().withCode(code).withReason(reason).build();
+    }
+
+    private static ConfigMap storedConfigMap(String resourceVersion, 
Map<String, String> data) {
+        var stored = createConfigMapSkeleton();
+        stored.getMetadata().setResourceVersion(resourceVersion);
+        stored.setData(new HashMap<>(data));
+        return stored;
+    }
+
+    /** Like the API server, every read returns a new object. */
     @SuppressWarnings("unchecked")
     private static Resource<ConfigMap> mockResource(
             Supplier<ConfigMap> get, Supplier<ConfigMap> update, 
Supplier<ConfigMap> create) {
@@ -223,7 +408,7 @@ public class ConfigMapViewTest {
                                 throw new 
UnsupportedOperationException(method.getName());
                             }
                             return switch (method.getName()) {
-                                case "get" -> get.get();
+                                case "get" -> new 
ConfigMapBuilder(get.get()).build();
                                 case "update" -> update.get();
                                 case "create" -> create.get();
                                 case "toString" -> "mockResource";

Reply via email to