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 49f33337 [FLINK-29394] Add observe Flink job health
49f33337 is described below

commit 49f3333796b7c5dae2a441d453b67d7e8bcee3bf
Author: Gabor Somogyi <[email protected]>
AuthorDate: Mon Sep 26 10:27:45 2022 +0200

    [FLINK-29394] Add observe Flink job health
---
 .../content/docs/custom-resource/job-management.md |   9 +
 docs/content/docs/custom-resource/reference.md     |   2 +-
 .../shortcodes/generated/dynamic_section.html      |  18 ++
 .../kubernetes_operator_config_configuration.html  |  18 ++
 .../config/KubernetesOperatorConfigOptions.java    |  24 +++
 .../operator/crd/status/FlinkDeploymentStatus.java |   2 +-
 .../operator/health/ClusterHealthInfo.java         |  76 ++++++++
 .../operator/observer/ClusterHealthEvaluator.java  | 146 +++++++++++++++
 .../operator/observer/ClusterHealthObserver.java   |  90 +++++++++
 .../deployment/AbstractDeploymentObserver.java     |  10 +-
 .../observer/deployment/ApplicationObserver.java   |   9 +
 .../AbstractFlinkResourceReconciler.java           |  30 +--
 .../deployment/ApplicationReconciler.java          |  47 ++++-
 .../operator/service/AbstractFlinkService.java     |  23 +++
 .../kubernetes/operator/service/FlinkService.java  |   4 +
 .../operator/validation/DefaultValidator.java      |  14 ++
 .../kubernetes/operator/TestingFlinkService.java   |  12 ++
 .../controller/UnhealthyDeploymentRestartTest.java | 117 ++++++++++++
 .../operator/health/ClusterHealthInfoTest.java     |  53 ++++++
 .../observer/ClusterHealthEvaluatorTest.java       | 202 +++++++++++++++++++++
 .../operator/validation/DefaultValidatorTest.java  |  18 ++
 21 files changed, 900 insertions(+), 24 deletions(-)

