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 8e567a5d [FLINK-39953] Fix cluster deletion timeout silently swallowed 
during upgrade (#1138)
8e567a5d is described below

commit 8e567a5da7b22ebe95af4bfd1c36ee40cbba0b30
Author: Lucas Gameiro <[email protected]>
AuthorDate: Tue Aug 4 10:48:26 2026 +0200

    [FLINK-39953] Fix cluster deletion timeout silently swallowed during 
upgrade (#1138)
---
 .../operator/service/AbstractFlinkService.java     |   4 +
 .../operator/service/NativeFlinkService.java       |  42 ++++---
 .../operator/service/AbstractFlinkServiceTest.java |  71 ++++++++++--
 .../operator/service/NativeFlinkServiceTest.java   |  64 ++++++++++
 .../service/StandaloneFlinkServiceTest.java        | 129 ++++++++++++++++++++-
 5 files changed, 285 insertions(+), 25 deletions(-)

diff --git 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/service/AbstractFlinkService.java
 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/service/AbstractFlinkService.java
index 543b937f..1d48d10d 100644
--- 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/service/AbstractFlinkService.java
+++ 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/service/AbstractFlinkService.java
@@ -117,6 +117,7 @@ import io.fabric8.kubernetes.api.model.PodList;
 import io.fabric8.kubernetes.api.model.apps.Deployment;
 import io.fabric8.kubernetes.client.KubernetesClient;
 import io.fabric8.kubernetes.client.KubernetesClientException;
+import io.fabric8.kubernetes.client.KubernetesClientTimeoutException;
 import io.fabric8.kubernetes.client.dsl.Resource;
 import io.fabric8.kubernetes.client.dsl.Waitable;
 import lombok.SneakyThrows;
@@ -1242,6 +1243,9 @@ public abstract class AbstractFlinkService implements 
FlinkService {
                 deleted.waitUntilCondition(
                         Objects::isNull, timeout.toMillis(), 
TimeUnit.MILLISECONDS);
                 LOG.info("Completed {}", operation);
+            } catch (KubernetesClientTimeoutException e) {
+                // Rethrow timeouts before the broader catch swallows it
+                throw e;
             } catch (KubernetesClientException kce) {
                 // We completely ignore not found errors and simply log others
                 if (kce.getCode() != HttpURLConnection.HTTP_NOT_FOUND) {
diff --git 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/service/NativeFlinkService.java
 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/service/NativeFlinkService.java
index a5a63f8b..65c2c7cd 100644
--- 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/service/NativeFlinkService.java
+++ 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/service/NativeFlinkService.java
@@ -56,6 +56,7 @@ import io.fabric8.kubernetes.api.model.PodList;
 import io.fabric8.kubernetes.api.model.apps.Deployment;
 import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder;
 import io.fabric8.kubernetes.client.KubernetesClient;
+import io.fabric8.kubernetes.client.KubernetesClientTimeoutException;
 import io.fabric8.kubernetes.client.dsl.EditReplacePatchable;
 import io.fabric8.kubernetes.client.dsl.base.PatchContext;
 import io.fabric8.kubernetes.client.dsl.base.PatchType;
@@ -323,23 +324,30 @@ public class NativeFlinkService extends 
AbstractFlinkService {
         // here is to initiate JM shutdown before the TMs
         var jmShutdownTimeout =
                 ObjectUtils.min(JM_SHUTDOWN_MAX_WAIT, 
remainingTimeout.dividedBy(2));
-        var remaining =
-                deleteBlocking(
-                        "Scaling JobManager Deployment to zero",
-                        () -> {
-                            try {
-                                jmDeployment.patch(
-                                        PatchContext.of(PatchType.JSON_MERGE), 
SCALE_TO_ZERO);
-                            } catch (Exception ignore) {
-                                // Ignore all errors here as this is an 
optional step
-                                return null;
-                            }
-                            return kubernetesClient
-                                    .pods()
-                                    .inNamespace(namespace)
-                                    
.withLabels(KubernetesUtils.getJobManagerSelectors(clusterId));
-                        },
-                        jmShutdownTimeout);
+        Duration remaining;
+        try {
+            remaining =
+                    deleteBlocking(
+                            "Scaling JobManager Deployment to zero",
+                            () -> {
+                                try {
+                                    jmDeployment.patch(
+                                            
PatchContext.of(PatchType.JSON_MERGE), SCALE_TO_ZERO);
+                                } catch (Exception ignore) {
+                                    // Ignore all errors here as this is an 
optional step
+                                    return null;
+                                }
+                                return kubernetesClient
+                                        .pods()
+                                        .inNamespace(namespace)
+                                        .withLabels(
+                                                
KubernetesUtils.getJobManagerSelectors(clusterId));
+                            },
+                            jmShutdownTimeout);
+        } catch (KubernetesClientTimeoutException e) {
+            LOG.warn("Timed out waiting for JobManager scale-to-zero (optional 
step), proceeding");
+            remaining = Duration.ZERO;
+        }
         return remainingTimeout.minus(jmShutdownTimeout).plus(remaining);
     }
 }
diff --git 
a/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/service/AbstractFlinkServiceTest.java
 
b/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/service/AbstractFlinkServiceTest.java
index 19d50587..ebfe0616 100644
--- 
a/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/service/AbstractFlinkServiceTest.java
+++ 
b/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/service/AbstractFlinkServiceTest.java
@@ -110,6 +110,7 @@ import io.fabric8.kubernetes.api.model.apps.DeploymentList;
 import io.fabric8.kubernetes.api.model.apps.DeploymentListBuilder;
 import io.fabric8.kubernetes.client.KubernetesClient;
 import io.fabric8.kubernetes.client.KubernetesClientException;
+import io.fabric8.kubernetes.client.KubernetesClientTimeoutException;
 import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient;
 import io.fabric8.kubernetes.client.server.mock.KubernetesMockServer;
 import lombok.SneakyThrows;
@@ -1319,19 +1320,75 @@ public class AbstractFlinkServiceTest {
         assertTrue(remainingMillis > 0);
         assertTrue(remainingMillis < 10000 - deleteDelay / 2);
 
-        // Test actual timeout
-        remainingMillis =
-                flinkService
-                        .deleteDeploymentBlocking(
+        // Timeout waiting for deletion should throw, not silently return
+        Assertions.assertThrows(
+                KubernetesClientTimeoutException.class,
+                () ->
+                        flinkService.deleteDeploymentBlocking(
                                 "Test",
                                 client.apps()
                                         .deployments()
                                         .inNamespace(namespace)
                                         .withName(deploymentName),
                                 DeletionPropagation.BACKGROUND,
-                                Duration.ofMillis(10))
-                        .toMillis();
-        assertEquals(0, remainingMillis);
+                                Duration.ofMillis(10)));
+    }
+
+    @Test
+    public void testDeleteBlockingTimeoutThrows() {
+        String deploymentName = "test-cluster";
+        String namespace = "test-namespace";
+        String getUrl =
+                String.format(
+                        
"/apis/apps/v1/namespaces/%s/deployments?fieldSelector=metadata.name%%3D%s",
+                        namespace, deploymentName);
+        String watchUrl =
+                String.format(
+                        
"/apis/apps/v1/namespaces/%s/deployments?allowWatchBookmarks=true&fieldSelector=metadata.name%%3D%s&timeoutSeconds=600&watch=true",
+                        namespace, deploymentName);
+
+        Deployment deployment =
+                new DeploymentBuilder()
+                        .withNewMetadata()
+                        .withName(deploymentName)
+                        .withNamespace(namespace)
+                        .endMetadata()
+                        .build();
+        DeploymentList deploymentList =
+                new DeploymentListBuilder()
+                        .withMetadata(new ListMeta())
+                        .withItems(deployment)
+                        .build();
+
+        // GET always returns the deployment; watch never emits DELETED within 
the test window
+        mockServer
+                .expect()
+                .get()
+                .withPath(getUrl)
+                .andReturn(HttpURLConnection.HTTP_OK, deploymentList)
+                .always();
+        mockServer
+                .expect()
+                .get()
+                .withPath(watchUrl)
+                .andUpgradeToWebSocket()
+                .open()
+                .waitFor(5000)
+                .andEmit(new WatchEvent(deployment, "DELETED"))
+                .done()
+                .always();
+
+        Assertions.assertThrows(
+                KubernetesClientTimeoutException.class,
+                () ->
+                        AbstractFlinkService.deleteBlocking(
+                                "Test",
+                                () ->
+                                        client.apps()
+                                                .deployments()
+                                                .inNamespace(namespace)
+                                                .withName(deploymentName),
+                                Duration.ofMillis(100)));
     }
 
     @ParameterizedTest
diff --git 
a/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/service/NativeFlinkServiceTest.java
 
b/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/service/NativeFlinkServiceTest.java
index 666f24da..4026bc8e 100644
--- 
a/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/service/NativeFlinkServiceTest.java
+++ 
b/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/service/NativeFlinkServiceTest.java
@@ -49,9 +49,14 @@ import 
org.apache.flink.runtime.rest.messages.job.JobResourceRequirementsHeaders
 import org.apache.flink.util.concurrent.Executors;
 
 import io.fabric8.kubernetes.api.model.DeletionPropagation;
+import io.fabric8.kubernetes.api.model.ListMeta;
 import io.fabric8.kubernetes.api.model.PodBuilder;
+import io.fabric8.kubernetes.api.model.WatchEvent;
 import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder;
+import io.fabric8.kubernetes.api.model.apps.DeploymentList;
+import io.fabric8.kubernetes.api.model.apps.DeploymentListBuilder;
 import io.fabric8.kubernetes.client.KubernetesClient;
+import io.fabric8.kubernetes.client.KubernetesClientTimeoutException;
 import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient;
 import io.fabric8.kubernetes.client.server.mock.KubernetesMockServer;
 import org.junit.jupiter.api.BeforeEach;
@@ -75,6 +80,7 @@ import static 
org.apache.flink.kubernetes.operator.config.KubernetesOperatorConf
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.fail;
 
@@ -195,6 +201,64 @@ public class NativeFlinkServiceTest {
                         .get());
     }
 
+    @Test
+    public void testDeleteClusterInternalThrowsOnDeploymentDeletionTimeout() {
+        var timeout = Duration.ofMillis(200);
+        configuration.set(
+                
KubernetesOperatorConfigOptions.OPERATOR_RESOURCE_CLEANUP_TIMEOUT, timeout);
+        var flinkService =
+                new NativeFlinkService(
+                        client,
+                        null,
+                        executorService,
+                        
FlinkOperatorConfiguration.fromConfiguration(configuration),
+                        eventRecorder);
+
+        var dep =
+                new DeploymentBuilder()
+                        .withNewMetadata()
+                        .withName(TestUtils.TEST_DEPLOYMENT_NAME)
+                        .withNamespace(TestUtils.TEST_NAMESPACE)
+                        .endMetadata()
+                        .build();
+        DeploymentList deploymentList =
+                new DeploymentListBuilder().withMetadata(new 
ListMeta()).withItems(dep).build();
+
+        String getUrl =
+                String.format(
+                        
"/apis/apps/v1/namespaces/%s/deployments?fieldSelector=metadata.name%%3D%s",
+                        TestUtils.TEST_NAMESPACE, 
TestUtils.TEST_DEPLOYMENT_NAME);
+        String watchUrl =
+                String.format(
+                        
"/apis/apps/v1/namespaces/%s/deployments?allowWatchBookmarks=true&fieldSelector=metadata.name%%3D%s&timeoutSeconds=600&watch=true",
+                        TestUtils.TEST_NAMESPACE, 
TestUtils.TEST_DEPLOYMENT_NAME);
+
+        // GET always returns the deployment; watch never emits DELETED within 
timeout
+        mockServer.expect().get().withPath(getUrl).andReturn(200, 
deploymentList).always();
+        mockServer
+                .expect()
+                .get()
+                .withPath(watchUrl)
+                .andUpgradeToWebSocket()
+                .open()
+                .waitFor(5000)
+                .andEmit(new WatchEvent(dep, "DELETED"))
+                .done()
+                .always();
+
+        var deployment = TestUtils.buildApplicationCluster();
+        ReconciliationUtils.updateStatusForDeployedSpec(deployment, new 
Configuration());
+
+        assertThrows(
+                KubernetesClientTimeoutException.class,
+                () ->
+                        flinkService.deleteClusterInternal(
+                                TestUtils.TEST_NAMESPACE,
+                                TestUtils.TEST_DEPLOYMENT_NAME,
+                                configManager.getObserveConfig(deployment),
+                                DeletionPropagation.BACKGROUND));
+    }
+
     @ParameterizedTest
     
@MethodSource("org.apache.flink.kubernetes.operator.TestUtils#flinkVersions")
     public void testDeleteOnSavepointBefore1_15(FlinkVersion flinkVersion) 
throws Exception {
diff --git 
a/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/service/StandaloneFlinkServiceTest.java
 
b/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/service/StandaloneFlinkServiceTest.java
index a871466b..e4e245fa 100644
--- 
a/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/service/StandaloneFlinkServiceTest.java
+++ 
b/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/service/StandaloneFlinkServiceTest.java
@@ -29,23 +29,34 @@ import 
org.apache.flink.kubernetes.operator.api.spec.JobSpec;
 import org.apache.flink.kubernetes.operator.api.spec.KubernetesDeploymentMode;
 import org.apache.flink.kubernetes.operator.artifact.ArtifactManager;
 import org.apache.flink.kubernetes.operator.config.FlinkConfigManager;
+import org.apache.flink.kubernetes.operator.config.FlinkOperatorConfiguration;
+import 
org.apache.flink.kubernetes.operator.config.KubernetesOperatorConfigOptions;
 import org.apache.flink.kubernetes.operator.utils.StandaloneKubernetesUtils;
 import org.apache.flink.util.concurrent.Executors;
 
+import io.fabric8.kubernetes.api.model.DeletionPropagation;
+import io.fabric8.kubernetes.api.model.ListMeta;
 import io.fabric8.kubernetes.api.model.ObjectMeta;
+import io.fabric8.kubernetes.api.model.WatchEvent;
 import io.fabric8.kubernetes.api.model.apps.Deployment;
+import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder;
+import io.fabric8.kubernetes.api.model.apps.DeploymentList;
+import io.fabric8.kubernetes.api.model.apps.DeploymentListBuilder;
 import io.fabric8.kubernetes.api.model.apps.DeploymentSpec;
+import io.fabric8.kubernetes.client.KubernetesClientTimeoutException;
 import io.fabric8.kubernetes.client.NamespacedKubernetesClient;
 import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient;
 import io.fabric8.kubernetes.client.server.mock.KubernetesMockServer;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
+import java.time.Duration;
 import java.util.List;
 
 import static 
org.apache.flink.kubernetes.operator.config.KubernetesOperatorConfigOptions.OPERATOR_HEALTH_PROBE_PORT;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /**
@@ -59,10 +70,10 @@ public class StandaloneFlinkServiceTest {
     StandaloneFlinkService flinkStandaloneService;
     FlinkConfigManager configManager;
     FlinkDeployment flinkDeployment;
+    private final Configuration configuration = new Configuration();
 
     @BeforeEach
     public void setup() {
-        var configuration = new Configuration();
         configuration.set(KubernetesConfigOptions.CLUSTER_ID, 
TestUtils.TEST_DEPLOYMENT_NAME);
         configuration.set(KubernetesConfigOptions.NAMESPACE, 
TestUtils.TEST_NAMESPACE);
         configuration.set(OPERATOR_HEALTH_PROBE_PORT, 80);
@@ -261,6 +272,122 @@ public class StandaloneFlinkServiceTest {
         
assertFalse(service.getRuntimeConfig().containsKey(OPERATOR_HEALTH_PROBE_PORT.key()));
     }
 
+    @Test
+    public void testDeleteClusterInternalThrowsOnDeploymentDeletionTimeout() {
+        var timeout = Duration.ofMillis(200);
+        configuration.set(
+                
KubernetesOperatorConfigOptions.OPERATOR_RESOURCE_CLEANUP_TIMEOUT, timeout);
+        var service =
+                new StandaloneFlinkService(
+                        kubernetesClient,
+                        new ArtifactManager(configManager),
+                        Executors.newDirectExecutorService(),
+                        
FlinkOperatorConfiguration.fromConfiguration(configuration));
+
+        String jmDeploymentName =
+                StandaloneKubernetesUtils.getJobManagerDeploymentName(
+                        TestUtils.TEST_DEPLOYMENT_NAME);
+
+        var dep =
+                new DeploymentBuilder()
+                        .withNewMetadata()
+                        .withName(jmDeploymentName)
+                        .withNamespace(TestUtils.TEST_NAMESPACE)
+                        .endMetadata()
+                        .build();
+        DeploymentList deploymentList =
+                new DeploymentListBuilder().withMetadata(new 
ListMeta()).withItems(dep).build();
+
+        String getUrl =
+                String.format(
+                        
"/apis/apps/v1/namespaces/%s/deployments?fieldSelector=metadata.name%%3D%s",
+                        TestUtils.TEST_NAMESPACE, jmDeploymentName);
+        String watchUrl =
+                String.format(
+                        
"/apis/apps/v1/namespaces/%s/deployments?allowWatchBookmarks=true&fieldSelector=metadata.name%%3D%s&timeoutSeconds=600&watch=true",
+                        TestUtils.TEST_NAMESPACE, jmDeploymentName);
+
+        // GET always returns the deployment; watch never emits DELETED within 
timeout
+        mockServer.expect().get().withPath(getUrl).andReturn(200, 
deploymentList).always();
+        mockServer
+                .expect()
+                .get()
+                .withPath(watchUrl)
+                .andUpgradeToWebSocket()
+                .open()
+                .waitFor(5000)
+                .andEmit(new WatchEvent(dep, "DELETED"))
+                .done()
+                .always();
+
+        assertThrows(
+                KubernetesClientTimeoutException.class,
+                () ->
+                        service.deleteClusterInternal(
+                                TestUtils.TEST_NAMESPACE,
+                                TestUtils.TEST_DEPLOYMENT_NAME,
+                                new Configuration(),
+                                DeletionPropagation.BACKGROUND));
+    }
+
+    @Test
+    public void testDeleteClusterInternalThrowsOnTaskManagerDeletionTimeout() {
+        var timeout = Duration.ofMillis(200);
+        configuration.set(
+                
KubernetesOperatorConfigOptions.OPERATOR_RESOURCE_CLEANUP_TIMEOUT, timeout);
+        var service =
+                new StandaloneFlinkService(
+                        kubernetesClient,
+                        new ArtifactManager(configManager),
+                        Executors.newDirectExecutorService(),
+                        
FlinkOperatorConfiguration.fromConfiguration(configuration));
+
+        String tmDeploymentName =
+                StandaloneKubernetesUtils.getTaskManagerDeploymentName(
+                        TestUtils.TEST_DEPLOYMENT_NAME);
+
+        var dep =
+                new DeploymentBuilder()
+                        .withNewMetadata()
+                        .withName(tmDeploymentName)
+                        .withNamespace(TestUtils.TEST_NAMESPACE)
+                        .endMetadata()
+                        .build();
+        DeploymentList deploymentList =
+                new DeploymentListBuilder().withMetadata(new 
ListMeta()).withItems(dep).build();
+
+        String getUrl =
+                String.format(
+                        
"/apis/apps/v1/namespaces/%s/deployments?fieldSelector=metadata.name%%3D%s",
+                        TestUtils.TEST_NAMESPACE, tmDeploymentName);
+        String watchUrl =
+                String.format(
+                        
"/apis/apps/v1/namespaces/%s/deployments?allowWatchBookmarks=true&fieldSelector=metadata.name%%3D%s&timeoutSeconds=600&watch=true",
+                        TestUtils.TEST_NAMESPACE, tmDeploymentName);
+
+        // GET always returns the deployment; watch never emits DELETED within 
timeout
+        mockServer.expect().get().withPath(getUrl).andReturn(200, 
deploymentList).always();
+        mockServer
+                .expect()
+                .get()
+                .withPath(watchUrl)
+                .andUpgradeToWebSocket()
+                .open()
+                .waitFor(5000)
+                .andEmit(new WatchEvent(dep, "DELETED"))
+                .done()
+                .always();
+
+        assertThrows(
+                KubernetesClientTimeoutException.class,
+                () ->
+                        service.deleteClusterInternal(
+                                TestUtils.TEST_NAMESPACE,
+                                TestUtils.TEST_DEPLOYMENT_NAME,
+                                new Configuration(),
+                                DeletionPropagation.BACKGROUND));
+    }
+
     class TestingStandaloneFlinkService extends StandaloneFlinkService {
         Configuration runtimeConfig;
 

Reply via email to