diff --git a/docs/content/docs/custom-resource/job-management.md 
b/docs/content/docs/custom-resource/job-management.md
index b23880bc..ee00a117 100644
--- a/docs/content/docs/custom-resource/job-management.md
+++ b/docs/content/docs/custom-resource/job-management.md
@@ -232,6 +232,15 @@ In these cases no recovery will be performed to avoid 
dataloss and an error will
 
 Please check the [manual Recovery section](#manual-recovery) to understand how 
to recover from these situations.
 
+## Restart of unhealthy job deployments
+
+When Kubernetes HA is enabled, the operator can restart the Flink cluster 
deployments in cases when it was considered
+unhealthy. Unhealthy deployment restart can be turned on in the configuration 
by setting `kubernetes.operator.cluster.health-check.enabled` to `true` 
(default: `false`).  
+In order this feature to work one must enable [recovery of missing job 
deployments](#recovery-of-missing-job-deployments).
+
+At the moment deployment is considered unhealthy when Flink's restarts count 
reaches `kubernetes.operator.cluster.health-check.restarts.threshold` (default: 
`64`)
+within time window of 
`kubernetes.operator.cluster.health-check.restarts.window` (default: 2 minutes).
+
 ## Application upgrade rollbacks (Experimental)
 
 The operator supports upgrade rollbacks as an experimental feature.
diff --git a/docs/content/docs/custom-resource/reference.md 
b/docs/content/docs/custom-resource/reference.md
index 924e89f5..36c11f65 100644
--- a/docs/content/docs/custom-resource/reference.md
+++ b/docs/content/docs/custom-resource/reference.md
@@ -197,7 +197,7 @@ This page serves as a full reference for FlinkDeployment 
custom resource definit
 | ----------| ---- | ---- |
 | jobStatus | org.apache.flink.kubernetes.operator.crd.status.JobStatus | Last 
observed status of the Flink job on Application/Session cluster. |
 | error | java.lang.String | Error information about the 
FlinkDeployment/FlinkSessionJob. |
-| clusterInfo | java.util.Map<java.lang.String,java.lang.String> | Config 
information from running clusters. |
+| clusterInfo | java.util.Map<java.lang.String,java.lang.String> | Information 
from running clusters. |
 | jobManagerDeploymentStatus | 
org.apache.flink.kubernetes.operator.crd.status.JobManagerDeploymentStatus | 
Last observed status of the JobManager deployment. |
 | reconciliationStatus | 
org.apache.flink.kubernetes.operator.crd.status.FlinkDeploymentReconciliationStatus
 | Status of the last reconcile operation. |
 | taskManager | 
org.apache.flink.kubernetes.operator.crd.status.TaskManagerInfo | Information 
about the TaskManagers for the scale subresource. |
diff --git a/docs/layouts/shortcodes/generated/dynamic_section.html 
b/docs/layouts/shortcodes/generated/dynamic_section.html
index 4cfac307..79696e38 100644
--- a/docs/layouts/shortcodes/generated/dynamic_section.html
+++ b/docs/layouts/shortcodes/generated/dynamic_section.html
@@ -8,6 +8,24 @@
         </tr>
     </thead>
     <tbody>
+        <tr>
+            <td><h5>kubernetes.operator.cluster.health-check.enabled</h5></td>
+            <td style="word-wrap: break-word;">false</td>
+            <td>Boolean</td>
+            <td>Whether to enable health check for clusters.</td>
+        </tr>
+        <tr>
+            
<td><h5>kubernetes.operator.cluster.health-check.restarts.threshold</h5></td>
+            <td style="word-wrap: break-word;">64</td>
+            <td>Integer</td>
+            <td>The threshold which is checked against job restart count 
within a configured window. If the restart count is reaching the threshold then 
full cluster restart is initiated.</td>
+        </tr>
+        <tr>
+            
<td><h5>kubernetes.operator.cluster.health-check.restarts.window</h5></td>
+            <td style="word-wrap: break-word;">2 min</td>
+            <td>Duration</td>
+            <td>The duration of the time window where job restart count 
measured.</td>
+        </tr>
         <tr>
             <td><h5>kubernetes.operator.deployment.readiness.timeout</h5></td>
             <td style="word-wrap: break-word;">1 min</td>
diff --git 
a/docs/layouts/shortcodes/generated/kubernetes_operator_config_configuration.html
 
b/docs/layouts/shortcodes/generated/kubernetes_operator_config_configuration.html
index f00a1204..acdcaccb 100644
--- 
a/docs/layouts/shortcodes/generated/kubernetes_operator_config_configuration.html
+++ 
b/docs/layouts/shortcodes/generated/kubernetes_operator_config_configuration.html
@@ -8,6 +8,24 @@
         </tr>
     </thead>
     <tbody>
+        <tr>
+            <td><h5>kubernetes.operator.cluster.health-check.enabled</h5></td>
+            <td style="word-wrap: break-word;">false</td>
+            <td>Boolean</td>
+            <td>Whether to enable health check for clusters.</td>
+        </tr>
+        <tr>
+            
<td><h5>kubernetes.operator.cluster.health-check.restarts.threshold</h5></td>
+            <td style="word-wrap: break-word;">64</td>
+            <td>Integer</td>
+            <td>The threshold which is checked against job restart count 
within a configured window. If the restart count is reaching the threshold then 
full cluster restart is initiated.</td>
+        </tr>
+        <tr>
+            
<td><h5>kubernetes.operator.cluster.health-check.restarts.window</h5></td>
+            <td style="word-wrap: break-word;">2 min</td>
+            <td>Duration</td>
+            <td>The duration of the time window where job restart count 
measured.</td>
+        </tr>
         <tr>
             <td><h5>kubernetes.operator.config.cache.size</h5></td>
             <td style="word-wrap: break-word;">1000</td>
diff --git 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/config/KubernetesOperatorConfigOptions.java
 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/config/KubernetesOperatorConfigOptions.java
index 18664a1f..8c18f249 100644
--- 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/config/KubernetesOperatorConfigOptions.java
+++ 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/config/KubernetesOperatorConfigOptions.java
@@ -315,4 +315,28 @@ public class KubernetesOperatorConfigOptions {
                     .intType()
                     .defaultValue(8085)
                     .withDescription("The port the health probe will use to 
expose the status.");
+
+    @Documentation.Section(SECTION_DYNAMIC)
+    public static final ConfigOption<Boolean> 
OPERATOR_CLUSTER_HEALTH_CHECK_ENABLED =
+            operatorConfig("cluster.health-check.enabled")
+                    .booleanType()
+                    .defaultValue(false)
+                    .withDescription("Whether to enable health check for 
clusters.");
+
+    @Documentation.Section(SECTION_DYNAMIC)
+    public static final ConfigOption<Duration> 
OPERATOR_CLUSTER_HEALTH_CHECK_RESTARTS_WINDOW =
+            operatorConfig("cluster.health-check.restarts.window")
+                    .durationType()
+                    .defaultValue(Duration.ofMinutes(2))
+                    .withDescription(
+                            "The duration of the time window where job restart 
count measured.");
+
+    @Documentation.Section(SECTION_DYNAMIC)
+    public static final ConfigOption<Integer> 
OPERATOR_CLUSTER_HEALTH_CHECK_RESTARTS_THRESHOLD =
+            operatorConfig("cluster.health-check.restarts.threshold")
+                    .intType()
+                    .defaultValue(64)
+                    .withDescription(
+                            "The threshold which is checked against job 
restart count within a configured window. "
+                                    + "If the restart count is reaching the 
threshold then full cluster restart is initiated.");
 }
diff --git 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/crd/status/FlinkDeploymentStatus.java
 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/crd/status/FlinkDeploymentStatus.java
index c2f6d0d2..c9367c70 100644
--- 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/crd/status/FlinkDeploymentStatus.java
+++ 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/crd/status/FlinkDeploymentStatus.java
@@ -40,7 +40,7 @@ import java.util.Map;
 @SuperBuilder
 public class FlinkDeploymentStatus extends CommonStatus<FlinkDeploymentSpec> {
 
-    /** Config information from running clusters. */
+    /** Information from running clusters. */
     private Map<String, String> clusterInfo = new HashMap<>();
 
     /** Last observed status of the JobManager deployment. */
diff --git 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/health/ClusterHealthInfo.java
 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/health/ClusterHealthInfo.java
new file mode 100644
index 00000000..bb76f862
--- /dev/null
+++ 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/health/ClusterHealthInfo.java
@@ -0,0 +1,76 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.flink.kubernetes.operator.health;
+
+import org.apache.flink.annotation.Experimental;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.Clock;
+
+/** Represents information about job health. */
+@Experimental
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+public class ClusterHealthInfo {
+    /** Millisecond timestamp of the last observed health information. */
+    private long timeStamp;
+
+    /** Number of restarts. */
+    private int numRestarts;
+
+    /** Calculated field whether the cluster is healthy or not. */
+    private boolean healthy;
+
+    public static ClusterHealthInfo of(int numRestarts) {
+        return of(Clock.systemDefaultZone(), numRestarts);
+    }
+
+    public static ClusterHealthInfo of(Clock clock, int numRestarts) {
+        return new ClusterHealthInfo(clock.millis(), numRestarts, true);
+    }
+
+    public static boolean isValid(ClusterHealthInfo clusterHealthInfo) {
+        return clusterHealthInfo.timeStamp != 0;
+    }
+
+    public static String serialize(ClusterHealthInfo clusterHealthInfo) {
+        ObjectMapper objectMapper = new ObjectMapper();
+        try {
+            return objectMapper.writeValueAsString(clusterHealthInfo);
+        } catch (JsonProcessingException e) {
+            throw new RuntimeException(
+                    "Could not serialize ClusterHealthInfo, this indicates a 
bug...", e);
+        }
+    }
+
+    public static ClusterHealthInfo deserialize(String data) {
+        ObjectMapper objectMapper = new ObjectMapper();
+        try {
+            return objectMapper.readValue(data, ClusterHealthInfo.class);
+        } catch (JsonProcessingException e) {
+            throw new RuntimeException(
+                    "Could not deserialize ClusterHealthInfo, this indicates a 
bug...", e);
+        }
+    }
+}
diff --git 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/observer/ClusterHealthEvaluator.java
 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/observer/ClusterHealthEvaluator.java
new file mode 100644
index 00000000..4f5f3dc6
--- /dev/null
+++ 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/observer/ClusterHealthEvaluator.java
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.flink.kubernetes.operator.observer;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.kubernetes.operator.health.ClusterHealthInfo;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.util.Map;
+
+import static 
org.apache.flink.kubernetes.operator.config.KubernetesOperatorConfigOptions.OPERATOR_CLUSTER_HEALTH_CHECK_RESTARTS_THRESHOLD;
+import static 
org.apache.flink.kubernetes.operator.config.KubernetesOperatorConfigOptions.OPERATOR_CLUSTER_HEALTH_CHECK_RESTARTS_WINDOW;
+
+/** Evaluates whether the cluster is healthy. */
+public class ClusterHealthEvaluator {
+
+    private static final String CLUSTER_INFO_KEY = 
ClusterHealthInfo.class.getSimpleName();
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(ClusterHealthEvaluator.class);
+
+    private final Clock clock;
+
+    public ClusterHealthEvaluator(Clock clock) {
+        this.clock = clock;
+    }
+
+    public static ClusterHealthInfo getLastValidClusterHealthInfo(Map<String, 
String> clusterInfo) {
+        LOG.debug("Getting last valid health check info");
+        if (clusterInfo.containsKey(CLUSTER_INFO_KEY)) {
+            return 
ClusterHealthInfo.deserialize(clusterInfo.get(CLUSTER_INFO_KEY));
+        } else {
+            LOG.debug("No last valid health check info");
+            return null;
+        }
+    }
+
+    public static void setLastValidClusterHealthInfo(
+            Map<String, String> clusterInfo, ClusterHealthInfo 
clusterHealthInfo) {
+        LOG.debug("Setting last valid health check info");
+        clusterInfo.put(CLUSTER_INFO_KEY, 
ClusterHealthInfo.serialize(clusterHealthInfo));
+    }
+
+    public static void removeLastValidClusterHealthInfo(Map<String, String> 
clusterInfo) {
+        LOG.debug("Removing last valid health check info");
+        clusterInfo.remove(CLUSTER_INFO_KEY);
+    }
+
+    public void evaluate(
+            Configuration configuration,
+            Map<String, String> clusterInfo,
+            ClusterHealthInfo observedClusterHealthInfo) {
+
+        if (ClusterHealthInfo.isValid(observedClusterHealthInfo)) {
+            LOG.debug("Observed health info is valid");
+
+            var lastValidClusterHealthInfo = 
getLastValidClusterHealthInfo(clusterInfo);
+            if (lastValidClusterHealthInfo == null) {
+                LOG.debug("No last valid health info, skipping health check");
+                setLastValidClusterHealthInfo(clusterInfo, 
observedClusterHealthInfo);
+            } else if (observedClusterHealthInfo.getTimeStamp()
+                    < lastValidClusterHealthInfo.getTimeStamp()) {
+                String msg =
+                        "Observed health info timestamp is less than the last 
valid health info timestamp, this indicates a bug...";
+                LOG.error(msg);
+                throw new IllegalStateException(msg);
+            } else if (observedClusterHealthInfo.getNumRestarts()
+                    < lastValidClusterHealthInfo.getNumRestarts()) {
+                LOG.debug(
+                        "Observed health info number of restarts is less than 
the last valid health info number of restarts, skipping health check");
+                setLastValidClusterHealthInfo(clusterInfo, 
observedClusterHealthInfo);
+            } else {
+                boolean isHealthy = true;
+
+                LOG.debug("Valid health info exist, checking cluster health");
+                LOG.debug("Last valid health info: {}", 
lastValidClusterHealthInfo);
+                LOG.debug("Observed health info: {}", 
observedClusterHealthInfo);
+
+                var timestampDiffMs =
+                        observedClusterHealthInfo.getTimeStamp()
+                                - lastValidClusterHealthInfo.getTimeStamp();
+                LOG.debug(
+                        "Time difference between health infos: {}",
+                        Duration.ofMillis(timestampDiffMs));
+
+                var restartCheckWindow =
+                        
configuration.get(OPERATOR_CLUSTER_HEALTH_CHECK_RESTARTS_WINDOW);
+                var restartCheckWindowMs = restartCheckWindow.toMillis();
+                double countMultiplier = (double) restartCheckWindowMs / 
(double) timestampDiffMs;
+                // If the 2 health info timestamp difference is within the 
window then no
+                // scaling needed
+                if (countMultiplier > 1) {
+                    countMultiplier = 1;
+                }
+                long numRestarts =
+                        (long)
+                                ((double)
+                                                
(observedClusterHealthInfo.getNumRestarts()
+                                                        - 
lastValidClusterHealthInfo
+                                                                
.getNumRestarts())
+                                        * countMultiplier);
+                LOG.debug(
+                        "Calculated restart count for {} window: {}",
+                        restartCheckWindow,
+                        numRestarts);
+
+                if (lastValidClusterHealthInfo.getTimeStamp()
+                        < clock.millis() - restartCheckWindowMs) {
+                    LOG.debug("Last valid health info timestamp is outside of 
the window");
+                    setLastValidClusterHealthInfo(clusterInfo, 
observedClusterHealthInfo);
+                }
+
+                var restartThreshold =
+                        
configuration.get(OPERATOR_CLUSTER_HEALTH_CHECK_RESTARTS_THRESHOLD);
+                if (numRestarts > restartThreshold) {
+                    LOG.info("Restart count hit threshold: {}", 
restartThreshold);
+                    setLastValidClusterHealthInfo(clusterInfo, 
observedClusterHealthInfo);
+                    isHealthy = false;
+                }
+
+                // Update the health flag
+                lastValidClusterHealthInfo = 
getLastValidClusterHealthInfo(clusterInfo);
+                lastValidClusterHealthInfo.setHealthy(isHealthy);
+                setLastValidClusterHealthInfo(clusterInfo, 
lastValidClusterHealthInfo);
+            }
+        }
+    }
+}
diff --git 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/observer/ClusterHealthObserver.java
 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/observer/ClusterHealthObserver.java
new file mode 100644
index 00000000..8bb042f9
--- /dev/null
+++ 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/observer/ClusterHealthObserver.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.flink.kubernetes.operator.observer;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.kubernetes.operator.crd.FlinkDeployment;
+import org.apache.flink.kubernetes.operator.health.ClusterHealthInfo;
+import org.apache.flink.kubernetes.operator.service.FlinkService;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Clock;
+import java.util.List;
+
+/** An observer to observe the cluster health. */
+public class ClusterHealthObserver {
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(ClusterHealthObserver.class);
+    private static final String FULL_RESTARTS_METRIC_NAME = "fullRestarts";
+    private static final String NUM_RESTARTS_METRIC_NAME = "numRestarts";
+    private final FlinkService flinkService;
+    private final ClusterHealthEvaluator clusterHealthEvaluator;
+
+    public ClusterHealthObserver(FlinkService flinkService) {
+        this.flinkService = flinkService;
+        this.clusterHealthEvaluator = new 
ClusterHealthEvaluator(Clock.systemDefaultZone());
+    }
+
+    /** Observe the health of the flink cluster. */
+    public void observe(FlinkDeployment flinkApp, Configuration 
deployedConfig) {
+        try {
+            LOG.info("Observing cluster health");
+            var deploymentStatus = flinkApp.getStatus();
+            var jobStatus = deploymentStatus.getJobStatus();
+            var jobId = jobStatus.getJobId();
+            var metrics =
+                    flinkService.getMetrics(
+                            deployedConfig,
+                            jobId,
+                            List.of(FULL_RESTARTS_METRIC_NAME, 
NUM_RESTARTS_METRIC_NAME));
+            ClusterHealthInfo observedClusterHealthInfo;
+            if (metrics.containsKey(NUM_RESTARTS_METRIC_NAME)) {
+                LOG.debug(NUM_RESTARTS_METRIC_NAME + " metric is used");
+                observedClusterHealthInfo =
+                        ClusterHealthInfo.of(
+                                
Integer.parseInt(metrics.get(NUM_RESTARTS_METRIC_NAME)));
+            } else if (metrics.containsKey(FULL_RESTARTS_METRIC_NAME)) {
+                LOG.debug(
+                        FULL_RESTARTS_METRIC_NAME
+                                + " metric is used because "
+                                + NUM_RESTARTS_METRIC_NAME
+                                + " is missing");
+                observedClusterHealthInfo =
+                        ClusterHealthInfo.of(
+                                
Integer.parseInt(metrics.get(FULL_RESTARTS_METRIC_NAME)));
+            } else {
+                throw new IllegalStateException(
+                        "No job restart metric found. Either "
+                                + FULL_RESTARTS_METRIC_NAME
+                                + "(old and deprecated in never Flink 
versions) or "
+                                + NUM_RESTARTS_METRIC_NAME
+                                + "(new) must exist.");
+            }
+            LOG.debug("Observed cluster health: {}", 
observedClusterHealthInfo);
+
+            clusterHealthEvaluator.evaluate(
+                    deployedConfig, deploymentStatus.getClusterInfo(), 
observedClusterHealthInfo);
+        } catch (Exception e) {
+            LOG.warn("Exception while observing cluster health: {}", 
e.getMessage());
+            // Intentionally not throwing exception since we handle fetch 
metrics failure as
+            // temporary issue
+        }
+    }
+}
diff --git 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/observer/deployment/AbstractDeploymentObserver.java
 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/observer/deployment/AbstractDeploymentObserver.java
index 744ecd64..7319e923 100644
--- 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/observer/deployment/AbstractDeploymentObserver.java
+++ 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/observer/deployment/AbstractDeploymentObserver.java
@@ -47,7 +47,6 @@ import io.javaoperatorsdk.operator.api.reconciler.Context;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -116,13 +115,10 @@ public abstract class AbstractDeploymentObserver 
implements Observer<FlinkDeploy
     }
 
     private void observeClusterInfo(FlinkDeployment flinkApp, Configuration 
configuration) {
-        if (!flinkApp.getStatus().getClusterInfo().isEmpty()) {
-            return;
-        }
         try {
             Map<String, String> clusterInfo = 
flinkService.getClusterInfo(configuration);
-            flinkApp.getStatus().setClusterInfo(clusterInfo);
-            logger.debug("ClusterInfo: {}", clusterInfo);
+            flinkApp.getStatus().getClusterInfo().putAll(clusterInfo);
+            logger.debug("ClusterInfo: {}", 
flinkApp.getStatus().getClusterInfo());
         } catch (Exception e) {
             logger.error("Exception while fetching cluster info", e);
         }
@@ -139,8 +135,6 @@ public abstract class AbstractDeploymentObserver implements 
Observer<FlinkDeploy
             return;
         }
 
-        flinkApp.getStatus().setClusterInfo(new HashMap<>());
-
         logger.info(
                 "Observing JobManager deployment. Previous status: {}", 
previousJmStatus.name());
 
diff --git 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/observer/deployment/ApplicationObserver.java
 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/observer/deployment/ApplicationObserver.java
index a2eac7e3..c41ee389 100644
--- 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/observer/deployment/ApplicationObserver.java
+++ 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/observer/deployment/ApplicationObserver.java
@@ -22,6 +22,7 @@ import 
org.apache.flink.kubernetes.operator.config.FlinkConfigManager;
 import org.apache.flink.kubernetes.operator.crd.FlinkDeployment;
 import org.apache.flink.kubernetes.operator.crd.status.FlinkDeploymentStatus;
 import org.apache.flink.kubernetes.operator.crd.status.JobStatus;
+import org.apache.flink.kubernetes.operator.observer.ClusterHealthObserver;
 import org.apache.flink.kubernetes.operator.observer.JobStatusObserver;
 import org.apache.flink.kubernetes.operator.observer.SavepointObserver;
 import 
org.apache.flink.kubernetes.operator.observer.context.ApplicationObserverContext;
@@ -35,12 +36,16 @@ import io.javaoperatorsdk.operator.api.reconciler.Context;
 import java.util.List;
 import java.util.Optional;
 
+import static 
org.apache.flink.kubernetes.operator.config.KubernetesOperatorConfigOptions.OPERATOR_CLUSTER_HEALTH_CHECK_ENABLED;
+
 /** The observer of {@link 
org.apache.flink.kubernetes.operator.config.Mode#APPLICATION} cluster. */
 public class ApplicationObserver extends AbstractDeploymentObserver {
 
     private final SavepointObserver<FlinkDeployment, FlinkDeploymentStatus> 
savepointObserver;
     private final JobStatusObserver<FlinkDeployment, 
ApplicationObserverContext> jobStatusObserver;
 
+    private final ClusterHealthObserver clusterHealthObserver;
+
     public ApplicationObserver(
             FlinkService flinkService,
             FlinkConfigManager configManager,
@@ -94,6 +99,7 @@ public class ApplicationObserver extends 
AbstractDeploymentObserver {
                                 err);
                     }
                 };
+        this.clusterHealthObserver = new ClusterHealthObserver(flinkService);
     }
 
     @Override
@@ -108,6 +114,9 @@ public class ApplicationObserver extends 
AbstractDeploymentObserver {
                         new ApplicationObserverContext(flinkApp, context, 
deployedConfig));
         if (jobFound) {
             savepointObserver.observeSavepointStatus(flinkApp, deployedConfig);
+            if 
(deployedConfig.getBoolean(OPERATOR_CLUSTER_HEALTH_CHECK_ENABLED)) {
+                clusterHealthObserver.observe(flinkApp, deployedConfig);
+            }
         }
     }
 }
diff --git 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/reconciler/deployment/AbstractFlinkResourceReconciler.java
 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/reconciler/deployment/AbstractFlinkResourceReconciler.java
index c915d5fd..11bb82fd 100644
--- 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/reconciler/deployment/AbstractFlinkResourceReconciler.java
+++ 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/reconciler/deployment/AbstractFlinkResourceReconciler.java
@@ -399,23 +399,27 @@ public abstract class AbstractFlinkResourceReconciler<
      * @param deployment FlinkDeployment object.
      * @return True if recovery should be executed.
      */
-    protected static boolean shouldRecoverDeployment(
-            Configuration conf, FlinkDeployment deployment) {
-
-        if (!jmMissingForRunningDeployment(deployment)
-                || !conf.get(
-                        
KubernetesOperatorConfigOptions.OPERATOR_JM_DEPLOYMENT_RECOVERY_ENABLED)) {
-            return false;
+    protected boolean shouldRecoverDeployment(Configuration conf, 
FlinkDeployment deployment) {
+        boolean result = false;
+
+        if 
(conf.get(KubernetesOperatorConfigOptions.OPERATOR_JM_DEPLOYMENT_RECOVERY_ENABLED))
 {
+            LOG.debug("Checking whether jobmanager deployment needs recovery");
+
+            if (jmMissingForRunningDeployment(deployment)) {
+                LOG.debug("Jobmanager deployment is missing, trying to 
recover");
+                if (FlinkUtils.isKubernetesHAActivated(conf)) {
+                    LOG.debug("HA is enabled, recovering lost jobmanager 
deployment");
+                    result = true;
+                } else {
+                    LOG.warn("Could not recover lost jobmanager deployment 
without HA enabled");
+                }
+            }
         }
 
-        if (!FlinkUtils.isKubernetesHAActivated(conf)) {
-            LOG.warn("Could not recover lost deployment without HA enabled");
-            return false;
-        }
-        return true;
+        return result;
     }
 
-    private static boolean jmMissingForRunningDeployment(FlinkDeployment 
deployment) {
+    private boolean jmMissingForRunningDeployment(FlinkDeployment deployment) {
         var deployedJob = 
ReconciliationUtils.getDeployedSpec(deployment).getJob();
         return (deployedJob == null || deployedJob.getState() == 
JobState.RUNNING)
                 && (deployment.getStatus().getJobManagerDeploymentStatus()
diff --git 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/reconciler/deployment/ApplicationReconciler.java
 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/reconciler/deployment/ApplicationReconciler.java
index c3798786..1c7c84d8 100644
--- 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/reconciler/deployment/ApplicationReconciler.java
+++ 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/reconciler/deployment/ApplicationReconciler.java
@@ -31,6 +31,8 @@ import 
org.apache.flink.kubernetes.operator.crd.spec.UpgradeMode;
 import org.apache.flink.kubernetes.operator.crd.status.FlinkDeploymentStatus;
 import 
org.apache.flink.kubernetes.operator.crd.status.JobManagerDeploymentStatus;
 import 
org.apache.flink.kubernetes.operator.exception.DeploymentFailedException;
+import org.apache.flink.kubernetes.operator.health.ClusterHealthInfo;
+import org.apache.flink.kubernetes.operator.observer.ClusterHealthEvaluator;
 import org.apache.flink.kubernetes.operator.reconciler.ReconciliationUtils;
 import org.apache.flink.kubernetes.operator.service.FlinkService;
 import org.apache.flink.kubernetes.operator.utils.EventRecorder;
@@ -51,6 +53,8 @@ import org.slf4j.LoggerFactory;
 import java.util.Optional;
 import java.util.UUID;
 
+import static 
org.apache.flink.kubernetes.operator.config.KubernetesOperatorConfigOptions.OPERATOR_CLUSTER_HEALTH_CHECK_ENABLED;
+
 /** Reconciler Flink Application deployments. */
 public class ApplicationReconciler
         extends AbstractJobReconciler<FlinkDeployment, FlinkDeploymentSpec, 
FlinkDeploymentStatus> {
@@ -258,10 +262,17 @@ public class ApplicationReconciler
             return true;
         }
 
-        if (shouldRecoverDeployment(observeConfig, deployment)) {
+        boolean shouldRestartJobBecauseUnhealthy =
+                shouldRestartJobBecauseUnhealthy(deployment, observeConfig);
+        boolean shouldRecoverDeployment = 
shouldRecoverDeployment(observeConfig, deployment);
+        if (shouldRestartJobBecauseUnhealthy || shouldRecoverDeployment) {
+            if (shouldRestartJobBecauseUnhealthy) {
+                cancelJob(deployment, ctx, UpgradeMode.LAST_STATE, 
observeConfig);
+            }
             recoverJmDeployment(deployment, ctx, observeConfig);
             return true;
         }
+
         return false;
     }
 
@@ -273,6 +284,40 @@ public class ApplicationReconciler
         restoreJob(deployment, specToRecover, deployment.getStatus(), ctx, 
observeConfig, true);
     }
 
+    private boolean shouldRestartJobBecauseUnhealthy(
+            FlinkDeployment deployment, Configuration observeConfig) {
+        boolean restartNeeded = false;
+
+        if (observeConfig.getBoolean(OPERATOR_CLUSTER_HEALTH_CHECK_ENABLED)) {
+            var clusterInfo = deployment.getStatus().getClusterInfo();
+            ClusterHealthInfo clusterHealthInfo =
+                    
ClusterHealthEvaluator.getLastValidClusterHealthInfo(clusterInfo);
+            if (clusterHealthInfo != null) {
+                LOG.debug("Cluster info contains job health info");
+                if (!clusterHealthInfo.isHealthy()) {
+                    if (deployment.getSpec().getJob().getUpgradeMode() == 
UpgradeMode.STATELESS) {
+                        LOG.debug("Stateless job, recovering unhealthy 
jobmanager deployment");
+                        restartNeeded = true;
+                    } else if 
(FlinkUtils.isKubernetesHAActivated(observeConfig)) {
+                        LOG.debug("HA is enabled, recovering unhealthy 
jobmanager deployment");
+                        restartNeeded = true;
+                    } else {
+                        LOG.warn(
+                                "Could not recover unhealthy jobmanager 
deployment without HA enabled");
+                    }
+
+                    if (restartNeeded) {
+                        
ClusterHealthEvaluator.removeLastValidClusterHealthInfo(clusterInfo);
+                    }
+                }
+            } else {
+                LOG.debug("Cluster info not contains job health info, skipping 
health check");
+            }
+        }
+
+        return restartNeeded;
+    }
+
     @Override
     @SneakyThrows
     protected DeleteControl cleanupInternal(FlinkDeployment deployment, 
Context<?> context) {
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 6ae12f80..832013ee 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
@@ -20,6 +20,7 @@ package org.apache.flink.kubernetes.operator.service;
 import org.apache.flink.annotation.VisibleForTesting;
 import org.apache.flink.api.common.JobID;
 import org.apache.flink.api.common.JobStatus;
+import org.apache.flink.api.java.tuple.Tuple2;
 import org.apache.flink.client.program.ClusterClient;
 import org.apache.flink.client.program.rest.RestClusterClient;
 import org.apache.flink.configuration.CheckpointingOptions;
@@ -60,6 +61,7 @@ import 
org.apache.flink.runtime.rest.messages.EmptyMessageParameters;
 import org.apache.flink.runtime.rest.messages.EmptyRequestBody;
 import org.apache.flink.runtime.rest.messages.JobsOverviewHeaders;
 import org.apache.flink.runtime.rest.messages.TriggerId;
+import org.apache.flink.runtime.rest.messages.job.metrics.JobMetricsHeaders;
 import 
org.apache.flink.runtime.rest.messages.job.savepoints.SavepointDisposalRequest;
 import 
org.apache.flink.runtime.rest.messages.job.savepoints.SavepointDisposalTriggerHeaders;
 import org.apache.flink.runtime.rest.messages.job.savepoints.SavepointInfo;
@@ -860,4 +862,25 @@ public abstract class AbstractFlinkService implements 
FlinkService {
             throw new RuntimeException("Failed to create empty jar", e);
         }
     }
+
+    public Map<String, String> getMetrics(
+            Configuration conf, String jobId, List<String> metricNames) throws 
Exception {
+        try (RestClusterClient<String> clusterClient =
+                (RestClusterClient<String>) getClusterClient(conf)) {
+            var jobMetricsMessageParameters =
+                    
JobMetricsHeaders.getInstance().getUnresolvedMessageParameters();
+            
jobMetricsMessageParameters.jobPathParameter.resolve(JobID.fromHexString(jobId));
+            
jobMetricsMessageParameters.metricsFilterParameter.resolve(metricNames);
+            var responseBody =
+                    clusterClient
+                            .sendRequest(
+                                    JobMetricsHeaders.getInstance(),
+                                    jobMetricsMessageParameters,
+                                    EmptyRequestBody.getInstance())
+                            .get();
+            return responseBody.getMetrics().stream()
+                    .map(metric -> Tuple2.of(metric.getId(), 
metric.getValue()))
+                    .collect(Collectors.toMap((t) -> t.f0, (t) -> t.f1));
+        }
+    }
 }
diff --git 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/service/FlinkService.java
 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/service/FlinkService.java
index 560c5cc5..3e46351d 100644
--- 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/service/FlinkService.java
+++ 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/service/FlinkService.java
@@ -38,6 +38,7 @@ import io.fabric8.kubernetes.client.KubernetesClient;
 import javax.annotation.Nullable;
 
 import java.util.Collection;
+import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 
@@ -97,4 +98,7 @@ public interface FlinkService {
     default boolean scale(ObjectMeta meta, JobSpec jobSpec, Configuration 
conf) {
         return false;
     }
+
+    Map<String, String> getMetrics(Configuration conf, String jobId, 
List<String> metricNames)
+            throws Exception;
 }
diff --git 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/validation/DefaultValidator.java
 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/validation/DefaultValidator.java
index 03604b59..2d51888d 100644
--- 
a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/validation/DefaultValidator.java
+++ 
b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/validation/DefaultValidator.java
@@ -156,6 +156,20 @@ public class DefaultValidator implements 
FlinkResourceValidator {
             return Optional.of("Kubernetes HA must be enabled for rollback 
support.");
         }
 
+        if 
(conf.get(KubernetesOperatorConfigOptions.OPERATOR_CLUSTER_HEALTH_CHECK_ENABLED)
+                && !conf.get(
+                        
KubernetesOperatorConfigOptions.OPERATOR_JM_DEPLOYMENT_RECOVERY_ENABLED)) {
+            return Optional.of(
+                    "Deployment recovery ("
+                            + KubernetesOperatorConfigOptions
+                                    .OPERATOR_JM_DEPLOYMENT_RECOVERY_ENABLED
+                                    .key()
+                            + ") must be enabled for job health check ("
+                            + 
KubernetesOperatorConfigOptions.OPERATOR_CLUSTER_HEALTH_CHECK_ENABLED
+                                    .key()
+                            + ") support.");
+        }
+
         return Optional.empty();
     }
 
diff --git 
a/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/TestingFlinkService.java
 
b/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/TestingFlinkService.java
index 961830c6..a0ee0dd6 100644
--- 
a/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/TestingFlinkService.java
+++ 
b/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/TestingFlinkService.java
@@ -105,6 +105,8 @@ public class TestingFlinkService extends 
AbstractFlinkService {
     private final Map<String, Boolean> savepointTriggers = new HashMap<>();
     private int desiredReplicas = 0;
 
+    private Map<String, String> metricsValues = new HashMap<>();
+
     public TestingFlinkService() {
         super(null, new FlinkConfigManager(new Configuration()));
     }
@@ -503,4 +505,14 @@ public class TestingFlinkService extends 
AbstractFlinkService {
     public int getDesiredReplicas() {
         return desiredReplicas;
     }
+
+    public void setMetricValue(String name, String value) {
+        metricsValues.put(name, value);
+    }
+
+    @Override
+    public Map<String, String> getMetrics(
+            Configuration conf, String jobId, List<String> metricNames) {
+        return metricsValues;
+    }
 }
diff --git 
a/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/controller/UnhealthyDeploymentRestartTest.java
 
b/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/controller/UnhealthyDeploymentRestartTest.java
new file mode 100644
index 00000000..e7a35dbc
--- /dev/null
+++ 
b/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/controller/UnhealthyDeploymentRestartTest.java
@@ -0,0 +1,117 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.flink.kubernetes.operator.controller;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.kubernetes.operator.TestUtils;
+import org.apache.flink.kubernetes.operator.TestingFlinkService;
+import org.apache.flink.kubernetes.operator.config.FlinkConfigManager;
+import org.apache.flink.kubernetes.operator.crd.FlinkDeployment;
+import org.apache.flink.kubernetes.operator.crd.spec.FlinkVersion;
+import org.apache.flink.kubernetes.operator.crd.spec.UpgradeMode;
+import 
org.apache.flink.kubernetes.operator.crd.status.JobManagerDeploymentStatus;
+
+import io.fabric8.kubernetes.client.KubernetesClient;
+import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient;
+import io.javaoperatorsdk.operator.api.reconciler.Context;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Stream;
+
+import static 
org.apache.flink.kubernetes.operator.config.KubernetesOperatorConfigOptions.OPERATOR_CLUSTER_HEALTH_CHECK_ENABLED;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+
+/** @link Unhealthy deployment restart tests */
+@EnableKubernetesMockClient(crud = true)
+public class UnhealthyDeploymentRestartTest {
+
+    private static final String NUM_RESTARTS_METRIC_NAME = "numRestarts";
+
+    private FlinkConfigManager configManager;
+
+    private TestingFlinkService flinkService;
+    private Context<FlinkDeployment> context;
+    private TestingFlinkDeploymentController testController;
+
+    private KubernetesClient kubernetesClient;
+
+    @BeforeEach
+    public void setup() {
+        var configuration = new Configuration();
+        configuration.set(OPERATOR_CLUSTER_HEALTH_CHECK_ENABLED, true);
+        configManager = new FlinkConfigManager(configuration);
+        flinkService = new TestingFlinkService(kubernetesClient);
+        context = flinkService.getContext();
+        testController =
+                new TestingFlinkDeploymentController(configManager, 
kubernetesClient, flinkService);
+        
kubernetesClient.resource(TestUtils.buildApplicationCluster()).createOrReplace();
+    }
+
+    @ParameterizedTest
+    @MethodSource("applicationTestParams")
+    public void verifyApplicationUnhealthyJmRecovery(
+            FlinkVersion flinkVersion, UpgradeMode upgradeMode) throws 
Exception {
+        FlinkDeployment appCluster = 
TestUtils.buildApplicationCluster(flinkVersion);
+        appCluster.getSpec().getJob().setUpgradeMode(upgradeMode);
+
+        // Start a healthy deployment
+        flinkService.setMetricValue(NUM_RESTARTS_METRIC_NAME, "0");
+        testController.reconcile(appCluster, context);
+        testController.reconcile(appCluster, context);
+        testController.reconcile(appCluster, context);
+        assertEquals(
+                JobManagerDeploymentStatus.READY,
+                appCluster.getStatus().getJobManagerDeploymentStatus());
+        assertEquals("RUNNING", 
appCluster.getStatus().getJobStatus().getState());
+
+        // Make deployment unhealthy
+        flinkService.setMetricValue(NUM_RESTARTS_METRIC_NAME, "100");
+        testController.reconcile(appCluster, context);
+        assertEquals(
+                JobManagerDeploymentStatus.DEPLOYING,
+                appCluster.getStatus().getJobManagerDeploymentStatus());
+
+        // After restart the deployment is healthy again
+        flinkService.setMetricValue(NUM_RESTARTS_METRIC_NAME, "0");
+        testController.reconcile(appCluster, context);
+        testController.reconcile(appCluster, context);
+        assertEquals(
+                JobManagerDeploymentStatus.READY,
+                appCluster.getStatus().getJobManagerDeploymentStatus());
+        assertEquals("RUNNING", 
appCluster.getStatus().getJobStatus().getState());
+        assertEquals(
+                appCluster.getSpec(),
+                
appCluster.getStatus().getReconciliationStatus().deserializeLastReconciledSpec());
+    }
+
+    private static Stream<Arguments> applicationTestParams() {
+        List<Arguments> args = new ArrayList<>();
+        for (FlinkVersion version : FlinkVersion.values()) {
+            for (UpgradeMode upgradeMode : UpgradeMode.values()) {
+                args.add(arguments(version, upgradeMode));
+            }
+        }
+        return args.stream();
+    }
+}
diff --git 
a/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/health/ClusterHealthInfoTest.java
 
b/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/health/ClusterHealthInfoTest.java
new file mode 100644
index 00000000..2b6707c0
--- /dev/null
+++ 
b/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/health/ClusterHealthInfoTest.java
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.flink.kubernetes.operator.health;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.Clock;
+import java.time.ZoneId;
+
+import static java.time.Instant.ofEpochSecond;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ClusterHealthInfoTest {
+
+    @Test
+    public void isValidShouldReturnFalseWhenTimestampIsZero() {
+        var clock = Clock.fixed(ofEpochSecond(0), ZoneId.systemDefault());
+        assertFalse(ClusterHealthInfo.isValid(ClusterHealthInfo.of(clock, 0)));
+    }
+
+    @Test
+    public void isValidShouldReturnTrueWhenTimestampIsNonzero() {
+        var clock = Clock.fixed(ofEpochSecond(1), ZoneId.systemDefault());
+        assertTrue(ClusterHealthInfo.isValid(ClusterHealthInfo.of(clock, 0)));
+    }
+
+    @Test
+    public void serializationRoundTrip() {
+        var clock = Clock.fixed(ofEpochSecond(123), ZoneId.systemDefault());
+        var clusterHealthInfo = ClusterHealthInfo.of(clock, 456);
+        var clusterHealthInfoJson = 
ClusterHealthInfo.serialize(clusterHealthInfo);
+
+        var clusterHealthInfoFromJson = 
ClusterHealthInfo.deserialize(clusterHealthInfoJson);
+        assertEquals(clusterHealthInfo, clusterHealthInfoFromJson);
+    }
+}
diff --git 
a/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/observer/ClusterHealthEvaluatorTest.java
 
b/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/observer/ClusterHealthEvaluatorTest.java
new file mode 100644
index 00000000..e6cf9a63
--- /dev/null
+++ 
b/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/observer/ClusterHealthEvaluatorTest.java
@@ -0,0 +1,202 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.flink.kubernetes.operator.observer;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.kubernetes.operator.health.ClusterHealthInfo;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.temporal.ChronoUnit;
+import java.util.HashMap;
+import java.util.Map;
+
+import static java.time.Instant.ofEpochMilli;
+import static java.time.Instant.ofEpochSecond;
+import static 
org.apache.flink.kubernetes.operator.config.KubernetesOperatorConfigOptions.OPERATOR_CLUSTER_HEALTH_CHECK_RESTARTS_THRESHOLD;
+import static 
org.apache.flink.kubernetes.operator.config.KubernetesOperatorConfigOptions.OPERATOR_CLUSTER_HEALTH_CHECK_RESTARTS_WINDOW;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/** Tests for {@link ClusterHealthEvaluator}. */
+class ClusterHealthEvaluatorTest {
+
+    private Configuration configuration;
+    private Map<String, String> clusterInfo;
+    private ClusterHealthEvaluator clusterHealthEvaluator;
+    private final Instant invalidInstant = ofEpochMilli(0);
+    private final Instant validInstant = ofEpochMilli(1);
+    private ClusterHealthInfo invalidClusterHealthInfo;
+
+    @BeforeEach
+    public void beforeEach() {
+        configuration = new Configuration();
+
+        clusterInfo = new HashMap<>();
+
+        var now = Clock.fixed(ofEpochSecond(120), ZoneId.systemDefault());
+        clusterHealthEvaluator = new ClusterHealthEvaluator(now);
+
+        var clock = Clock.fixed(invalidInstant, ZoneId.systemDefault());
+        invalidClusterHealthInfo = ClusterHealthInfo.of(clock, 0);
+    }
+
+    @Test
+    public void evaluateShouldNotSetLastStateWhenInvalidObserved() {
+        clusterHealthEvaluator.evaluate(configuration, clusterInfo, 
invalidClusterHealthInfo);
+        
assertNull(ClusterHealthEvaluator.getLastValidClusterHealthInfo(clusterInfo));
+    }
+
+    @Test
+    public void evaluateShouldSetLastStateWhenValidObserved() {
+        var observedClusterHealthInfo = createClusterHealthInfo(validInstant, 
0);
+
+        clusterHealthEvaluator.evaluate(configuration, clusterInfo, 
observedClusterHealthInfo);
+        assertEquals(
+                observedClusterHealthInfo,
+                
ClusterHealthEvaluator.getLastValidClusterHealthInfo(clusterInfo));
+    }
+
+    @Test
+    public void evaluateShouldThrowExceptionWhenObservedTimestampIsOld() {
+        var observedClusterHealthInfo1 = createClusterHealthInfo(validInstant, 
0);
+        var observedClusterHealthInfo2 = 
createClusterHealthInfo(validInstant.plusMillis(100), 0);
+
+        ClusterHealthEvaluator.setLastValidClusterHealthInfo(
+                clusterInfo, observedClusterHealthInfo2);
+        assertThrows(
+                IllegalStateException.class,
+                () ->
+                        clusterHealthEvaluator.evaluate(
+                                configuration, clusterInfo, 
observedClusterHealthInfo1));
+    }
+
+    @Test
+    public void evaluateShouldOverwriteLastStateWhenRestartCountIsLess() {
+        var observedClusterHealthInfo1 = createClusterHealthInfo(validInstant, 
1);
+        var observedClusterHealthInfo2 = createClusterHealthInfo(validInstant, 
0);
+
+        clusterHealthEvaluator.evaluate(configuration, clusterInfo, 
observedClusterHealthInfo1);
+        assertEquals(
+                observedClusterHealthInfo1,
+                
ClusterHealthEvaluator.getLastValidClusterHealthInfo(clusterInfo));
+        clusterHealthEvaluator.evaluate(configuration, clusterInfo, 
observedClusterHealthInfo2);
+        assertEquals(
+                observedClusterHealthInfo2,
+                
ClusterHealthEvaluator.getLastValidClusterHealthInfo(clusterInfo));
+    }
+
+    @Test
+    public void evaluateShouldNotOverwriteLastStateWhenTimestampIsInWindow() {
+        configuration.set(OPERATOR_CLUSTER_HEALTH_CHECK_RESTARTS_WINDOW, 
Duration.ofMinutes(2));
+        var observedClusterHealthInfo1 = createClusterHealthInfo(validInstant, 
0);
+        var observedClusterHealthInfo2 =
+                createClusterHealthInfo(validInstant.plus(1, 
ChronoUnit.MINUTES), 0);
+
+        ClusterHealthEvaluator.setLastValidClusterHealthInfo(
+                clusterInfo, observedClusterHealthInfo1);
+        clusterHealthEvaluator.evaluate(configuration, clusterInfo, 
observedClusterHealthInfo2);
+        assertEquals(
+                observedClusterHealthInfo1,
+                
ClusterHealthEvaluator.getLastValidClusterHealthInfo(clusterInfo));
+    }
+
+    @Test
+    public void evaluateShouldOverwriteLastStateWhenTimestampIsOutOfWindow() {
+        configuration.set(OPERATOR_CLUSTER_HEALTH_CHECK_RESTARTS_WINDOW, 
Duration.ofMinutes(1));
+        var observedClusterHealthInfo1 = createClusterHealthInfo(validInstant, 
0);
+        var observedClusterHealthInfo2 =
+                createClusterHealthInfo(validInstant.plus(1, 
ChronoUnit.MINUTES), 0);
+
+        ClusterHealthEvaluator.setLastValidClusterHealthInfo(
+                clusterInfo, observedClusterHealthInfo1);
+        clusterHealthEvaluator.evaluate(configuration, clusterInfo, 
observedClusterHealthInfo2);
+        assertEquals(
+                observedClusterHealthInfo2,
+                
ClusterHealthEvaluator.getLastValidClusterHealthInfo(clusterInfo));
+    }
+
+    @Test
+    public void evaluateShouldMarkClusterHealthyWhenNoPreviousState() {
+        var observedClusterHealthInfo = createClusterHealthInfo(validInstant, 
1);
+
+        clusterHealthEvaluator.evaluate(configuration, clusterInfo, 
observedClusterHealthInfo);
+        assertClusterHealthIs(true);
+    }
+
+    @Test
+    public void evaluateShouldMarkClusterHealthyWhenThresholdNotHit() {
+        configuration.set(OPERATOR_CLUSTER_HEALTH_CHECK_RESTARTS_WINDOW, 
Duration.ofMinutes(5));
+        configuration.set(OPERATOR_CLUSTER_HEALTH_CHECK_RESTARTS_THRESHOLD, 
100);
+        ClusterHealthInfo observedClusterHealthInfo1 = 
createClusterHealthInfo(validInstant, 0);
+        var observedClusterHealthInfo2 =
+                createClusterHealthInfo(validInstant.plus(1, 
ChronoUnit.MINUTES), 100);
+
+        ClusterHealthEvaluator.setLastValidClusterHealthInfo(
+                clusterInfo, observedClusterHealthInfo1);
+        clusterHealthEvaluator.evaluate(configuration, clusterInfo, 
observedClusterHealthInfo2);
+        assertClusterHealthIs(true);
+    }
+
+    @Test
+    public void 
evaluateShouldMarkClusterUnhealthyWhenThresholdHitImmediately() {
+        configuration.set(OPERATOR_CLUSTER_HEALTH_CHECK_RESTARTS_WINDOW, 
Duration.ofMinutes(5));
+        configuration.set(OPERATOR_CLUSTER_HEALTH_CHECK_RESTARTS_THRESHOLD, 
100);
+        ClusterHealthInfo observedClusterHealthInfo1 = 
createClusterHealthInfo(validInstant, 0);
+        var observedClusterHealthInfo2 =
+                createClusterHealthInfo(validInstant.plus(1, 
ChronoUnit.MINUTES), 101);
+
+        ClusterHealthEvaluator.setLastValidClusterHealthInfo(
+                clusterInfo, observedClusterHealthInfo1);
+        clusterHealthEvaluator.evaluate(configuration, clusterInfo, 
observedClusterHealthInfo2);
+        assertClusterHealthIs(false);
+    }
+
+    @Test
+    public void evaluateShouldMarkClusterUnhealthyWhenThresholdHitInAverage() {
+        configuration.set(OPERATOR_CLUSTER_HEALTH_CHECK_RESTARTS_WINDOW, 
Duration.ofMinutes(5));
+        configuration.set(OPERATOR_CLUSTER_HEALTH_CHECK_RESTARTS_THRESHOLD, 
100);
+        var observedClusterHealthInfo1 = createClusterHealthInfo(validInstant, 
0);
+        var observedClusterHealthInfo2 =
+                createClusterHealthInfo(validInstant.plus(6, 
ChronoUnit.MINUTES), 122);
+
+        ClusterHealthEvaluator.setLastValidClusterHealthInfo(
+                clusterInfo, observedClusterHealthInfo1);
+        clusterHealthEvaluator.evaluate(configuration, clusterInfo, 
observedClusterHealthInfo2);
+        assertClusterHealthIs(false);
+    }
+
+    private ClusterHealthInfo createClusterHealthInfo(Instant instant, int 
numRestarts) {
+        var clock = Clock.fixed(instant, ZoneId.systemDefault());
+        return ClusterHealthInfo.of(clock, numRestarts);
+    }
+
+    private void assertClusterHealthIs(boolean healthy) {
+        var lastValidClusterHealthInfo =
+                
ClusterHealthEvaluator.getLastValidClusterHealthInfo(clusterInfo);
+        assertNotNull(lastValidClusterHealthInfo);
+        assertEquals(healthy, lastValidClusterHealthInfo.isHealthy());
+    }
+}
diff --git 
a/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/validation/DefaultValidatorTest.java
 
b/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/validation/DefaultValidatorTest.java
index 0b2c3361..0a03da37 100644
--- 
a/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/validation/DefaultValidatorTest.java
+++ 
b/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/validation/DefaultValidatorTest.java
@@ -179,6 +179,24 @@ public class DefaultValidatorTest {
                                                 
KubernetesConfigOptions.NAMESPACE.key(), "myns")),
                 "Forbidden Flink config key");
 
+        testError(
+                dep ->
+                        dep.getSpec()
+                                .setFlinkConfiguration(
+                                        Map.of(
+                                                KubernetesOperatorConfigOptions
+                                                                
.OPERATOR_CLUSTER_HEALTH_CHECK_ENABLED
+                                                                .key(),
+                                                        "true",
+                                                KubernetesOperatorConfigOptions
+                                                                
.OPERATOR_JM_DEPLOYMENT_RECOVERY_ENABLED
+                                                                .key(),
+                                                        "false")),
+                "Deployment recovery ("
+                        + 
KubernetesOperatorConfigOptions.OPERATOR_JM_DEPLOYMENT_RECOVERY_ENABLED
+                                .key()
+                        + ") must be enabled");
+
         // Test log config validation
         testSuccess(
                 dep ->

Reply via email to