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

wuchong pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fluss.git


The following commit(s) were added to refs/heads/main by this push:
     new ad51d97e8 [server] Add Cluster Health API implementation (#3400)
ad51d97e8 is described below

commit ad51d97e8d8f5e7e0ccf10a611fc68f21e916941
Author: yunhong <[email protected]>
AuthorDate: Wed Jun 10 23:21:49 2026 +0800

    [server] Add Cluster Health API implementation (#3400)
---
 .../java/org/apache/fluss/client/admin/Admin.java  |  26 ++
 .../apache/fluss/client/admin/ClusterHealth.java   | 108 +++++++
 .../fluss/client/admin/ClusterHealthStatus.java    |  33 ++
 .../org/apache/fluss/client/admin/FlussAdmin.java  |   7 +
 .../fluss/client/utils/ClientRpcMessageUtils.java  |  25 ++
 .../fluss/client/admin/FlussAdminITCase.java       |  51 +++
 .../java/org/apache/fluss/metrics/MetricNames.java |   1 +
 .../src/main/resources/bin/readiness-check.sh      | 271 ++++++++++++++++
 .../flink/sink/testutils/TestAdminAdapter.java     |   5 +
 .../fluss/rpc/gateway/AdminReadOnlyGateway.java    |  10 +
 .../org/apache/fluss/rpc/protocol/ApiKeys.java     |   3 +-
 fluss-rpc/src/main/proto/FlussApi.proto            |   9 +
 .../fluss/rpc/TestingTabletGatewayService.java     |   8 +
 .../server/coordinator/CoordinatorContext.java     |  78 ++++-
 .../coordinator/CoordinatorEventProcessor.java     |  10 +
 .../coordinator/CoordinatorRequestBatch.java       |  28 ++
 .../server/coordinator/CoordinatorService.java     |  57 ++++
 .../coordinator/event/CoordinatorEventManager.java |  19 +-
 .../apache/fluss/server/tablet/TabletServer.java   |   4 +-
 .../apache/fluss/server/tablet/TabletService.java  |  35 ++-
 .../server/tools/ClusterHealthReadinessCheck.java  | 265 ++++++++++++++++
 .../server/coordinator/ClusterHealthTest.java      | 346 +++++++++++++++++++++
 .../server/coordinator/CoordinatorContextTest.java | 127 ++++++++
 .../coordinator/CoordinatorRequestBatchTest.java   | 160 ++++++++++
 .../server/coordinator/TestCoordinatorGateway.java |   8 +
 .../server/tablet/TestTabletServerGateway.java     |   8 +
 fluss-test-coverage/pom.xml                        |   4 +-
 helm/templates/sts-coordinator.yaml                |   4 +-
 helm/templates/sts-tablet.yaml                     |  32 +-
 helm/values.yaml                                   |  37 +++
 website/docs/install-deploy/deploying-with-helm.md |  42 ++-
 .../maintenance/observability/monitor-metrics.md   |   9 +-
 website/docs/maintenance/operations/upgrading.md   |  38 +++
 33 files changed, 1842 insertions(+), 26 deletions(-)

diff --git 
a/fluss-client/src/main/java/org/apache/fluss/client/admin/Admin.java 
b/fluss-client/src/main/java/org/apache/fluss/client/admin/Admin.java
index ea28a246b..5f0bcbde5 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/admin/Admin.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/admin/Admin.java
@@ -770,4 +770,30 @@ public interface Admin extends AutoCloseable {
      * @since 0.9
      */
     CompletableFuture<Void> deleteProducerOffsets(String producerId);
+
+    /**
+     * Get the health status of the cluster asynchronously.
+     *
+     * <p>The returned {@link ClusterHealth} contains replica statistics and 
an overall {@link
+     * ClusterHealthStatus}:
+     *
+     * <ul>
+     *   <li>{@link ClusterHealthStatus#GREEN} — all replicas are in-sync and 
all leaders are
+     *       active. The cluster is fully healthy.
+     *   <li>{@link ClusterHealthStatus#YELLOW} — all leaders are active, but 
some replicas have not
+     *       yet rejoined the in-sync replica set (ISR).
+     *   <li>{@link ClusterHealthStatus#RED} — one or more leader replicas 
have not yet been
+     *       confirmed active (e.g., leader election or KV snapshot recovery 
is still in progress).
+     *   <li>{@link ClusterHealthStatus#UNKNOWN} — the Coordinator was unable 
to determine cluster
+     *       health (e.g., the server does not support this API).
+     * </ul>
+     *
+     * <p>This API is designed for the situation like a Kubernetes 
readiness-probe gate during
+     * rolling upgrades: only proceed to the next pod when the status is 
{@code GREEN}, ensuring all
+     * replicas have fully recovered before the next server is restarted.
+     *
+     * @return a {@link CompletableFuture} that completes with the cluster 
health information.
+     * @since 1.0
+     */
+    CompletableFuture<ClusterHealth> getClusterHealth();
 }
diff --git 
a/fluss-client/src/main/java/org/apache/fluss/client/admin/ClusterHealth.java 
b/fluss-client/src/main/java/org/apache/fluss/client/admin/ClusterHealth.java
new file mode 100644
index 000000000..8f751bc16
--- /dev/null
+++ 
b/fluss-client/src/main/java/org/apache/fluss/client/admin/ClusterHealth.java
@@ -0,0 +1,108 @@
+/*
+ * 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.fluss.client.admin;
+
+import org.apache.fluss.annotation.PublicEvolving;
+
+import java.util.Objects;
+
+/**
+ * Cluster health information returned by {@link Admin#getClusterHealth()}.
+ *
+ * @since 1.0
+ */
+@PublicEvolving
+public final class ClusterHealth {
+
+    private final int numReplicas;
+    private final int inSyncReplicas;
+    private final int numLeaderReplicas;
+    private final int activeLeaderReplicas;
+    private final ClusterHealthStatus status;
+
+    public ClusterHealth(
+            int numReplicas,
+            int inSyncReplicas,
+            int numLeaderReplicas,
+            int activeLeaderReplicas,
+            ClusterHealthStatus status) {
+        this.numReplicas = numReplicas;
+        this.inSyncReplicas = inSyncReplicas;
+        this.numLeaderReplicas = numLeaderReplicas;
+        this.activeLeaderReplicas = activeLeaderReplicas;
+        this.status = Objects.requireNonNull(status, "status");
+    }
+
+    public int getNumReplicas() {
+        return numReplicas;
+    }
+
+    public int getInSyncReplicas() {
+        return inSyncReplicas;
+    }
+
+    public int getNumLeaderReplicas() {
+        return numLeaderReplicas;
+    }
+
+    public int getActiveLeaderReplicas() {
+        return activeLeaderReplicas;
+    }
+
+    public ClusterHealthStatus getStatus() {
+        return status;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (!(o instanceof ClusterHealth)) {
+            return false;
+        }
+        ClusterHealth that = (ClusterHealth) o;
+        return numReplicas == that.numReplicas
+                && inSyncReplicas == that.inSyncReplicas
+                && numLeaderReplicas == that.numLeaderReplicas
+                && activeLeaderReplicas == that.activeLeaderReplicas
+                && status == that.status;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(
+                numReplicas, inSyncReplicas, numLeaderReplicas, 
activeLeaderReplicas, status);
+    }
+
+    @Override
+    public String toString() {
+        return "ClusterHealth{"
+                + "numReplicas="
+                + numReplicas
+                + ", inSyncReplicas="
+                + inSyncReplicas
+                + ", numLeaderReplicas="
+                + numLeaderReplicas
+                + ", activeLeaderReplicas="
+                + activeLeaderReplicas
+                + ", status="
+                + status
+                + '}';
+    }
+}
diff --git 
a/fluss-client/src/main/java/org/apache/fluss/client/admin/ClusterHealthStatus.java
 
b/fluss-client/src/main/java/org/apache/fluss/client/admin/ClusterHealthStatus.java
new file mode 100644
index 000000000..531ebf0eb
--- /dev/null
+++ 
b/fluss-client/src/main/java/org/apache/fluss/client/admin/ClusterHealthStatus.java
@@ -0,0 +1,33 @@
+/*
+ * 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.fluss.client.admin;
+
+import org.apache.fluss.annotation.PublicEvolving;
+
+/**
+ * Health status of the Fluss cluster.
+ *
+ * @since 1.0
+ */
+@PublicEvolving
+public enum ClusterHealthStatus {
+    GREEN,
+    YELLOW,
+    RED,
+    UNKNOWN
+}
diff --git 
a/fluss-client/src/main/java/org/apache/fluss/client/admin/FlussAdmin.java 
b/fluss-client/src/main/java/org/apache/fluss/client/admin/FlussAdmin.java
index 954247850..f724a15b8 100644
--- a/fluss-client/src/main/java/org/apache/fluss/client/admin/FlussAdmin.java
+++ b/fluss-client/src/main/java/org/apache/fluss/client/admin/FlussAdmin.java
@@ -68,6 +68,7 @@ import 
org.apache.fluss.rpc.messages.DescribeClusterConfigsRequest;
 import org.apache.fluss.rpc.messages.DropAclsRequest;
 import org.apache.fluss.rpc.messages.DropDatabaseRequest;
 import org.apache.fluss.rpc.messages.DropTableRequest;
+import org.apache.fluss.rpc.messages.GetClusterHealthRequest;
 import org.apache.fluss.rpc.messages.GetDatabaseInfoRequest;
 import org.apache.fluss.rpc.messages.GetKvSnapshotMetadataRequest;
 import org.apache.fluss.rpc.messages.GetLakeSnapshotRequest;
@@ -896,6 +897,12 @@ public class FlussAdmin implements Admin {
         }
     }
 
+    @Override
+    public CompletableFuture<ClusterHealth> getClusterHealth() {
+        return gateway.getClusterHealth(new GetClusterHealthRequest())
+                .thenApply(ClientRpcMessageUtils::toClusterHealth);
+    }
+
     @VisibleForTesting
     public AdminGateway getAdminGateway() {
         return gateway;
diff --git 
a/fluss-client/src/main/java/org/apache/fluss/client/utils/ClientRpcMessageUtils.java
 
b/fluss-client/src/main/java/org/apache/fluss/client/utils/ClientRpcMessageUtils.java
index 0bd67da17..3f6e2443a 100644
--- 
a/fluss-client/src/main/java/org/apache/fluss/client/utils/ClientRpcMessageUtils.java
+++ 
b/fluss-client/src/main/java/org/apache/fluss/client/utils/ClientRpcMessageUtils.java
@@ -17,6 +17,8 @@
 
 package org.apache.fluss.client.utils;
 
+import org.apache.fluss.client.admin.ClusterHealth;
+import org.apache.fluss.client.admin.ClusterHealthStatus;
 import org.apache.fluss.client.admin.OffsetSpec;
 import org.apache.fluss.client.admin.ProducerOffsetsResult;
 import org.apache.fluss.client.lookup.LookupBatch;
@@ -51,6 +53,7 @@ import org.apache.fluss.rpc.messages.AlterDatabaseRequest;
 import org.apache.fluss.rpc.messages.AlterTableRequest;
 import org.apache.fluss.rpc.messages.CreatePartitionRequest;
 import org.apache.fluss.rpc.messages.DropPartitionRequest;
+import org.apache.fluss.rpc.messages.GetClusterHealthResponse;
 import org.apache.fluss.rpc.messages.GetFileSystemSecurityTokenResponse;
 import org.apache.fluss.rpc.messages.GetKvSnapshotMetadataResponse;
 import org.apache.fluss.rpc.messages.GetLakeSnapshotResponse;
@@ -829,4 +832,26 @@ public class ClientRpcMessageUtils {
                         .collect(Collectors.toList());
         return new 
GetTableStatsRequest().setTableId(tableId).addAllBucketsReqs(pbBuckets);
     }
+
+    public static ClusterHealth toClusterHealth(GetClusterHealthResponse resp) 
{
+        return new ClusterHealth(
+                resp.getNumReplicas(),
+                resp.getInSyncReplicas(),
+                resp.getNumLeaderReplicas(),
+                resp.getActiveLeaderReplicas(),
+                toClusterHealthStatus(resp.getStatus()));
+    }
+
+    private static ClusterHealthStatus toClusterHealthStatus(int pbStatus) {
+        switch (pbStatus) {
+            case 0:
+                return ClusterHealthStatus.GREEN;
+            case 1:
+                return ClusterHealthStatus.YELLOW;
+            case 2:
+                return ClusterHealthStatus.RED;
+            default:
+                return ClusterHealthStatus.UNKNOWN;
+        }
+    }
 }
diff --git 
a/fluss-client/src/test/java/org/apache/fluss/client/admin/FlussAdminITCase.java
 
b/fluss-client/src/test/java/org/apache/fluss/client/admin/FlussAdminITCase.java
index 65cca8195..68181844e 100644
--- 
a/fluss-client/src/test/java/org/apache/fluss/client/admin/FlussAdminITCase.java
+++ 
b/fluss-client/src/test/java/org/apache/fluss/client/admin/FlussAdminITCase.java
@@ -2413,4 +2413,55 @@ class FlussAdminITCase extends ClientToServerITCaseBase {
                 .isInstanceOf(NetworkException.class)
                 .hasMessageContaining("connection timed out");
     }
+
+    @Test
+    void testClusterHealthDuringRollingUpgrade() throws Exception {
+        TablePath tablePath = TablePath.of("test_db", "health_test_table");
+        TableDescriptor tableDescriptor =
+                
TableDescriptor.builder().schema(DEFAULT_SCHEMA).distributedBy(3, "id").build();
+        long tableId = createTable(tablePath, tableDescriptor, true);
+        waitAllReplicasReady(tableId, 3);
+
+        // Phase 1: Cluster is healthy — status should be GREEN.
+        ClusterHealth health = admin.getClusterHealth().get();
+        assertThat(health.getStatus()).isEqualTo(ClusterHealthStatus.GREEN);
+        
assertThat(health.getNumReplicas()).isEqualTo(health.getInSyncReplicas());
+        
assertThat(health.getNumLeaderReplicas()).isEqualTo(health.getActiveLeaderReplicas());
+
+        // Phase 2: Stop one tablet server (simulate server crash during 
rolling upgrade).
+        int stoppedServerId = 0;
+        FLUSS_CLUSTER_EXTENSION.stopTabletServer(stoppedServerId);
+        FLUSS_CLUSTER_EXTENSION.assertHasTabletServerNumber(2);
+
+        for (int bucket = 0; bucket < 3; bucket++) {
+            TableBucket tb = new TableBucket(tableId, bucket);
+            FLUSS_CLUSTER_EXTENSION.waitUntilReplicaShrinkFromIsr(tb, 
stoppedServerId);
+        }
+
+        // Status should not be GREEN (YELLOW or RED depending on leader 
placement).
+        ClusterHealth duringDown = admin.getClusterHealth().get();
+        
assertThat(duringDown.getStatus()).isNotEqualTo(ClusterHealthStatus.GREEN);
+        
assertThat(duringDown.getInSyncReplicas()).isLessThan(duringDown.getNumReplicas());
+
+        // Phase 3: Restart the server.
+        FLUSS_CLUSTER_EXTENSION.startTabletServer(stoppedServerId);
+        FLUSS_CLUSTER_EXTENSION.assertHasTabletServerNumber(3);
+
+        // Phase 4: Wait for recovery — status should return to GREEN.
+        for (int bucket = 0; bucket < 3; bucket++) {
+            TableBucket tb = new TableBucket(tableId, bucket);
+            FLUSS_CLUSTER_EXTENSION.waitUntilReplicaExpandToIsr(tb, 
stoppedServerId);
+        }
+
+        waitUntil(
+                () -> admin.getClusterHealth().get().getStatus() == 
ClusterHealthStatus.GREEN,
+                Duration.ofMinutes(1),
+                "Cluster should return to GREEN after server restart");
+
+        ClusterHealth afterRecovery = admin.getClusterHealth().get();
+        
assertThat(afterRecovery.getStatus()).isEqualTo(ClusterHealthStatus.GREEN);
+        
assertThat(afterRecovery.getNumReplicas()).isEqualTo(afterRecovery.getInSyncReplicas());
+        assertThat(afterRecovery.getNumLeaderReplicas())
+                .isEqualTo(afterRecovery.getActiveLeaderReplicas());
+    }
 }
diff --git 
a/fluss-common/src/main/java/org/apache/fluss/metrics/MetricNames.java 
b/fluss-common/src/main/java/org/apache/fluss/metrics/MetricNames.java
index fe34deba9..f56ca967c 100644
--- a/fluss-common/src/main/java/org/apache/fluss/metrics/MetricNames.java
+++ b/fluss-common/src/main/java/org/apache/fluss/metrics/MetricNames.java
@@ -44,6 +44,7 @@ public class MetricNames {
     public static final String BUCKET_COUNT = "bucketCount";
     public static final String PARTITION_COUNT = "partitionCount";
     public static final String REPLICAS_TO_DELETE_COUNT = 
"replicasToDeleteCount";
+    public static final String PENDING_LEADER_ACTIVATION_COUNT = 
"pendingLeaderActivationCount";
 
     // for coordinator event processor
     public static final String EVENT_QUEUE_SIZE = "eventQueueSize";
diff --git a/fluss-dist/src/main/resources/bin/readiness-check.sh 
b/fluss-dist/src/main/resources/bin/readiness-check.sh
new file mode 100644
index 000000000..7d2ac5d9a
--- /dev/null
+++ b/fluss-dist/src/main/resources/bin/readiness-check.sh
@@ -0,0 +1,271 @@
+#!/usr/bin/env bash
+
+#
+# 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.
+#
+
+# 
==============================================================================
+# Fluss Readiness Check Script
+# 
==============================================================================
+#
+# Two-step readiness probe for Kubernetes StatefulSet rolling upgrades:
+#
+#   Step 1 — Local TCP port check: verify this TabletServer process is alive
+#            and has bound its RPC port. Fast, no external dependency.
+#
+#   Step 2 — Cluster health check: call the LOCAL TabletServer's Cluster
+#            Health API (which forwards to the Coordinator over the internal
+#            listener) and pass only if status is GREEN.
+#            YELLOW/RED/UNKNOWN means recovery is incomplete — block the 
upgrade.
+#
+# Both steps must pass for the pod to be marked Ready.
+#
+# Exit codes (from ClusterHealthReadinessCheck.java):
+#   0 = Ready (status GREEN)
+#   1 = Not ready (status YELLOW, RED, UNKNOWN, or TabletServer unreachable)
+#   2 = API unsupported (older server) → immediate TCP fallback (latched)
+#
+# Environment variables (set by helm template or container spec):
+#   FLUSS_HOME                    - Fluss installation directory
+#   READINESS_TIMEOUT_MS          - Timeout for Health API call (default: 5000)
+#   READINESS_TCP_HOST            - TabletServer host the probe talks to. 
Defaults
+#       to ${POD_IP} when set (the typical Kubernetes case where bind.listeners
+#       binds the tablet's CLIENT endpoint to the pod IP rather than 0.0.0.0),
+#       falling back to 127.0.0.1 otherwise. The probe always talks to the 
local
+#       sidecar tablet, which forwards getClusterHealth to the Coordinator over
+#       the internal listener — so the probe never needs to know the
+#       Coordinator's address.
+#   READINESS_TCP_PORT            - TabletServer client port (default: 9124).
+#   READINESS_HEALTH_CHECK_AUTH   - Optional client auth configuration as
+#       semicolon-separated key:value pairs (e.g.
+#       client.security.protocol:SASL;client.sasl.mechanism:PLAIN). Required 
only
+#       when the local client listener enforces SASL.
+#   READINESS_HEALTH_CHECK_TIMEOUT_SECONDS - Maximum wall-clock seconds the
+#       cluster health gate is allowed to keep this pod NotReady on first boot.
+#       After this budget is exhausted the probe latches the TCP-only fast path
+#       so a permanently-unrecoverable cluster cannot wedge a rolling upgrade.
+#       Default: 1200 (20 minutes).
+# 
==============================================================================
+
+set -o pipefail
+
+# ---- Configuration ----
+
+FLUSS_HOME="${FLUSS_HOME:-/opt/fluss}"
+TIMEOUT_MS="${READINESS_TIMEOUT_MS:-5000}"
+# Probe the local sidecar tablet inside the same pod — it forwards the request
+# to the Coordinator internally, so the probe never has to know the
+# Coordinator's address. Prefer ${POD_IP} when set (typical K8s case where
+# bind.listeners binds the CLIENT endpoint to the pod IP, not 0.0.0.0); fall
+# back to 127.0.0.1 for local/non-K8s invocations.
+TCP_HOST="${READINESS_TCP_HOST:-${POD_IP:-127.0.0.1}}"
+TCP_PORT="${READINESS_TCP_PORT:-9124}"
+# Wall-clock budget for the first-boot cluster health gate. Once exhausted, the
+# probe latches the TCP fast path so a permanently-unhealthy cluster cannot
+# wedge a rolling upgrade indefinitely.
+HEALTH_CHECK_TIMEOUT_SECONDS="${READINESS_HEALTH_CHECK_TIMEOUT_SECONDS:-1200}"
+
+# Marker files for tracking state across probe invocations
+MARKER_DIR="/tmp/fluss-readiness"
+FIRST_READY_MARKER="${MARKER_DIR}/first-ready"
+# Records the epoch of the first time this pod entered the cluster health gate.
+# Used to enforce HEALTH_CHECK_TIMEOUT_SECONDS across probe invocations (the
+# Java CLI is forked fresh each cycle, so the budget must live in a marker
+# file).
+FIRST_PROBE_EPOCH_FILE="${MARKER_DIR}/first-probe-epoch"
+# Latched-once marker so the "now in TCP-only fast path" notice is logged
+# exactly one time per pod lifetime (avoids flooding kubectl logs every 3s).
+FAST_PATH_LOGGED="${MARKER_DIR}/fast-path-logged"
+
+mkdir -p "${MARKER_DIR}"
+
+# ---- Helper Functions ----
+
+# Step 1: TCP port check (local liveness)
+check_tcp() {
+    local host="${TCP_HOST}"
+    local port="${1:-${TCP_PORT}}"
+    (echo > /dev/tcp/"${host}"/"${port}") >/dev/null 2>&1
+    return $?
+}
+
+# Step 2: Run the Java ClusterHealthReadinessCheck CLI tool (cluster health 
check).
+# The tool ships inside fluss-server-${version}.jar; no extra jar 
(fluss-client,
+# fluss-dist) is required.
+#
+# Host/port are passed explicitly via --host/--port (derived from TCP_HOST and
+# TCP_PORT above). Optional auth properties come from env var
+# READINESS_HEALTH_CHECK_AUTH (semicolon-separated key:value pairs).
+run_recovery_check() {
+    local timeout_ms="$1"
+
+    # Locate fluss-server jar (the only jar this probe needs).
+    local fluss_server_jar=""
+    while IFS= read -r -d '' jarfile; do
+        if [[ "$jarfile" =~ .*/fluss-server[^/]*.jar$ ]]; then
+            fluss_server_jar="$jarfile"
+            break
+        fi
+    done < <(find "${FLUSS_HOME}/lib" ! -type d -name '*.jar' -print0 | sort 
-z)
+
+    if [[ -z "$fluss_server_jar" ]]; then
+        echo "[readiness-check] ERROR: fluss-server jar not found in 
${FLUSS_HOME}/lib"
+        return 3
+    fi
+
+    # log4j-api lives next to fluss-server jar in the dist; include the whole 
lib/
+    # on the classpath so SLF4J/log4j wiring resolves correctly.
+    local classpath="${FLUSS_HOME}/lib/*"
+
+    # Find Java
+    local java_cmd="java"
+    if [[ -n "${JAVA_HOME}" && -x "${JAVA_HOME}/bin/java" ]]; then
+        java_cmd="${JAVA_HOME}/bin/java"
+    fi
+
+    # Run the check — host/port are explicit CLI flags; optional auth is read
+    # from READINESS_HEALTH_CHECK_AUTH env (already in the process 
environment).
+    local output
+    output=$("${java_cmd}" \
+        -XX:+IgnoreUnrecognizedVMOptions \
+        -Xmx64m \
+        -classpath "${classpath}" \
+        org.apache.fluss.server.tools.ClusterHealthReadinessCheck \
+        --host "${TCP_HOST}" \
+        --port "${TCP_PORT}" \
+        --timeoutMs "${timeout_ms}" 2>&1)
+    local exit_code=$?
+
+    # Log output to container's main process stderr (visible in kubectl logs)
+    if [[ -n "$output" ]]; then
+        echo "$output" >&2
+        if [[ -w /proc/1/fd/2 ]]; then
+            echo "[readiness-probe] $output" > /proc/1/fd/2
+        fi
+    fi
+
+    return $exit_code
+}
+
+# ---- Main Logic ----
+
+# Helper: log to container's main process (visible in kubectl logs)
+log_to_main() {
+    echo "$1" >&2
+    if [[ -w /proc/1/fd/2 ]]; then
+        echo "$1" > /proc/1/fd/2
+    fi
+}
+
+# ---- Step 1: Local TCP port check ----
+# If the local TS process hasn't bound its port yet, fail immediately.
+# No need to query the Coordinator for cluster state.
+if ! check_tcp; then
+    exit 1
+fi
+
+# ---- Steady-state fast path ----
+#
+# K8s readinessProbe runs every periodSeconds FOREVER, not only during a
+# rolling upgrade. The cluster health gate (Step 2 below) is meant to gate
+# the FIRST readiness of a freshly created pod so that recovery finishes
+# before traffic flows. Running it on every probe cycle is unsafe:
+#
+#   1. Coordinator pod briefly goes down (e.g. its own rolling restart).
+#   2. ALL tablet-server probes call run_recovery_check at the same period,
+#      all fail to reach Coordinator → all flip to NotReady simultaneously.
+#   3. statefulset then rolls all tablet-servers together → cascade outage:
+#         coordinator-server-0   0/2   Init:0/1
+#         tablet-server-0/1/2    0/2   Init:0/1
+#
+# So once the cluster has been GREEN at least once in this pod's lifetime
+# (FIRST_READY_MARKER present), we DROP to TCP-only. The marker lives in
+# /tmp/fluss-readiness, which is wiped whenever the pod is recreated by an
+# upgrade — so each freshly created pod will run the recovery gate exactly
+# ONCE before flipping to the fast path.
+if [[ -f "${FIRST_READY_MARKER}" ]]; then
+    # Log the switch-over notice exactly once per pod lifetime so operators
+    # can confirm via `kubectl logs` that this pod has graduated from the
+    # cluster health gate to the cheap TCP-only port check.
+    if [[ ! -f "${FAST_PATH_LOGGED}" ]]; then
+        log_to_main "[readiness-check] Switched to TCP-only port-readiness 
check; cluster health gate will not run again until this pod is recreated"
+        touch "${FAST_PATH_LOGGED}"
+    fi
+    exit 0
+fi
+
+# ---- Step 2: Cluster health check (first boot of this pod only) ----
+#
+# We reach here only if this pod has never been ready in its lifetime. Block
+# traffic until the cluster reports GREEN. Once we latch the marker,
+# subsequent probes take the fast path above.
+
+# Record the epoch of the first probe attempt so we can enforce the recovery
+# budget across subsequent (forked-fresh) Java CLI invocations.
+if [[ ! -f "${FIRST_PROBE_EPOCH_FILE}" ]]; then
+    date +%s > "${FIRST_PROBE_EPOCH_FILE}"
+fi
+first_probe_epoch=$(cat "${FIRST_PROBE_EPOCH_FILE}" 2>/dev/null || echo 0)
+now_epoch=$(date +%s)
+elapsed_seconds=$(( now_epoch - first_probe_epoch ))
+
+# Bail out of the gate if the cluster has stayed unhealthy for too long. We
+# explicitly trade off "do not serve traffic from a half-recovered server" for
+# "never wedge a rolling upgrade" — operators can lengthen the budget via
+# READINESS_HEALTH_CHECK_TIMEOUT_SECONDS if they want stricter behaviour.
+if (( elapsed_seconds >= HEALTH_CHECK_TIMEOUT_SECONDS )); then
+    log_to_main "[readiness-check] Cluster health gate budget exhausted 
(${elapsed_seconds}s >= ${HEALTH_CHECK_TIMEOUT_SECONDS}s); latching TCP fast 
path to unblock rolling upgrade"
+    touch "${FIRST_READY_MARKER}"
+    exit 0
+fi
+
+run_recovery_check "${TIMEOUT_MS}"
+local_exit=$?
+
+case $local_exit in
+    0)
+        # Recovery completed — latch fast path for the rest of this pod's life.
+        log_to_main "[readiness-check] Cluster health GREEN; latching fast 
path — next probe will switch to TCP-only port check"
+        touch "${FIRST_READY_MARKER}"
+        exit 0
+        ;;
+    1)
+        # Not recovered yet (or Coordinator temporarily unreachable) — stay
+        # unready. Next probe cycle will retry. This is the upgrade gate.
+        log_to_main "[readiness-check] First boot: BLOCKED (exit 1) — waiting 
for recovery"
+        exit 1
+        ;;
+    2)
+        # API unsupported (older Coordinator that predates the Cluster Health
+        # API). Server is up but definitively cannot answer; there is nothing
+        # to wait for, so latch the fast path immediately and rely on TCP-only
+        # readiness from now on. Waiting here would waste a grace period ×
+        # pod_count of dead time on the first upgrade from such a version,
+        # with zero protection gained (the API genuinely cannot be available
+        # while the entire cluster is on the old Coordinator).
+        log_to_main "[readiness-check] Cluster Health API unsupported by 
Coordinator, TCP fallback (latched)"
+        touch "${FIRST_READY_MARKER}"
+        exit 0
+        ;;
+    *)
+        # Configuration error — fall back to TCP and latch fast path so a
+        # broken probe never wedges the rolling upgrade forever.
+        log_to_main "[readiness-check] Config error (exit $local_exit), TCP 
fallback (latched)"
+        touch "${FIRST_READY_MARKER}"
+        exit 0
+        ;;
+esac
diff --git 
a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/sink/testutils/TestAdminAdapter.java
 
b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/sink/testutils/TestAdminAdapter.java
index 708226545..b8454c713 100644
--- 
a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/sink/testutils/TestAdminAdapter.java
+++ 
b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/sink/testutils/TestAdminAdapter.java
@@ -317,4 +317,9 @@ public class TestAdminAdapter implements Admin {
     public CompletableFuture<LakeSnapshot> getReadableLakeSnapshot(TablePath 
tablePath) {
         throw new UnsupportedOperationException("Not implemented in 
TestAdminAdapter");
     }
+
+    @Override
+    public CompletableFuture<org.apache.fluss.client.admin.ClusterHealth> 
getClusterHealth() {
+        throw new UnsupportedOperationException("Not implemented in 
TestAdminAdapter");
+    }
 }
diff --git 
a/fluss-rpc/src/main/java/org/apache/fluss/rpc/gateway/AdminReadOnlyGateway.java
 
b/fluss-rpc/src/main/java/org/apache/fluss/rpc/gateway/AdminReadOnlyGateway.java
index 272d1b4a1..574e1a510 100644
--- 
a/fluss-rpc/src/main/java/org/apache/fluss/rpc/gateway/AdminReadOnlyGateway.java
+++ 
b/fluss-rpc/src/main/java/org/apache/fluss/rpc/gateway/AdminReadOnlyGateway.java
@@ -22,6 +22,8 @@ import org.apache.fluss.rpc.messages.DatabaseExistsRequest;
 import org.apache.fluss.rpc.messages.DatabaseExistsResponse;
 import org.apache.fluss.rpc.messages.DescribeClusterConfigsRequest;
 import org.apache.fluss.rpc.messages.DescribeClusterConfigsResponse;
+import org.apache.fluss.rpc.messages.GetClusterHealthRequest;
+import org.apache.fluss.rpc.messages.GetClusterHealthResponse;
 import org.apache.fluss.rpc.messages.GetDatabaseInfoRequest;
 import org.apache.fluss.rpc.messages.GetDatabaseInfoResponse;
 import org.apache.fluss.rpc.messages.GetFileSystemSecurityTokenRequest;
@@ -192,4 +194,12 @@ public interface AdminReadOnlyGateway extends RpcGateway {
     @RPC(api = ApiKeys.DESCRIBE_CLUSTER_CONFIGS)
     CompletableFuture<DescribeClusterConfigsResponse> describeClusterConfigs(
             DescribeClusterConfigsRequest request);
+
+    /**
+     * Get the overall cluster health snapshot.
+     *
+     * @return the cluster health response.
+     */
+    @RPC(api = ApiKeys.GET_CLUSTER_HEALTH)
+    CompletableFuture<GetClusterHealthResponse> 
getClusterHealth(GetClusterHealthRequest request);
 }
diff --git a/fluss-rpc/src/main/java/org/apache/fluss/rpc/protocol/ApiKeys.java 
b/fluss-rpc/src/main/java/org/apache/fluss/rpc/protocol/ApiKeys.java
index 92a4680ee..dd164d7cc 100644
--- a/fluss-rpc/src/main/java/org/apache/fluss/rpc/protocol/ApiKeys.java
+++ b/fluss-rpc/src/main/java/org/apache/fluss/rpc/protocol/ApiKeys.java
@@ -103,7 +103,8 @@ public enum ApiKeys {
     DROP_KV_SNAPSHOT_LEASE(1058, 0, 0, PUBLIC),
     GET_TABLE_STATS(1059, 0, 0, PUBLIC),
     ALTER_DATABASE(1060, 0, 0, PUBLIC),
-    SCAN_KV(1061, 0, 0, PUBLIC);
+    SCAN_KV(1061, 0, 0, PUBLIC),
+    GET_CLUSTER_HEALTH(1062, 0, 0, PUBLIC);
 
     private static final Map<Integer, ApiKeys> ID_TO_TYPE =
             Arrays.stream(ApiKeys.values())
diff --git a/fluss-rpc/src/main/proto/FlussApi.proto 
b/fluss-rpc/src/main/proto/FlussApi.proto
index e8381215f..1e66bc343 100644
--- a/fluss-rpc/src/main/proto/FlussApi.proto
+++ b/fluss-rpc/src/main/proto/FlussApi.proto
@@ -767,6 +767,15 @@ message DeleteProducerOffsetsRequest {
 message DeleteProducerOffsetsResponse {
 }
 
+message GetClusterHealthRequest { }
+
+message GetClusterHealthResponse {
+  required int32 num_replicas = 1;
+  required int32 in_sync_replicas = 2;
+  required int32 num_leader_replicas = 3;
+  required int32 active_leader_replicas = 4;
+  required int32 status = 5; // PbClusterHealthStatus: GREEN=0, YELLOW=1, 
RED=2, UNKNOWN=3
+}
 
 
 // --------------- Inner classes ----------------
diff --git 
a/fluss-rpc/src/test/java/org/apache/fluss/rpc/TestingTabletGatewayService.java 
b/fluss-rpc/src/test/java/org/apache/fluss/rpc/TestingTabletGatewayService.java
index 351768093..f465bb4a6 100644
--- 
a/fluss-rpc/src/test/java/org/apache/fluss/rpc/TestingTabletGatewayService.java
+++ 
b/fluss-rpc/src/test/java/org/apache/fluss/rpc/TestingTabletGatewayService.java
@@ -25,6 +25,8 @@ import 
org.apache.fluss.rpc.messages.DescribeClusterConfigsRequest;
 import org.apache.fluss.rpc.messages.DescribeClusterConfigsResponse;
 import org.apache.fluss.rpc.messages.FetchLogRequest;
 import org.apache.fluss.rpc.messages.FetchLogResponse;
+import org.apache.fluss.rpc.messages.GetClusterHealthRequest;
+import org.apache.fluss.rpc.messages.GetClusterHealthResponse;
 import org.apache.fluss.rpc.messages.GetDatabaseInfoRequest;
 import org.apache.fluss.rpc.messages.GetDatabaseInfoResponse;
 import org.apache.fluss.rpc.messages.GetFileSystemSecurityTokenRequest;
@@ -264,4 +266,10 @@ public class TestingTabletGatewayService extends 
TestingGatewayService
     public CompletableFuture<ScanKvResponse> scanKv(ScanKvRequest request) {
         return null;
     }
+
+    @Override
+    public CompletableFuture<GetClusterHealthResponse> getClusterHealth(
+            GetClusterHealthRequest request) {
+        return null;
+    }
 }
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorContext.java
 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorContext.java
index 92dfc5ee5..dc0fa58a3 100644
--- 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorContext.java
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorContext.java
@@ -106,6 +106,13 @@ public class CoordinatorContext {
      */
     private final Map<Integer, Set<TableBucket>> replicasOnOffline = new 
HashMap<>();
 
+    /**
+     * Tracks buckets where a leader change has been dispatched (via 
NotifyLeaderAndIsr) but not yet
+     * confirmed by the target server. A bucket enters this set when we send 
the notification and
+     * leaves it when the target server successfully responds confirming it is 
the leader.
+     */
+    private final Set<TableBucket> pendingLeaderActivationBuckets = new 
HashSet<>();
+
     /** A mapping from tabletServers to server tag. */
     private final Map<Integer, ServerTag> serverTags = new HashMap<>();
 
@@ -218,6 +225,48 @@ public class CoordinatorContext {
         replicasOnOffline.remove(serverId);
     }
 
+    // ---- Pending leader activation tracking (for Cluster Health API) ----
+
+    public void addPendingLeaderActivation(TableBucket bucket) {
+        pendingLeaderActivationBuckets.add(bucket);
+    }
+
+    public void addPendingLeaderActivations(Collection<TableBucket> buckets) {
+        pendingLeaderActivationBuckets.addAll(buckets);
+    }
+
+    public void clearPendingLeaderActivation(TableBucket bucket) {
+        pendingLeaderActivationBuckets.remove(bucket);
+    }
+
+    /**
+     * Returns whether the given bucket has an active leader. A leader is 
considered active when:
+     *
+     * <ul>
+     *   <li>A LeaderAndIsr record exists for the bucket
+     *   <li>The leader is not {@link LeaderAndIsr#NO_LEADER}
+     *   <li>The leader's tablet server is alive
+     *   <li>The bucket is not pending leader activation confirmation
+     * </ul>
+     */
+    public boolean isLeaderActive(TableBucket bucket) {
+        return getBucketLeaderAndIsr(bucket)
+                .map(
+                        lai ->
+                                lai.leader() != LeaderAndIsr.NO_LEADER
+                                        && 
liveTabletServers.containsKey(lai.leader())
+                                        && 
!pendingLeaderActivationBuckets.contains(bucket))
+                .orElse(false);
+    }
+
+    public Set<TableBucket> getPendingLeaderActivationBuckets() {
+        return Collections.unmodifiableSet(pendingLeaderActivationBuckets);
+    }
+
+    public void removeFromPendingLeaderActivations(Set<TableBucket> buckets) {
+        pendingLeaderActivationBuckets.removeAll(buckets);
+    }
+
     public Map<Long, TablePath> allTables() {
         return tablePathById;
     }
@@ -656,10 +705,16 @@ public class CoordinatorContext {
         tablesToBeDeleted.remove(tableId);
         Map<Integer, List<Integer>> assignment = 
tableAssignments.remove(tableId);
         if (assignment != null) {
-            // remove leadership info for each bucket from the context
+            Set<TableBucket> removedBuckets = new HashSet<>();
             assignment
                     .keySet()
-                    .forEach(bucket -> bucketLeaderAndIsr.remove(new 
TableBucket(tableId, bucket)));
+                    .forEach(
+                            bucket -> {
+                                TableBucket tb = new TableBucket(tableId, 
bucket);
+                                bucketLeaderAndIsr.remove(tb);
+                                removedBuckets.add(tb);
+                            });
+            removeFromPendingLeaderActivations(removedBuckets);
         }
 
         TablePath tablePath = tablePathById.remove(tableId);
@@ -673,16 +728,20 @@ public class CoordinatorContext {
         partitionsToBeDeleted.remove(tablePartition);
         Map<Integer, List<Integer>> assignment = 
partitionAssignments.remove(tablePartition);
         if (assignment != null) {
-            // remove leadership info for each bucket from the context
+            Set<TableBucket> removedBuckets = new HashSet<>();
             assignment
                     .keySet()
                     .forEach(
-                            bucket ->
-                                    bucketLeaderAndIsr.remove(
-                                            new TableBucket(
-                                                    
tablePartition.getTableId(),
-                                                    
tablePartition.getPartitionId(),
-                                                    bucket)));
+                            bucket -> {
+                                TableBucket tb =
+                                        new TableBucket(
+                                                tablePartition.getTableId(),
+                                                
tablePartition.getPartitionId(),
+                                                bucket);
+                                bucketLeaderAndIsr.remove(tb);
+                                removedBuckets.add(tb);
+                            });
+            removeFromPendingLeaderActivations(removedBuckets);
         }
 
         PhysicalTablePath physicalTablePath =
@@ -717,6 +776,7 @@ public class CoordinatorContext {
         partitionAssignments.clear();
         bucketLeaderAndIsr.clear();
         replicasOnOffline.clear();
+        pendingLeaderActivationBuckets.clear();
         bucketStates.clear();
         replicaStates.clear();
         tablePathById.clear();
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessor.java
 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessor.java
index 62d63ae1f..13eaba282 100644
--- 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessor.java
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessor.java
@@ -1003,6 +1003,7 @@ public class CoordinatorEventProcessor implements 
EventProcessor {
         // get the server that receives the response
         int serverId = 
notifyLeaderAndIsrResponseReceivedEvent.getResponseServerId();
         Set<TableBucketReplica> offlineReplicas = new HashSet<>();
+        List<TableBucket> succeededBuckets = new ArrayList<>();
         // get all the results for each bucket
         List<NotifyLeaderAndIsrResultForBucket> 
notifyLeaderAndIsrResultForBuckets =
                 
notifyLeaderAndIsrResponseReceivedEvent.getNotifyLeaderAndIsrResultForBuckets();
@@ -1013,6 +1014,14 @@ public class CoordinatorEventProcessor implements 
EventProcessor {
                 offlineReplicas.add(
                         new TableBucketReplica(
                                 
notifyLeaderAndIsrResultForBucket.getTableBucket(), serverId));
+            } else {
+                
succeededBuckets.add(notifyLeaderAndIsrResultForBucket.getTableBucket());
+            }
+        }
+        for (TableBucket tb : succeededBuckets) {
+            Optional<LeaderAndIsr> laiOpt = 
coordinatorContext.getBucketLeaderAndIsr(tb);
+            if (laiOpt.isPresent() && laiOpt.get().leader() == serverId) {
+                coordinatorContext.clearPendingLeaderActivation(tb);
             }
         }
         if (!offlineReplicas.isEmpty()) {
@@ -1169,6 +1178,7 @@ public class CoordinatorEventProcessor implements 
EventProcessor {
         // process dead tablet server
         LOG.info("Tablet server failure callback for {}.", tabletServerId);
         coordinatorContext.removeOfflineBucketInServer(tabletServerId);
+
         coordinatorContext.removeLiveTabletServer(tabletServerId);
         coordinatorContext.shuttingDownTabletServers().remove(tabletServerId);
         coordinatorChannelManager.removeTabletServer(tabletServerId);
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorRequestBatch.java
 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorRequestBatch.java
index 344105367..3ec69feeb 100644
--- 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorRequestBatch.java
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorRequestBatch.java
@@ -35,6 +35,7 @@ import 
org.apache.fluss.rpc.messages.PbStopReplicaRespForBucket;
 import org.apache.fluss.rpc.messages.StopReplicaRequest;
 import org.apache.fluss.rpc.messages.UpdateMetadataRequest;
 import org.apache.fluss.rpc.protocol.ApiError;
+import org.apache.fluss.server.coordinator.event.AccessContextEvent;
 import 
org.apache.fluss.server.coordinator.event.DeleteReplicaResponseReceivedEvent;
 import org.apache.fluss.server.coordinator.event.EventManager;
 import 
org.apache.fluss.server.coordinator.event.NotifyLeaderAndIsrResponseReceivedEvent;
@@ -413,6 +414,18 @@ public class CoordinatorRequestBatch {
                     makeNotifyLeaderAndIsrRequest(
                             coordinatorEpoch, 
notifyRequestEntry.getValue().values());
 
+            // Track exactly which buckets THIS request marked as pending 
leader activation. Only
+            // those entries (where leader == serverId) need to be cleared if 
the request fails
+            Set<TableBucket> addedToPendingLeaderActivation = new HashSet<>();
+            for (Map.Entry<TableBucket, PbNotifyLeaderAndIsrReqForBucket> 
entry :
+                    notifyRequestEntry.getValue().entrySet()) {
+                int leader = entry.getValue().getLeader();
+                if (leader == serverId) {
+                    
coordinatorContext.addPendingLeaderActivation(entry.getKey());
+                    addedToPendingLeaderActivation.add(entry.getKey());
+                }
+            }
+
             coordinatorChannelManager.sendBucketLeaderAndIsrRequest(
                     serverId,
                     notifyLeaderAndIsrRequest,
@@ -428,6 +441,21 @@ public class CoordinatorRequestBatch {
                             // coordinator will remove the sender for the 
tablet server and mark all
                             // replica in the tablet server as offline. so, in 
here, if encounter
                             // any error, we just ignore it.
+
+                            // Clear pending state so the health API does not 
report stale
+                            // RED. The coordinator will detect actual server 
death via
+                            // heartbeat timeout and trigger re-election 
separately.
+                            if (!addedToPendingLeaderActivation.isEmpty()) {
+                                eventManager.put(
+                                        new AccessContextEvent<Void>(
+                                                ctx -> {
+                                                    for (TableBucket tb :
+                                                            
addedToPendingLeaderActivation) {
+                                                        
ctx.clearPendingLeaderActivation(tb);
+                                                    }
+                                                    return null;
+                                                }));
+                            }
                             return;
                         }
                         // put the response receive event into the event 
manager
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorService.java
 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorService.java
index 5a37aa9a6..8c122cb78 100644
--- 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorService.java
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorService.java
@@ -98,6 +98,8 @@ import org.apache.fluss.rpc.messages.DropPartitionRequest;
 import org.apache.fluss.rpc.messages.DropPartitionResponse;
 import org.apache.fluss.rpc.messages.DropTableRequest;
 import org.apache.fluss.rpc.messages.DropTableResponse;
+import org.apache.fluss.rpc.messages.GetClusterHealthRequest;
+import org.apache.fluss.rpc.messages.GetClusterHealthResponse;
 import org.apache.fluss.rpc.messages.GetProducerOffsetsRequest;
 import org.apache.fluss.rpc.messages.GetProducerOffsetsResponse;
 import org.apache.fluss.rpc.messages.LakeTieringHeartbeatRequest;
@@ -166,6 +168,7 @@ import 
org.apache.fluss.server.metadata.CoordinatorMetadataProvider;
 import org.apache.fluss.server.utils.ServerRpcMessageUtils;
 import org.apache.fluss.server.zk.ZooKeeperClient;
 import org.apache.fluss.server.zk.data.BucketAssignment;
+import org.apache.fluss.server.zk.data.LeaderAndIsr;
 import org.apache.fluss.server.zk.data.PartitionAssignment;
 import org.apache.fluss.server.zk.data.TableAssignment;
 import org.apache.fluss.server.zk.data.TableRegistration;
@@ -1234,6 +1237,60 @@ public final class CoordinatorService extends 
RpcServiceBase implements Coordina
         return response;
     }
 
+    @Override
+    public CompletableFuture<GetClusterHealthResponse> getClusterHealth(
+            GetClusterHealthRequest request) {
+        if (authorizer != null) {
+            authorizer.authorize(currentSession(), OperationType.DESCRIBE, 
Resource.cluster());
+        }
+
+        AccessContextEvent<GetClusterHealthResponse> event =
+                new 
AccessContextEvent<>(CoordinatorService::computeClusterHealth);
+        eventManagerSupplier.get().put(event);
+        return event.getResultFuture();
+    }
+
+    @VisibleForTesting
+    static GetClusterHealthResponse computeClusterHealth(CoordinatorContext 
ctx) {
+        GetClusterHealthResponse response = new GetClusterHealthResponse();
+
+        int numReplicas = 0;
+        int inSyncReplicas = 0;
+        int numLeaderReplicas = 0;
+        int activeLeaderReplicas = 0;
+
+        for (TableBucket tb : ctx.getAllBuckets()) {
+            List<Integer> assignment = ctx.getAssignment(tb);
+            numReplicas += assignment.size();
+            numLeaderReplicas++;
+
+            Optional<LeaderAndIsr> laiOpt = ctx.getBucketLeaderAndIsr(tb);
+            if (laiOpt.isPresent()) {
+                inSyncReplicas += laiOpt.get().isr().size();
+            }
+            if (ctx.isLeaderActive(tb)) {
+                activeLeaderReplicas++;
+            }
+        }
+
+        // PbClusterHealthStatus: GREEN=0, YELLOW=1, RED=2, UNKNOWN=3
+        int status;
+        if (activeLeaderReplicas < numLeaderReplicas) {
+            status = 2; // RED
+        } else if (inSyncReplicas < numReplicas) {
+            status = 1; // YELLOW
+        } else {
+            status = 0; // GREEN
+        }
+
+        response.setNumReplicas(numReplicas);
+        response.setInSyncReplicas(inSyncReplicas);
+        response.setNumLeaderReplicas(numLeaderReplicas);
+        response.setActiveLeaderReplicas(activeLeaderReplicas);
+        response.setStatus(status);
+        return response;
+    }
+
     @VisibleForTesting
     public DataLakeFormat getDataLakeFormat() {
         return 
lakeCatalogDynamicLoader.getLakeCatalogContainer().getDataLakeFormat();
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/event/CoordinatorEventManager.java
 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/event/CoordinatorEventManager.java
index 6f13df501..33df6f484 100644
--- 
a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/event/CoordinatorEventManager.java
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/event/CoordinatorEventManager.java
@@ -72,6 +72,12 @@ public final class CoordinatorEventManager implements 
EventManager {
     private volatile int partitionCount;
     private volatile int replicasToDeleteCount;
 
+    /**
+     * Number of buckets currently waiting for the leader-activation ack from 
the target tablet
+     * server.
+     */
+    private volatile int pendingLeaderActivationCount;
+
     private static final int WINDOW_SIZE = 100;
     private static final long METRICS_UPDATE_INTERVAL_MS = 5000; // 5 seconds
 
@@ -101,6 +107,8 @@ public final class CoordinatorEventManager implements 
EventManager {
         coordinatorMetricGroup.gauge(MetricNames.PARTITION_COUNT, () -> 
partitionCount);
         coordinatorMetricGroup.gauge(
                 MetricNames.REPLICAS_TO_DELETE_COUNT, () -> 
replicasToDeleteCount);
+        coordinatorMetricGroup.gauge(
+                MetricNames.PENDING_LEADER_ACTIVATION_COUNT, () -> 
pendingLeaderActivationCount);
     }
 
     /** Not thread safety! this method can only be executed in the 
CoordinatorEventThread. */
@@ -116,6 +124,8 @@ public final class CoordinatorEventManager implements 
EventManager {
                             int bucketCount = 
context.bucketLeaderAndIsr().size();
                             int partitionCount = 
context.getTotalPartitionCount();
                             int offlineBucketCount = 
context.getOfflineBucketCount();
+                            int pendingLeaderActivationCount =
+                                    
context.getPendingLeaderActivationBuckets().size();
 
                             int replicasToDeletes = 0;
                             // for replica in partitions to be deleted
@@ -150,7 +160,8 @@ public final class CoordinatorEventManager implements 
EventManager {
                                     bucketCount,
                                     partitionCount,
                                     offlineBucketCount,
-                                    replicasToDeletes);
+                                    replicasToDeletes,
+                                    pendingLeaderActivationCount);
                         });
 
         eventProcessor.process(accessContextEvent);
@@ -166,6 +177,7 @@ public final class CoordinatorEventManager implements 
EventManager {
             this.partitionCount = metricsData.partitionCount;
             this.offlineBucketCount = metricsData.offlineBucketCount;
             this.replicasToDeleteCount = metricsData.replicasToDeleteCount;
+            this.pendingLeaderActivationCount = 
metricsData.pendingLeaderActivationCount;
         } catch (Exception e) {
             LOG.warn("Failed to update metrics via AccessContextEvent", e);
         }
@@ -298,6 +310,7 @@ public final class CoordinatorEventManager implements 
EventManager {
         private final int partitionCount;
         private final int offlineBucketCount;
         private final int replicasToDeleteCount;
+        private final int pendingLeaderActivationCount;
 
         public MetricsData(
                 int coordinatorServerCount,
@@ -307,7 +320,8 @@ public final class CoordinatorEventManager implements 
EventManager {
                 int bucketCount,
                 int partitionCount,
                 int offlineBucketCount,
-                int replicasToDeleteCount) {
+                int replicasToDeleteCount,
+                int pendingLeaderActivationCount) {
             this.coordinatorServerCount = coordinatorServerCount;
             this.tabletServerCount = tabletServerCount;
             this.tableCount = tableCount;
@@ -316,6 +330,7 @@ public final class CoordinatorEventManager implements 
EventManager {
             this.partitionCount = partitionCount;
             this.offlineBucketCount = offlineBucketCount;
             this.replicasToDeleteCount = replicasToDeleteCount;
+            this.pendingLeaderActivationCount = pendingLeaderActivationCount;
         }
     }
 }
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/tablet/TabletServer.java 
b/fluss-server/src/main/java/org/apache/fluss/server/tablet/TabletServer.java
index 74afa8337..c7c5fdd78 100644
--- 
a/fluss-server/src/main/java/org/apache/fluss/server/tablet/TabletServer.java
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/tablet/TabletServer.java
@@ -315,7 +315,9 @@ public class TabletServer extends ServerBase {
                             authorizer,
                             dynamicConfigManager,
                             ioExecutor,
-                            scannerManager);
+                            scannerManager,
+                            coordinatorGateway,
+                            interListenerName);
 
             RequestsMetrics requestsMetrics =
                     
RequestsMetrics.createTabletServerRequestMetrics(tabletServerMetricGroup);
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/tablet/TabletService.java 
b/fluss-server/src/main/java/org/apache/fluss/server/tablet/TabletService.java
index bdeff1818..892bcbc34 100644
--- 
a/fluss-server/src/main/java/org/apache/fluss/server/tablet/TabletService.java
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/tablet/TabletService.java
@@ -22,6 +22,7 @@ import org.apache.fluss.exception.AuthorizationException;
 import org.apache.fluss.exception.InvalidScanRequestException;
 import org.apache.fluss.exception.NotLeaderOrFollowerException;
 import org.apache.fluss.exception.ScannerExpiredException;
+import org.apache.fluss.exception.StaleMetadataException;
 import org.apache.fluss.exception.UnknownScannerIdException;
 import org.apache.fluss.exception.UnknownTableOrBucketException;
 import org.apache.fluss.fs.FileSystem;
@@ -34,9 +35,12 @@ import org.apache.fluss.rpc.entity.FetchLogResultForBucket;
 import org.apache.fluss.rpc.entity.LookupResultForBucket;
 import org.apache.fluss.rpc.entity.PrefixLookupResultForBucket;
 import org.apache.fluss.rpc.entity.ResultForBucket;
+import org.apache.fluss.rpc.gateway.CoordinatorGateway;
 import org.apache.fluss.rpc.gateway.TabletServerGateway;
 import org.apache.fluss.rpc.messages.FetchLogRequest;
 import org.apache.fluss.rpc.messages.FetchLogResponse;
+import org.apache.fluss.rpc.messages.GetClusterHealthRequest;
+import org.apache.fluss.rpc.messages.GetClusterHealthResponse;
 import org.apache.fluss.rpc.messages.GetTableStatsRequest;
 import org.apache.fluss.rpc.messages.GetTableStatsResponse;
 import org.apache.fluss.rpc.messages.InitWriterRequest;
@@ -148,6 +152,8 @@ public final class TabletService extends RpcServiceBase 
implements TabletServerG
     private final TabletServerMetadataCache metadataCache;
     private final TabletServerMetadataProvider metadataFunctionProvider;
     private final ScannerManager scannerManager;
+    private final CoordinatorGateway coordinatorGateway;
+    private final String interListenerName;
 
     public TabletService(
             int serverId,
@@ -159,7 +165,9 @@ public final class TabletService extends RpcServiceBase 
implements TabletServerG
             @Nullable Authorizer authorizer,
             DynamicConfigManager dynamicConfigManager,
             ExecutorService ioExecutor,
-            ScannerManager scannerManager) {
+            ScannerManager scannerManager,
+            CoordinatorGateway coordinatorGateway,
+            String interListenerName) {
         super(
                 remoteFileSystem,
                 ServerType.TABLET_SERVER,
@@ -174,6 +182,8 @@ public final class TabletService extends RpcServiceBase 
implements TabletServerG
         this.metadataFunctionProvider =
                 new TabletServerMetadataProvider(zkClient, metadataManager, 
metadataCache);
         this.scannerManager = scannerManager;
+        this.coordinatorGateway = coordinatorGateway;
+        this.interListenerName = interListenerName;
     }
 
     @Override
@@ -446,6 +456,29 @@ public final class TabletService extends RpcServiceBase 
implements TabletServerG
         return response;
     }
 
+    @Override
+    public CompletableFuture<GetClusterHealthResponse> getClusterHealth(
+            GetClusterHealthRequest request) {
+        // Tablet servers don't own the cluster-wide health view; we forward 
the call to the
+        // coordinator over the internal listener.
+        if (authorizer != null) {
+            authorizer.authorize(currentSession(), OperationType.DESCRIBE, 
Resource.cluster());
+        }
+
+        if (metadataCache.getCoordinatorServer(interListenerName) == null) {
+            // Fail fast during the startup window before the tablet has 
received its first
+            // UpdateMetadataRequest from the coordinator. The supplier inside 
coordinatorGateway
+            // would otherwise block-and-retry, which is not what readiness 
probes want.
+            CompletableFuture<GetClusterHealthResponse> failed = new 
CompletableFuture<>();
+            failed.completeExceptionally(
+                    new StaleMetadataException(
+                            "Tablet server has not yet received coordinator 
metadata; cluster"
+                                    + " health is unavailable."));
+            return failed;
+        }
+        return coordinatorGateway.getClusterHealth(request);
+    }
+
     @Override
     public CompletableFuture<ScanKvResponse> scanKv(ScanKvRequest request) {
         ScanKvResponse response = new ScanKvResponse();
diff --git 
a/fluss-server/src/main/java/org/apache/fluss/server/tools/ClusterHealthReadinessCheck.java
 
b/fluss-server/src/main/java/org/apache/fluss/server/tools/ClusterHealthReadinessCheck.java
new file mode 100644
index 000000000..320afbc60
--- /dev/null
+++ 
b/fluss-server/src/main/java/org/apache/fluss/server/tools/ClusterHealthReadinessCheck.java
@@ -0,0 +1,265 @@
+/*
+ * 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.fluss.server.tools;
+
+import org.apache.fluss.cluster.ServerNode;
+import org.apache.fluss.cluster.ServerType;
+import org.apache.fluss.config.Configuration;
+import org.apache.fluss.exception.UnsupportedVersionException;
+import org.apache.fluss.metrics.registry.MetricRegistryImpl;
+import org.apache.fluss.rpc.GatewayClientProxy;
+import org.apache.fluss.rpc.RpcClient;
+import org.apache.fluss.rpc.gateway.TabletServerGateway;
+import org.apache.fluss.rpc.messages.GetClusterHealthRequest;
+import org.apache.fluss.rpc.messages.GetClusterHealthResponse;
+import org.apache.fluss.rpc.metrics.ClientMetricGroup;
+import org.apache.fluss.utils.ExceptionUtils;
+
+import java.util.Collections;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * Lightweight readiness-check CLI for Fluss tablet-server pods.
+ *
+ * <p>The probe connects to a tablet server (typically the local one on {@code 
127.0.0.1}) and
+ * issues a single {@code getClusterHealth} call. The tablet server forwards 
the request to the
+ * coordinator over its internal listener and returns the cluster-wide health 
snapshot. This means
+ * the readiness probe never has to know the coordinator's address and 
survives coordinator pod
+ * restarts cleanly.
+ *
+ * <h3>Inputs</h3>
+ *
+ * <p>For every input below, a CLI argument (when supplied) takes precedence 
over the corresponding
+ * environment variable. The env vars exist so the Kubernetes manifest can 
stay declarative; the CLI
+ * flags exist so an operator can override them ad-hoc when running the probe 
by hand.
+ *
+ * <ul>
+ *   <li>{@code --timeoutMs <ms>}: optional, defaults to {@value 
#DEFAULT_TIMEOUT_MS}.
+ *   <li>{@code --host <host>} / env {@value #ENV_TCP_HOST}: tablet server 
host. Defaults to {@code
+ *       127.0.0.1}.
+ *   <li>{@code --port <port>} / env {@value #ENV_TCP_PORT}: tablet server 
port. Defaults to {@value
+ *       #DEFAULT_TCP_PORT}.
+ *   <li>{@code --healthCheckAuth <props>} / env {@value 
#ENV_HEALTH_CHECK_AUTH}: optional client
+ *       auth configuration as semicolon-separated {@code key:value} pairs 
(e.g. {@code
+ *       client.security.protocol:SASL;client.sasl.mechanism:PLAIN;
+ *       
client.security.sasl.username:admin;client.security.sasl.password:admin-pass}). 
When
+ *       absent, the probe uses unauthenticated PLAINTEXT.
+ * </ul>
+ *
+ * <h3>Exit codes</h3>
+ *
+ * <ul>
+ *   <li>{@value #EXIT_READY} — cluster status is GREEN (Ready).
+ *   <li>{@value #EXIT_NOT_READY} — cluster status is YELLOW/RED/UNKNOWN, the 
tablet server is
+ *       unreachable, or the RPC timed out.
+ *   <li>{@value #EXIT_API_UNSUPPORTED} — tablet server is reachable but does 
not implement the
+ *       cluster-health API ({@link UnsupportedVersionException}).
+ *   <li>{@value #EXIT_ERROR} — invalid arguments or environment.
+ * </ul>
+ */
+public final class ClusterHealthReadinessCheck {
+
+    public static final int EXIT_READY = 0;
+    public static final int EXIT_NOT_READY = 1;
+    public static final int EXIT_API_UNSUPPORTED = 2;
+    public static final int EXIT_ERROR = 3;
+
+    private static final long DEFAULT_TIMEOUT_MS = 5000;
+    private static final String DEFAULT_TCP_HOST = "127.0.0.1";
+    private static final int DEFAULT_TCP_PORT = 9124;
+
+    /** Environment variable carrying the tablet server host. */
+    static final String ENV_TCP_HOST = "READINESS_TCP_HOST";
+
+    /** Environment variable carrying the tablet server port. */
+    static final String ENV_TCP_PORT = "READINESS_TCP_PORT";
+
+    /**
+     * Environment variable carrying client auth configuration as 
semicolon-separated {@code
+     * key:value} pairs. Values are forwarded verbatim to {@link 
Configuration} before {@link
+     * RpcClient#create} is called.
+     */
+    static final String ENV_HEALTH_CHECK_AUTH = "READINESS_HEALTH_CHECK_AUTH";
+
+    /**
+     * GREEN status code as produced by {@code 
CoordinatorService#computeClusterHealth} (mirrors
+     * {@code PbClusterHealthStatus.GREEN}).
+     */
+    private static final int STATUS_GREEN = 0;
+
+    private ClusterHealthReadinessCheck() {}
+
+    public static void main(String[] args) {
+        System.exit(run(args));
+    }
+
+    static int run(String[] args) {
+        long timeoutMs = DEFAULT_TIMEOUT_MS;
+        String hostArg = null;
+        String portArg = null;
+        String authArg = null;
+        for (int i = 0; i < args.length; i++) {
+            if ("--timeoutMs".equals(args[i]) && i + 1 < args.length) {
+                String raw = args[++i];
+                try {
+                    timeoutMs = Long.parseLong(raw);
+                } catch (NumberFormatException e) {
+                    System.err.println(
+                            "[readiness-check] ERROR: invalid --timeoutMs 
value: " + raw);
+                    return EXIT_ERROR;
+                }
+            } else if ("--host".equals(args[i]) && i + 1 < args.length) {
+                hostArg = args[++i];
+            } else if ("--port".equals(args[i]) && i + 1 < args.length) {
+                portArg = args[++i];
+            } else if ("--healthCheckAuth".equals(args[i]) && i + 1 < 
args.length) {
+                authArg = args[++i];
+            }
+        }
+
+        // CLI flag takes precedence over env so operators can override ad-hoc.
+        String host = firstNonBlank(hostArg, System.getenv(ENV_TCP_HOST), 
DEFAULT_TCP_HOST);
+        String portStr = firstNonBlank(portArg, System.getenv(ENV_TCP_PORT), 
null);
+        int port = DEFAULT_TCP_PORT;
+        if (portStr != null) {
+            try {
+                port = Integer.parseInt(portStr.trim());
+            } catch (NumberFormatException e) {
+                System.err.println("[readiness-check] ERROR: invalid port \"" 
+ portStr + "\"");
+                return EXIT_ERROR;
+            }
+        }
+        // The server id is irrelevant for a one-shot probe; pick a sentinel 
value.
+        ServerNode tabletServer = new ServerNode(0, host, port, 
ServerType.TABLET_SERVER);
+        String address = host + ":" + port;
+
+        String authPropsString = authArg != null ? authArg : 
System.getenv(ENV_HEALTH_CHECK_AUTH);
+        Configuration conf;
+        try {
+            conf = buildConfiguration(authPropsString);
+        } catch (IllegalArgumentException e) {
+            System.err.println(
+                    "[readiness-check] ERROR: cannot parse auth properties ("
+                            + (authArg != null ? "--healthCheckAuth" : 
ENV_HEALTH_CHECK_AUTH)
+                            + "): "
+                            + e.getMessage());
+            return EXIT_ERROR;
+        }
+
+        MetricRegistryImpl registry = new 
MetricRegistryImpl(Collections.emptyList());
+        ClientMetricGroup metricGroup = new ClientMetricGroup(registry, 
"readiness-probe");
+        try (RpcClient rpcClient = RpcClient.create(conf, metricGroup)) {
+            TabletServerGateway gateway =
+                    GatewayClientProxy.createGatewayProxy(
+                            () -> tabletServer, rpcClient, 
TabletServerGateway.class);
+            GetClusterHealthResponse resp =
+                    gateway.getClusterHealth(new GetClusterHealthRequest())
+                            .get(timeoutMs, TimeUnit.MILLISECONDS);
+            return evaluate(resp);
+        } catch (TimeoutException e) {
+            System.err.println(
+                    "[readiness-check] Timeout calling getClusterHealth on "
+                            + address
+                            + ", treating as not ready");
+            return EXIT_NOT_READY;
+        } catch (ExecutionException e) {
+            Throwable cause = e.getCause() != null ? e.getCause() : e;
+            if (ExceptionUtils.findThrowable(cause, 
UnsupportedVersionException.class)
+                    .isPresent()) {
+                System.err.println("[readiness-check] API unsupported: " + 
cause.getMessage());
+                return EXIT_API_UNSUPPORTED;
+            }
+            System.err.println(
+                    "[readiness-check] Cannot reach tablet server at "
+                            + address
+                            + ", treating as not ready: "
+                            + cause.getMessage());
+            return EXIT_NOT_READY;
+        } catch (Exception e) {
+            System.err.println(
+                    "[readiness-check] Cannot reach tablet server at "
+                            + address
+                            + ", treating as not ready: "
+                            + e.getMessage());
+            return EXIT_NOT_READY;
+        } finally {
+            // Best-effort: shut down the metric registry so the probe JVM can 
exit cleanly.
+            try {
+                registry.closeAsync();
+            } catch (Exception ignored) {
+                // ignore — probe process is about to exit anyway
+            }
+        }
+    }
+
+    /** Returns the first argument that is non-null and non-blank; otherwise 
{@code fallback}. */
+    private static String firstNonBlank(String... candidates) {
+        for (int i = 0; i < candidates.length - 1; i++) {
+            String c = candidates[i];
+            if (c != null && !c.trim().isEmpty()) {
+                return c;
+            }
+        }
+        return candidates[candidates.length - 1];
+    }
+
+    /**
+     * Build the {@link Configuration} fed to {@link RpcClient#create} from 
the optional auth
+     * configuration string. Format: semicolon-separated {@code key:value} 
pairs, e.g. {@code
+     * client.security.protocol:SASL;client.sasl.mechanism:PLAIN}.
+     */
+    private static Configuration buildConfiguration(String authString) {
+        Configuration conf = new Configuration();
+        if (authString == null || authString.trim().isEmpty()) {
+            return conf;
+        }
+        for (String pair : authString.split(";")) {
+            String trimmed = pair.trim();
+            if (trimmed.isEmpty()) {
+                continue;
+            }
+            int idx = trimmed.indexOf(':');
+            if (idx <= 0 || idx == trimmed.length() - 1) {
+                throw new IllegalArgumentException(
+                        "expected 'key:value' pairs separated by ';', got: \"" 
+ pair + "\"");
+            }
+            String key = trimmed.substring(0, idx).trim();
+            String value = trimmed.substring(idx + 1).trim();
+            conf.setString(key, value);
+        }
+        return conf;
+    }
+
+    private static int evaluate(GetClusterHealthResponse resp) {
+        int status = resp.getStatus();
+        System.out.println(
+                "[readiness-check] status="
+                        + status
+                        + " numReplicas="
+                        + resp.getNumReplicas()
+                        + " inSyncReplicas="
+                        + resp.getInSyncReplicas()
+                        + " numLeaderReplicas="
+                        + resp.getNumLeaderReplicas()
+                        + " activeLeaderReplicas="
+                        + resp.getActiveLeaderReplicas());
+        return status == STATUS_GREEN ? EXIT_READY : EXIT_NOT_READY;
+    }
+}
diff --git 
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/ClusterHealthTest.java
 
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/ClusterHealthTest.java
new file mode 100644
index 000000000..cf5d153f9
--- /dev/null
+++ 
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/ClusterHealthTest.java
@@ -0,0 +1,346 @@
+/*
+ * 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.fluss.server.coordinator;
+
+import org.apache.fluss.cluster.Endpoint;
+import org.apache.fluss.cluster.ServerType;
+import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.metadata.TablePartition;
+import org.apache.fluss.rpc.messages.GetClusterHealthResponse;
+import org.apache.fluss.server.metadata.ServerInfo;
+import org.apache.fluss.server.zk.ZkEpoch;
+import org.apache.fluss.server.zk.data.LeaderAndIsr;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link CoordinatorService#computeClusterHealth}. */
+class ClusterHealthTest {
+
+    private CoordinatorContext ctx;
+
+    @BeforeEach
+    void setUp() {
+        ctx = new CoordinatorContext(ZkEpoch.INITIAL_EPOCH);
+        ctx.setLiveTabletServers(
+                Arrays.asList(makeServerInfo(0), makeServerInfo(1), 
makeServerInfo(2)));
+    }
+
+    @Test
+    void testEmptyCluster() {
+        GetClusterHealthResponse resp = 
CoordinatorService.computeClusterHealth(ctx);
+
+        assertThat(resp.getNumReplicas()).isEqualTo(0);
+        assertThat(resp.getInSyncReplicas()).isEqualTo(0);
+        assertThat(resp.getNumLeaderReplicas()).isEqualTo(0);
+        assertThat(resp.getActiveLeaderReplicas()).isEqualTo(0);
+        assertThat(resp.getStatus()).isEqualTo(0 /* GREEN */);
+    }
+
+    @Test
+    void testGreenAllInSyncAllLeadersActive() {
+        TableBucket tb = new TableBucket(1L, 0);
+        ctx.updateBucketReplicaAssignment(tb, Arrays.asList(0, 1, 2));
+        ctx.putBucketLeaderAndIsr(
+                tb, new LeaderAndIsr(0, 1, Arrays.asList(0, 1, 2), 
Collections.emptyList(), 0, 1));
+
+        GetClusterHealthResponse resp = 
CoordinatorService.computeClusterHealth(ctx);
+
+        assertThat(resp.getNumReplicas()).isEqualTo(3);
+        assertThat(resp.getInSyncReplicas()).isEqualTo(3);
+        assertThat(resp.getNumLeaderReplicas()).isEqualTo(1);
+        assertThat(resp.getActiveLeaderReplicas()).isEqualTo(1);
+        assertThat(resp.getStatus()).isEqualTo(0 /* GREEN */);
+    }
+
+    @Test
+    void testYellowIsrIncompleteButLeadersActive() {
+        TableBucket tb = new TableBucket(1L, 0);
+        ctx.updateBucketReplicaAssignment(tb, Arrays.asList(0, 1, 2));
+        ctx.putBucketLeaderAndIsr(
+                tb, new LeaderAndIsr(0, 1, Arrays.asList(0, 1), 
Collections.emptyList(), 0, 1));
+
+        GetClusterHealthResponse resp = 
CoordinatorService.computeClusterHealth(ctx);
+
+        assertThat(resp.getNumReplicas()).isEqualTo(3);
+        assertThat(resp.getInSyncReplicas()).isEqualTo(2);
+        assertThat(resp.getNumLeaderReplicas()).isEqualTo(1);
+        assertThat(resp.getActiveLeaderReplicas()).isEqualTo(1);
+        assertThat(resp.getStatus()).isEqualTo(1 /* YELLOW */);
+    }
+
+    @Test
+    void testRedLeaderInactive() {
+        TableBucket tb = new TableBucket(1L, 0);
+        ctx.updateBucketReplicaAssignment(tb, Arrays.asList(0, 1, 2));
+        ctx.putBucketLeaderAndIsr(
+                tb, new LeaderAndIsr(0, 1, Arrays.asList(0, 1, 2), 
Collections.emptyList(), 0, 1));
+        ctx.addPendingLeaderActivation(tb);
+
+        GetClusterHealthResponse resp = 
CoordinatorService.computeClusterHealth(ctx);
+
+        assertThat(resp.getNumReplicas()).isEqualTo(3);
+        assertThat(resp.getInSyncReplicas()).isEqualTo(3);
+        assertThat(resp.getNumLeaderReplicas()).isEqualTo(1);
+        assertThat(resp.getActiveLeaderReplicas()).isEqualTo(0);
+        assertThat(resp.getStatus()).isEqualTo(2 /* RED */);
+    }
+
+    @Test
+    void testRedNoLeader() {
+        TableBucket tb = new TableBucket(1L, 0);
+        ctx.updateBucketReplicaAssignment(tb, Arrays.asList(0, 1));
+        ctx.putBucketLeaderAndIsr(
+                tb,
+                new LeaderAndIsr(
+                        LeaderAndIsr.NO_LEADER,
+                        1,
+                        Arrays.asList(0, 1),
+                        Collections.emptyList(),
+                        0,
+                        1));
+
+        GetClusterHealthResponse resp = 
CoordinatorService.computeClusterHealth(ctx);
+
+        assertThat(resp.getActiveLeaderReplicas()).isEqualTo(0);
+        assertThat(resp.getStatus()).isEqualTo(2 /* RED */);
+    }
+
+    @Test
+    void testRedLeaderOnDeadServer() {
+        TableBucket tb = new TableBucket(1L, 0);
+        ctx.updateBucketReplicaAssignment(tb, Arrays.asList(0, 1, 5));
+        ctx.putBucketLeaderAndIsr(
+                tb, new LeaderAndIsr(5, 1, Arrays.asList(0, 1, 5), 
Collections.emptyList(), 0, 1));
+
+        GetClusterHealthResponse resp = 
CoordinatorService.computeClusterHealth(ctx);
+
+        assertThat(resp.getActiveLeaderReplicas()).isEqualTo(0);
+        assertThat(resp.getStatus()).isEqualTo(2 /* RED */);
+    }
+
+    @Test
+    void testNoLeaderAndIsrCountsAsRedAndZeroIsr() {
+        TableBucket tb = new TableBucket(1L, 0);
+        ctx.updateBucketReplicaAssignment(tb, Arrays.asList(0, 1));
+
+        GetClusterHealthResponse resp = 
CoordinatorService.computeClusterHealth(ctx);
+
+        assertThat(resp.getNumReplicas()).isEqualTo(2);
+        assertThat(resp.getInSyncReplicas()).isEqualTo(0);
+        assertThat(resp.getNumLeaderReplicas()).isEqualTo(1);
+        assertThat(resp.getActiveLeaderReplicas()).isEqualTo(0);
+        assertThat(resp.getStatus()).isEqualTo(2 /* RED */);
+    }
+
+    @Test
+    void testTransitionRedToGreenAfterMarkActive() {
+        TableBucket tb = new TableBucket(1L, 0);
+        ctx.updateBucketReplicaAssignment(tb, Arrays.asList(0, 1));
+        ctx.putBucketLeaderAndIsr(
+                tb, new LeaderAndIsr(0, 1, Arrays.asList(0, 1), 
Collections.emptyList(), 0, 1));
+        ctx.addPendingLeaderActivation(tb);
+
+        
assertThat(CoordinatorService.computeClusterHealth(ctx).getStatus()).isEqualTo(2
 /* RED */);
+
+        ctx.clearPendingLeaderActivation(tb);
+
+        assertThat(CoordinatorService.computeClusterHealth(ctx).getStatus())
+                .isEqualTo(0 /* GREEN */);
+    }
+
+    @Test
+    void testMultipleBucketsMixed() {
+        TableBucket tb1 = new TableBucket(1L, 0);
+        TableBucket tb2 = new TableBucket(1L, 1);
+        TableBucket tb3 = new TableBucket(2L, 0);
+
+        ctx.updateBucketReplicaAssignment(tb1, Arrays.asList(0, 1));
+        ctx.updateBucketReplicaAssignment(tb2, Arrays.asList(1, 2));
+        ctx.updateBucketReplicaAssignment(tb3, Arrays.asList(0, 2));
+
+        ctx.putBucketLeaderAndIsr(
+                tb1, new LeaderAndIsr(0, 1, Arrays.asList(0, 1), 
Collections.emptyList(), 0, 1));
+        ctx.putBucketLeaderAndIsr(
+                tb2,
+                new LeaderAndIsr(
+                        1, 1, Collections.singletonList(1), 
Collections.emptyList(), 0, 1));
+        ctx.putBucketLeaderAndIsr(
+                tb3, new LeaderAndIsr(0, 1, Arrays.asList(0, 2), 
Collections.emptyList(), 0, 1));
+
+        GetClusterHealthResponse resp = 
CoordinatorService.computeClusterHealth(ctx);
+
+        assertThat(resp.getNumReplicas()).isEqualTo(6);
+        assertThat(resp.getInSyncReplicas()).isEqualTo(5);
+        assertThat(resp.getNumLeaderReplicas()).isEqualTo(3);
+        assertThat(resp.getActiveLeaderReplicas()).isEqualTo(3);
+        assertThat(resp.getStatus()).isEqualTo(1 /* YELLOW */);
+    }
+
+    @Test
+    void testPartitionedTableBucket() {
+        TableBucket tb = new TableBucket(1L, 100L, 0);
+        ctx.updateBucketReplicaAssignment(tb, Arrays.asList(0, 1));
+        ctx.putBucketLeaderAndIsr(
+                tb,
+                new LeaderAndIsr(
+                        0, 1, Collections.singletonList(0), 
Collections.emptyList(), 0, 1));
+
+        GetClusterHealthResponse resp = 
CoordinatorService.computeClusterHealth(ctx);
+
+        assertThat(resp.getNumReplicas()).isEqualTo(2);
+        assertThat(resp.getInSyncReplicas()).isEqualTo(1);
+        assertThat(resp.getNumLeaderReplicas()).isEqualTo(1);
+        assertThat(resp.getActiveLeaderReplicas()).isEqualTo(1);
+        assertThat(resp.getStatus()).isEqualTo(1 /* YELLOW */);
+    }
+
+    @Test
+    void testInactiveLeaderClearedOnTableRemoval() {
+        TableBucket tb = new TableBucket(1L, 0);
+        ctx.updateBucketReplicaAssignment(tb, Arrays.asList(0, 1));
+        ctx.putBucketLeaderAndIsr(
+                tb, new LeaderAndIsr(0, 1, Arrays.asList(0, 1), 
Collections.emptyList(), 0, 1));
+        ctx.addPendingLeaderActivation(tb);
+
+        assertThat(ctx.getPendingLeaderActivationBuckets()).contains(tb);
+
+        ctx.removeTable(1L);
+
+        assertThat(ctx.getPendingLeaderActivationBuckets()).doesNotContain(tb);
+    }
+
+    @Test
+    void testRedWithInactiveLeaderAndIncompleteIsr() {
+        TableBucket tb1 = new TableBucket(1L, 0);
+        TableBucket tb2 = new TableBucket(1L, 1);
+
+        ctx.updateBucketReplicaAssignment(tb1, Arrays.asList(0, 1));
+        ctx.updateBucketReplicaAssignment(tb2, Arrays.asList(0, 1));
+
+        ctx.putBucketLeaderAndIsr(
+                tb1,
+                new LeaderAndIsr(
+                        0, 1, Collections.singletonList(0), 
Collections.emptyList(), 0, 1));
+        ctx.putBucketLeaderAndIsr(
+                tb2, new LeaderAndIsr(1, 1, Arrays.asList(0, 1), 
Collections.emptyList(), 0, 1));
+
+        ctx.addPendingLeaderActivation(tb2);
+
+        GetClusterHealthResponse resp = 
CoordinatorService.computeClusterHealth(ctx);
+
+        assertThat(resp.getNumReplicas()).isEqualTo(4);
+        assertThat(resp.getInSyncReplicas()).isEqualTo(3);
+        assertThat(resp.getActiveLeaderReplicas()).isEqualTo(1);
+        assertThat(resp.getStatus()).isEqualTo(2 /* RED */);
+    }
+
+    @Test
+    void testInactiveLeaderClearedOnPartitionRemoval() {
+        TableBucket tb = new TableBucket(1L, 100L, 0);
+        ctx.updateBucketReplicaAssignment(tb, Arrays.asList(0, 1));
+        ctx.putBucketLeaderAndIsr(
+                tb, new LeaderAndIsr(0, 1, Arrays.asList(0, 1), 
Collections.emptyList(), 0, 1));
+        ctx.addPendingLeaderActivation(tb);
+
+        assertThat(ctx.getPendingLeaderActivationBuckets()).contains(tb);
+
+        ctx.removePartition(new TablePartition(1L, 100L));
+
+        assertThat(ctx.getPendingLeaderActivationBuckets()).doesNotContain(tb);
+    }
+
+    @Test
+    void testFollowerNotificationDoesNotMarkInactive() {
+        // Simulate sendNotifyLeaderAndIsrRequest: when NotifyLeaderAndIsr is 
sent
+        // to a server for a bucket where it is a follower, the bucket should 
NOT
+        // be marked inactive. Only the server that is the leader gets marked.
+        TableBucket tb = new TableBucket(1L, 0);
+        ctx.updateBucketReplicaAssignment(tb, Arrays.asList(0, 1));
+        ctx.putBucketLeaderAndIsr(
+                tb, new LeaderAndIsr(0, 1, Arrays.asList(0, 1), 
Collections.emptyList(), 0, 1));
+
+        // Sending to server 1 (follower): leader=0, serverId=1
+        // Follower does not match leader, so addPendingLeaderActivation is 
NOT called.
+        assertThat(ctx.isLeaderActive(tb)).isTrue();
+        assertThat(CoordinatorService.computeClusterHealth(ctx).getStatus())
+                .isEqualTo(0 /* GREEN */);
+
+        // Sending to server 0 (leader): leader=0, serverId=0 — matches, so 
mark pending.
+        ctx.addPendingLeaderActivation(tb);
+        assertThat(ctx.isLeaderActive(tb)).isFalse();
+        
assertThat(CoordinatorService.computeClusterHealth(ctx).getStatus()).isEqualTo(2
 /* RED */);
+    }
+
+    @Test
+    void testLeaderChangedBetweenSendAndResponseStaysInactive() {
+        // Simulate processNotifyLeaderAndIsrResponseReceivedEvent: if the 
leader
+        // changed between send and response, the responding server is no 
longer
+        // the leader, so the bucket must stay inactive.
+        TableBucket tb = new TableBucket(1L, 0);
+        ctx.updateBucketReplicaAssignment(tb, Arrays.asList(0, 1, 2));
+        ctx.putBucketLeaderAndIsr(
+                tb, new LeaderAndIsr(0, 1, Arrays.asList(0, 1, 2), 
Collections.emptyList(), 0, 1));
+        ctx.addPendingLeaderActivation(tb);
+
+        // Simulate: leader changed from server 0 to server 1 before server 0 
responds
+        ctx.putBucketLeaderAndIsr(
+                tb, new LeaderAndIsr(1, 2, Arrays.asList(0, 1, 2), 
Collections.emptyList(), 0, 1));
+
+        // Server 0 responds successfully — but it's no longer the leader
+        int respondingServerId = 0;
+        ctx.getBucketLeaderAndIsr(tb)
+                .ifPresent(
+                        lai -> {
+                            if (lai.leader() == respondingServerId) {
+                                ctx.clearPendingLeaderActivation(tb);
+                            }
+                        });
+
+        // Bucket must stay inactive because the responding server (0) != 
current leader (1)
+        assertThat(ctx.isLeaderActive(tb)).isFalse();
+        
assertThat(CoordinatorService.computeClusterHealth(ctx).getStatus()).isEqualTo(2
 /* RED */);
+
+        // Now server 1 (the actual leader) responds → bucket becomes active
+        int actualLeaderServerId = 1;
+        ctx.getBucketLeaderAndIsr(tb)
+                .ifPresent(
+                        lai -> {
+                            if (lai.leader() == actualLeaderServerId) {
+                                ctx.clearPendingLeaderActivation(tb);
+                            }
+                        });
+
+        assertThat(ctx.isLeaderActive(tb)).isTrue();
+        assertThat(CoordinatorService.computeClusterHealth(ctx).getStatus())
+                .isEqualTo(0 /* GREEN */);
+    }
+
+    private static ServerInfo makeServerInfo(int id) {
+        return new ServerInfo(
+                id,
+                "RACK" + id,
+                Endpoint.fromListenersString("CLIENT://host" + id + ":9124"),
+                ServerType.TABLET_SERVER);
+    }
+}
diff --git 
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorContextTest.java
 
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorContextTest.java
index 2b788bdb4..b69fe5fa3 100644
--- 
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorContextTest.java
+++ 
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorContextTest.java
@@ -17,17 +17,26 @@
 
 package org.apache.fluss.server.coordinator;
 
+import org.apache.fluss.cluster.Endpoint;
+import org.apache.fluss.cluster.ServerType;
 import org.apache.fluss.config.ConfigOptions;
 import org.apache.fluss.metadata.Schema;
+import org.apache.fluss.metadata.TableBucket;
 import org.apache.fluss.metadata.TableDescriptor;
 import org.apache.fluss.metadata.TableInfo;
 import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.server.metadata.ServerInfo;
 import org.apache.fluss.server.zk.ZkEpoch;
+import org.apache.fluss.server.zk.data.LeaderAndIsr;
 import org.apache.fluss.types.DataTypes;
 
 import org.junit.jupiter.api.Test;
 
 import java.time.Duration;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
 
 import static org.apache.fluss.config.ConfigOptions.TABLE_DATALAKE_ENABLED;
 import static org.apache.fluss.record.TestData.DEFAULT_REMOTE_DATA_DIR;
@@ -69,6 +78,124 @@ class CoordinatorContextTest {
         assertThat(context.getLakeTableCount()).isEqualTo(2);
     }
 
+    // ---- Pending Leader Activation Tracking Tests ----
+
+    @Test
+    void testAddPendingLeaderActivation() {
+        CoordinatorContext context = new 
CoordinatorContext(ZkEpoch.INITIAL_EPOCH);
+        TableBucket tb1 = new TableBucket(1L, 0);
+        TableBucket tb2 = new TableBucket(1L, 1);
+
+        assertThat(context.getPendingLeaderActivationBuckets()).isEmpty();
+
+        context.addPendingLeaderActivation(tb1);
+        context.addPendingLeaderActivation(tb2);
+
+        
assertThat(context.getPendingLeaderActivationBuckets()).containsExactlyInAnyOrder(tb1,
 tb2);
+    }
+
+    @Test
+    void testClearPendingLeaderActivation() {
+        CoordinatorContext context = new 
CoordinatorContext(ZkEpoch.INITIAL_EPOCH);
+        TableBucket tb1 = new TableBucket(1L, 0);
+        TableBucket tb2 = new TableBucket(1L, 1);
+
+        context.addPendingLeaderActivations(Arrays.asList(tb1, tb2));
+
+        context.clearPendingLeaderActivation(tb1);
+        
assertThat(context.getPendingLeaderActivationBuckets()).containsExactly(tb2);
+
+        context.clearPendingLeaderActivation(tb2);
+        assertThat(context.getPendingLeaderActivationBuckets()).isEmpty();
+    }
+
+    @Test
+    void testClearPendingLeaderActivationForNonExistentBucket() {
+        CoordinatorContext context = new 
CoordinatorContext(ZkEpoch.INITIAL_EPOCH);
+        TableBucket tb1 = new TableBucket(1L, 0);
+
+        // Should not throw
+        context.clearPendingLeaderActivation(tb1);
+        assertThat(context.getPendingLeaderActivationBuckets()).isEmpty();
+    }
+
+    @Test
+    void testRemoveFromPendingLeaderActivations() {
+        CoordinatorContext context = new 
CoordinatorContext(ZkEpoch.INITIAL_EPOCH);
+        TableBucket tb1 = new TableBucket(1L, 0);
+        TableBucket tb2 = new TableBucket(1L, 1);
+        TableBucket tb3 = new TableBucket(2L, 0);
+
+        context.addPendingLeaderActivations(Arrays.asList(tb1, tb2, tb3));
+
+        Set<TableBucket> toRemove = new HashSet<>(Arrays.asList(tb1, tb3));
+        context.removeFromPendingLeaderActivations(toRemove);
+
+        
assertThat(context.getPendingLeaderActivationBuckets()).containsExactly(tb2);
+    }
+
+    @Test
+    void testGetPendingLeaderActivationBucketsReturnsUnmodifiableSet() {
+        CoordinatorContext context = new 
CoordinatorContext(ZkEpoch.INITIAL_EPOCH);
+        TableBucket tb1 = new TableBucket(1L, 0);
+        context.addPendingLeaderActivation(tb1);
+
+        Set<TableBucket> pending = context.getPendingLeaderActivationBuckets();
+        assertThat(pending).isUnmodifiable();
+    }
+
+    @Test
+    void testIsLeaderActiveSingleSourceOfTruth() {
+        CoordinatorContext context = new 
CoordinatorContext(ZkEpoch.INITIAL_EPOCH);
+        TableBucket tb = new TableBucket(1L, 0);
+
+        // No LeaderAndIsr → not active
+        assertThat(context.isLeaderActive(tb)).isFalse();
+
+        // Set up live server and LeaderAndIsr
+        context.setLiveTabletServers(
+                Collections.singletonList(
+                        new ServerInfo(
+                                0,
+                                "RACK0",
+                                
Endpoint.fromListenersString("CLIENT://host0:9124"),
+                                ServerType.TABLET_SERVER)));
+        context.putBucketLeaderAndIsr(
+                tb,
+                new LeaderAndIsr(
+                        0, 1, Collections.singletonList(0), 
Collections.emptyList(), 0, 1));
+
+        // Active leader on live server, not pending → active
+        assertThat(context.isLeaderActive(tb)).isTrue();
+
+        // Add to pending → not active
+        context.addPendingLeaderActivation(tb);
+        assertThat(context.isLeaderActive(tb)).isFalse();
+
+        // Clear pending → active again
+        context.clearPendingLeaderActivation(tb);
+        assertThat(context.isLeaderActive(tb)).isTrue();
+
+        // Leader is NO_LEADER → not active
+        context.putBucketLeaderAndIsr(
+                tb,
+                new LeaderAndIsr(
+                        LeaderAndIsr.NO_LEADER,
+                        1,
+                        Collections.singletonList(0),
+                        Collections.emptyList(),
+                        0,
+                        1));
+        assertThat(context.isLeaderActive(tb)).isFalse();
+
+        // Leader on dead server → not active
+        context.putBucketLeaderAndIsr(
+                tb,
+                new LeaderAndIsr(
+                        99, 1, Collections.singletonList(99), 
Collections.emptyList(), 0, 1));
+        assertThat(context.isLeaderActive(tb)).isFalse();
+    }
+
     private TableInfo createTableInfo(long tableId, TablePath tablePath, 
boolean isLake) {
         TableDescriptor tableDescriptor =
                 TableDescriptor.builder()
diff --git 
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorRequestBatchTest.java
 
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorRequestBatchTest.java
new file mode 100644
index 000000000..0b3892a1c
--- /dev/null
+++ 
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorRequestBatchTest.java
@@ -0,0 +1,160 @@
+/*
+ * 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.fluss.server.coordinator;
+
+import org.apache.fluss.exception.NetworkException;
+import org.apache.fluss.metadata.PhysicalTablePath;
+import org.apache.fluss.metadata.TableBucket;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.rpc.messages.NotifyLeaderAndIsrRequest;
+import org.apache.fluss.rpc.messages.NotifyLeaderAndIsrResponse;
+import org.apache.fluss.server.coordinator.event.AccessContextEvent;
+import org.apache.fluss.server.coordinator.event.EventManager;
+import org.apache.fluss.server.zk.ZkEpoch;
+import org.apache.fluss.server.zk.data.LeaderAndIsr;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.function.BiConsumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Test for the {@link CoordinatorRequestBatch}. */
+class CoordinatorRequestBatchTest {
+
+    private CoordinatorContext coordinatorContext;
+
+    @BeforeEach
+    void beforeEach() {
+        coordinatorContext = new CoordinatorContext(ZkEpoch.INITIAL_EPOCH);
+    }
+
+    /**
+     * When the NotifyLeaderAndIsr request to the actual leader (leader == 
serverId) fails, the
+     * pending leader activation entry that this request added must be cleared 
via an
+     * AccessContextEvent dispatched on the event manager.
+     */
+    @Test
+    void testNotifyLeaderAndIsrSendFailureClearsLeaderPending() {
+        long tableId = 100L;
+        TableBucket tb = new TableBucket(tableId, 0);
+        TablePath tablePath = TablePath.of("db1", "t1");
+
+        coordinatorContext.putTablePath(tableId, tablePath);
+        coordinatorContext.setLiveTabletServers(
+                
CoordinatorTestUtils.createServers(Collections.singletonList(0)));
+        coordinatorContext.updateBucketReplicaAssignment(tb, 
Collections.singletonList(0));
+        LeaderAndIsr leaderAndIsr =
+                new LeaderAndIsr(0, 0, Collections.singletonList(0), 
Collections.emptyList(), 0, 0);
+        coordinatorContext.putBucketLeaderAndIsr(tb, leaderAndIsr);
+
+        TestCoordinatorChannelManager failingChannel = 
newAlwaysFailingChannelManager();
+        EventManager eventManager = newSynchronousAccessContextEventManager();
+
+        CoordinatorRequestBatch batch =
+                new CoordinatorRequestBatch(failingChannel, eventManager, 
coordinatorContext);
+        // server 0 IS the leader, so this request will mark tb pending.
+        batch.addNotifyLeaderRequestForTabletServers(
+                Collections.singleton(0),
+                PhysicalTablePath.of(tablePath),
+                tb,
+                Collections.singletonList(0),
+                leaderAndIsr);
+
+        batch.sendRequestToTabletServers(0);
+
+        // The failure callback must clear the pending entry that this request 
added.
+        
assertThat(coordinatorContext.getPendingLeaderActivationBuckets()).isEmpty();
+    }
+
+    /**
+     * When the NotifyLeaderAndIsr request to a follower (leader != serverId) 
fails, the failure
+     * callback must NOT clear any pending leader activation entry. 
Specifically, it must not remove
+     * entries added by another in-flight sender targeting the actual leader.
+     */
+    @Test
+    void testNotifyLeaderAndIsrSendFailureToFollowerDoesNotClearOtherPending() 
{
+        long tableId = 200L;
+        TableBucket followerTb = new TableBucket(tableId, 0);
+        TableBucket otherLeaderTb = new TableBucket(tableId, 1);
+        TablePath tablePath = TablePath.of("db1", "t2");
+
+        coordinatorContext.putTablePath(tableId, tablePath);
+        coordinatorContext.setLiveTabletServers(
+                CoordinatorTestUtils.createServers(Arrays.asList(0, 1)));
+        coordinatorContext.updateBucketReplicaAssignment(followerTb, 
Arrays.asList(0, 1));
+        coordinatorContext.updateBucketReplicaAssignment(otherLeaderTb, 
Arrays.asList(0, 1));
+        LeaderAndIsr leaderAndIsr =
+                new LeaderAndIsr(0, 0, Arrays.asList(0, 1), 
Collections.emptyList(), 0, 0);
+        coordinatorContext.putBucketLeaderAndIsr(followerTb, leaderAndIsr);
+
+        // Simulate another in-flight sender having already marked 
otherLeaderTb pending - we
+        // must not touch this entry on the unrelated follower-send failure.
+        coordinatorContext.addPendingLeaderActivation(otherLeaderTb);
+
+        TestCoordinatorChannelManager failingChannel = 
newAlwaysFailingChannelManager();
+        EventManager eventManager = newSynchronousAccessContextEventManager();
+
+        CoordinatorRequestBatch batch =
+                new CoordinatorRequestBatch(failingChannel, eventManager, 
coordinatorContext);
+        // Send to server 1, which is a follower for followerTb (leader is 0). 
Because
+        // leader != serverId, this request does NOT add followerTb to 
pending, so the failure
+        // callback must short-circuit without dispatching an 
AccessContextEvent.
+        batch.addNotifyLeaderRequestForTabletServers(
+                Collections.singleton(1),
+                PhysicalTablePath.of(tablePath),
+                followerTb,
+                Arrays.asList(0, 1),
+                leaderAndIsr);
+
+        batch.sendRequestToTabletServers(0);
+
+        // The unrelated leader's pending entry must remain intact.
+        assertThat(coordinatorContext.getPendingLeaderActivationBuckets())
+                .containsExactly(otherLeaderTb);
+    }
+
+    private static TestCoordinatorChannelManager 
newAlwaysFailingChannelManager() {
+        return new TestCoordinatorChannelManager() {
+            @Override
+            public void sendBucketLeaderAndIsrRequest(
+                    int receiveServerId,
+                    NotifyLeaderAndIsrRequest notifyLeaderAndIsrRequest,
+                    BiConsumer<NotifyLeaderAndIsrResponse, ? super Throwable> 
responseConsumer) {
+                responseConsumer.accept(
+                        null, new NetworkException("simulated send failure for 
test"));
+            }
+        };
+    }
+
+    /**
+     * Returns an EventManager that synchronously executes {@link 
AccessContextEvent}s against the
+     * test's CoordinatorContext, mimicking the serial event-thread semantics.
+     */
+    private EventManager newSynchronousAccessContextEventManager() {
+        return event -> {
+            if (event instanceof AccessContextEvent) {
+                AccessContextEvent<?> accessContextEvent = 
(AccessContextEvent<?>) event;
+                
accessContextEvent.getAccessFunction().apply(coordinatorContext);
+            }
+        };
+    }
+}
diff --git 
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/TestCoordinatorGateway.java
 
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/TestCoordinatorGateway.java
index 98e295f9a..8530ad4e7 100644
--- 
a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/TestCoordinatorGateway.java
+++ 
b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/TestCoordinatorGateway.java
@@ -70,6 +70,8 @@ import org.apache.fluss.rpc.messages.DropPartitionRequest;
 import org.apache.fluss.rpc.messages.DropPartitionResponse;
 import org.apache.fluss.rpc.messages.DropTableRequest;
 import org.apache.fluss.rpc.messages.DropTableResponse;
+import org.apache.fluss.rpc.messages.GetClusterHealthRequest;
+import org.apache.fluss.rpc.messages.GetClusterHealthResponse;
 import org.apache.fluss.rpc.messages.GetDatabaseInfoRequest;
 import org.apache.fluss.rpc.messages.GetDatabaseInfoResponse;
 import org.apache.fluss.rpc.messages.GetFileSystemSecurityTokenRequest;
@@ -461,6 +463,12 @@ public class TestCoordinatorGateway implements 
CoordinatorGateway {
         throw new UnsupportedOperationException();
     }
 
+    @Override
+    public CompletableFuture<GetClusterHealthResponse> getClusterHealth(
+            GetClusterHealthRequest request) {
+        throw new UnsupportedOperationException();
+    }
+
     @Override
     public CompletableFuture<RegisterProducerOffsetsResponse> 
registerProducerOffsets(
             RegisterProducerOffsetsRequest request) {
diff --git 
a/fluss-server/src/test/java/org/apache/fluss/server/tablet/TestTabletServerGateway.java
 
b/fluss-server/src/test/java/org/apache/fluss/server/tablet/TestTabletServerGateway.java
index e54987f13..c78270ea5 100644
--- 
a/fluss-server/src/test/java/org/apache/fluss/server/tablet/TestTabletServerGateway.java
+++ 
b/fluss-server/src/test/java/org/apache/fluss/server/tablet/TestTabletServerGateway.java
@@ -30,6 +30,8 @@ import 
org.apache.fluss.rpc.messages.DescribeClusterConfigsRequest;
 import org.apache.fluss.rpc.messages.DescribeClusterConfigsResponse;
 import org.apache.fluss.rpc.messages.FetchLogRequest;
 import org.apache.fluss.rpc.messages.FetchLogResponse;
+import org.apache.fluss.rpc.messages.GetClusterHealthRequest;
+import org.apache.fluss.rpc.messages.GetClusterHealthResponse;
 import org.apache.fluss.rpc.messages.GetDatabaseInfoRequest;
 import org.apache.fluss.rpc.messages.GetDatabaseInfoResponse;
 import org.apache.fluss.rpc.messages.GetFileSystemSecurityTokenRequest;
@@ -234,6 +236,12 @@ public class TestTabletServerGateway implements 
TabletServerGateway {
         throw new UnsupportedOperationException();
     }
 
+    @Override
+    public CompletableFuture<GetClusterHealthResponse> getClusterHealth(
+            GetClusterHealthRequest request) {
+        throw new UnsupportedOperationException();
+    }
+
     @Override
     public CompletableFuture<DatabaseExistsResponse> 
databaseExists(DatabaseExistsRequest request) {
         throw new UnsupportedOperationException();
diff --git a/fluss-test-coverage/pom.xml b/fluss-test-coverage/pom.xml
index db770f56c..d5e91d302 100644
--- a/fluss-test-coverage/pom.xml
+++ b/fluss-test-coverage/pom.xml
@@ -448,7 +448,9 @@
                                         
<exclude>org.apache.fluss.predicate.*</exclude>
                                         <!-- end exclude for lake source -->
                                         
<exclude>org.apache.fluss.lake.source.*</exclude>
-                                        <!-- exclude for dummy class -->
+                                        <!-- exclude for readiness-probe CLI 
tool and dist dummy class -->
+                                        
<exclude>org.apache.fluss.server.tools.ClusterHealthReadinessCheck</exclude>
+                                        
<exclude>org.apache.fluss.server.tools.ClusterHealthReadinessCheck.*</exclude>
                                         
<exclude>org.apache.fluss.dist.DummyClass</exclude>
                                         
<exclude>org.apache.fluss.flink.DummyClass120</exclude>
                                         
<exclude>org.apache.fluss.lake.batch.ArrowRecordBatch</exclude>
diff --git a/helm/templates/sts-coordinator.yaml 
b/helm/templates/sts-coordinator.yaml
index 918bf2d06..1d4dd3e9e 100644
--- a/helm/templates/sts-coordinator.yaml
+++ b/helm/templates/sts-coordinator.yaml
@@ -25,6 +25,8 @@ metadata:
 spec:
   serviceName: coordinator-server-hs
   replicas: {{ .Values.coordinator.numberOfReplicas }}
+  updateStrategy:
+    type: RollingUpdate
   selector:
     matchLabels:
       {{- include "fluss.selectorLabels" . | nindent 6 }}
@@ -122,7 +124,7 @@ spec:
               echo "bind.listeners: ${BIND_LISTENERS}" >> 
$FLUSS_HOME/conf/server.yaml && \
               echo "advertised.listeners: ${ADVERTISED_LISTENERS}" >> 
$FLUSS_HOME/conf/server.yaml && \
 
-              bin/coordinator-server.sh start-foreground
+              exec bin/coordinator-server.sh start-foreground
           livenessProbe:
             failureThreshold: 100
             timeoutSeconds: 1
diff --git a/helm/templates/sts-tablet.yaml b/helm/templates/sts-tablet.yaml
index 6232544de..200a4cdc7 100644
--- a/helm/templates/sts-tablet.yaml
+++ b/helm/templates/sts-tablet.yaml
@@ -25,6 +25,8 @@ metadata:
 spec:
   serviceName: tablet-server-hs
   replicas: {{ .Values.tablet.numberOfReplicas }}
+  updateStrategy:
+    type: RollingUpdate
   selector:
     matchLabels:
       {{- include "fluss.selectorLabels" . | nindent 6 }}
@@ -119,7 +121,7 @@ spec:
               echo "bind.listeners: ${BIND_LISTENERS}" >> 
$FLUSS_HOME/conf/server.yaml && \
               echo "advertised.listeners: ${ADVERTISED_LISTENERS}" >> 
$FLUSS_HOME/conf/server.yaml && \
 
-              bin/tablet-server.sh start-foreground
+              exec bin/tablet-server.sh start-foreground
           livenessProbe:
             failureThreshold: 100
             timeoutSeconds: 1
@@ -128,12 +130,28 @@ spec:
             tcpSocket:
               port: {{ .Values.listeners.client.port }}
           readinessProbe:
-            failureThreshold: 100
-            timeoutSeconds: 1
-            initialDelaySeconds: 10
-            periodSeconds: 3
-            tcpSocket:
-              port: {{ .Values.listeners.client.port }}
+            failureThreshold: {{ 
.Values.tablet.readinessProbe.failureThreshold | default 360 }}
+            timeoutSeconds: {{ .Values.tablet.readinessProbe.timeoutSeconds | 
default 10 }}
+            initialDelaySeconds: {{ 
.Values.tablet.readinessProbe.initialDelaySeconds | default 15 }}
+            periodSeconds: {{ .Values.tablet.readinessProbe.periodSeconds | 
default 5 }}
+            exec:
+              command:
+                - /bin/bash
+                - -c
+                - |
+                  export FLUSS_SERVER_ID=${POD_NAME##*-}
+                  export READINESS_TIMEOUT_MS={{ 
.Values.tablet.readinessProbe.rpcTimeoutMs | default 5000 }}
+                  export READINESS_TCP_PORT={{ .Values.listeners.client.port }}
+                  # bind.listeners binds the tablet's CLIENT endpoint to 
${POD_IP},
+                  # not 0.0.0.0, so we must probe the pod's own IP instead of
+                  # 127.0.0.1. The local tablet then forwards getClusterHealth
+                  # to coordinator via the internal listener.
+                  export READINESS_TCP_HOST=${POD_IP}
+                  export READINESS_HEALTH_CHECK_TIMEOUT_SECONDS={{ 
.Values.tablet.readinessProbe.healthCheckTimeoutSeconds | default 1200 }}
+                  {{- with .Values.tablet.readinessProbe.healthCheckAuth }}
+                  export READINESS_HEALTH_CHECK_AUTH={{ . | quote }}
+                  {{- end }}
+                  exec $FLUSS_HOME/bin/readiness-check.sh
           resources:
             {{- toYaml .Values.resources.tabletServer | nindent 12 }}
           volumeMounts:
diff --git a/helm/values.yaml b/helm/values.yaml
index e14877d11..dd56df982 100644
--- a/helm/values.yaml
+++ b/helm/values.yaml
@@ -43,6 +43,43 @@ tablet:
     enabled: false
     size: 1Gi
     storageClass:
+  # Readiness probe configuration for rolling upgrade gate.
+  # The probe talks to the LOCAL tablet server (127.0.0.1:client-port) and
+  # asks it to forward a getClusterHealth RPC to the Coordinator over the
+  # internal listener. Pod is marked Ready only when the cluster is GREEN.
+  # If the server does not support the API (older version), the probe
+  # immediately falls back to TCP-only port readiness.
+  readinessProbe:
+    # Timeout in ms for the RPC call.
+    rpcTimeoutMs: 5000
+    # Standard Kubernetes probe parameters (tuned for recovery checks).
+    #
+    # The total time Kubernetes will wait before flipping the pod to NotReady 
is
+    #     initialDelaySeconds + failureThreshold * periodSeconds
+    #   = 15 + 360 * 5 = 1815s (~30 minutes) by default.
+    # IMPORTANT: this window MUST be larger than healthCheckTimeoutSeconds
+    # below, otherwise Kubernetes will mark the pod NotReady BEFORE the
+    # in-script health-check fallback has a chance to latch the TCP fast path,
+    # which would still wedge the rolling upgrade. If you tune
+    # healthCheckTimeoutSeconds upward, raise failureThreshold accordingly so
+    # the inequality (failureThreshold * periodSeconds > 
healthCheckTimeoutSeconds)
+    # always holds.
+    failureThreshold: 360
+    timeoutSeconds: 10
+    initialDelaySeconds: 15
+    periodSeconds: 5
+    # Maximum wall-clock seconds the cluster health gate is allowed to keep a
+    # freshly-created tablet pod NotReady on its first boot. After this budget
+    # is exhausted the probe latches the TCP-only fast path so a permanently-
+    # unrecoverable cluster cannot wedge a rolling upgrade indefinitely.
+    # Default: 1200 (20 minutes).
+    healthCheckTimeoutSeconds: 1200
+    # Optional client auth configuration passed to the probe's RpcClient as a
+    # single-line string of semicolon-separated `key:value` pairs (no quoting
+    # or escaping needed). Required only when the local client listener
+    # enforces SASL. Example:
+    #   healthCheckAuth: 
"client.security.protocol:SASL;client.sasl.mechanism:PLAIN;client.security.sasl.username:admin;client.security.sasl.password:admin-pass"
+    healthCheckAuth: ""
   extraVolumes: []
   extraVolumeMounts: []
   initContainers: []
diff --git a/website/docs/install-deploy/deploying-with-helm.md 
b/website/docs/install-deploy/deploying-with-helm.md
index 5bb05dba7..949ff92b1 100644
--- a/website/docs/install-deploy/deploying-with-helm.md
+++ b/website/docs/install-deploy/deploying-with-helm.md
@@ -771,6 +771,43 @@ helm upgrade fluss ./helm -f values-new.yaml
 
 The StatefulSets support rolling updates. When you update the configuration, 
pods will be restarted one by one to maintain availability.
 
+#### Cluster Health Readiness Probe
+
+The TabletServer readiness probe performs a two-step check to ensure safe 
rolling upgrades:
+
+1. **Local TCP port check** — confirms the TabletServer process is up and 
listening.
+2. **Cluster health check** — calls the Coordinator's Cluster Health API to 
confirm cluster status is GREEN (all replicas
+   in-sync, all leaders active) across the cluster.
+
+Only when both steps pass is the pod marked as Ready, allowing the StatefulSet 
controller to proceed to the next pod.
+If the Coordinator does not support the Cluster Health API (e.g., during a 
mixed-version upgrade from an older version),
+the probe immediately falls back to TCP-only port readiness.
+
+You can tune the probe parameters in your values:
+
+```yaml
+tablet:
+  readinessProbe:
+    # Timeout in ms for the RPC call to Coordinator (default: 5000)
+    rpcTimeoutMs: 5000
+    # Standard Kubernetes probe parameters (tuned for recovery checks)
+    failureThreshold: 200
+    timeoutSeconds: 10
+    initialDelaySeconds: 15
+    periodSeconds: 5
+```
+
+| Parameter          | Default | Description                                   
                                                                                
                 |
+|--------------------|---------|------------------------------------------------------------------------------------------------------------------------------------------------|
+| `rpcTimeoutMs`     | `5000`  | Timeout in milliseconds for the RPC call to 
the Coordinator.                                                                
                   |
+| `failureThreshold` | `200`   | Max consecutive probe failures before marking 
the pod as unready. With `periodSeconds=5`, this allows up to ~16 minutes for 
recovery.          |
+| `periodSeconds`    | `5`     | How often the probe runs.                     
                                                                                
                 |
+
+:::note
+The CoordinatorServer does not need the Cluster Health API probe — it does not 
host data replicas, so a simple TCP check is sufficient.
+The Coordinator should be upgraded **after** all TabletServers are fully 
upgraded and recovered.
+:::
+
 ## Custom Container Images
 
 ### Building Custom Images
@@ -805,7 +842,7 @@ image:
 
 ### Health Checks
 
-The chart includes liveness and readiness probes:
+The chart includes liveness and readiness probes. By default, both use TCP 
socket checks:
 
 ```yaml
 livenessProbe:
@@ -823,6 +860,9 @@ readinessProbe:
   failureThreshold: 100
 ```
 
+For TabletServers, you can enable the Cluster Health readiness probe for safe 
rolling upgrades.
+See [Cluster Health Readiness Probe](#cluster-health-readiness-probe) for 
details.
+
 ### Logs
 
 Access logs from different components:
diff --git a/website/docs/maintenance/observability/monitor-metrics.md 
b/website/docs/maintenance/observability/monitor-metrics.md
index 4fcc83687..5383250b8 100644
--- a/website/docs/maintenance/observability/monitor-metrics.md
+++ b/website/docs/maintenance/observability/monitor-metrics.md
@@ -294,8 +294,8 @@ Some metrics might not be exposed when using other JVM 
implementations (e.g. IBM
   </thead>
   <tbody>
     <tr>
-       <th rowspan="27"><strong>coordinator</strong></th>
-      <td style={{textAlign: 'center', verticalAlign: 'middle' }} 
rowspan="10">-</td>
+       <th rowspan="28"><strong>coordinator</strong></th>
+      <td style={{textAlign: 'center', verticalAlign: 'middle' }} 
rowspan="11">-</td>
       <td>activeCoordinatorCount</td>
       <td>The number of active CoordinatorServer (only leader) in this 
cluster.</td>
       <td>Gauge</td>
@@ -345,6 +345,11 @@ Some metrics might not be exposed when using other JVM 
implementations (e.g. IBM
       <td>The total number of replicas in the progress to be deleted in this 
cluster.</td>
       <td>Gauge</td>
     </tr>
+    <tr>
+      <td>pendingLeaderActivationCount</td>
+      <td>The number of buckets currently waiting for the leader-activation 
acknowledgement from the target tablet server. A non-zero, sustained value 
blocks the cluster-health API from reporting GREEN and will hold the readiness 
gate during a rolling upgrade; if it stays non-zero indefinitely it indicates a 
stuck activation that requires investigation.</td>
+      <td>Gauge</td>
+    </tr>
     <tr>
       <td rowspan="3">event</td>
       <td>eventQueueSize</td>
diff --git a/website/docs/maintenance/operations/upgrading.md 
b/website/docs/maintenance/operations/upgrading.md
index 2b75557b6..1eb768a46 100644
--- a/website/docs/maintenance/operations/upgrading.md
+++ b/website/docs/maintenance/operations/upgrading.md
@@ -57,6 +57,44 @@ To upgrade the `TabletServers`, follow these steps 
one-by-one for each `TabletSe
 ./fluss-$FLUSS_VERSION$/bin/tablet-server.sh start
 ```
 
+**Wait for the cluster to recover before upgrading the next TabletServer**
+
+After restarting a TabletServer, you should wait for the cluster to fully 
recover before proceeding to the next one.
+Upgrading too quickly can cause multiple servers to be in an unrecovered state 
simultaneously, which may break min-ISR guarantees
+and affect data availability.
+
+You can use the **Cluster Health API** to monitor the cluster's health status:
+
+```java
+Admin admin = connection.getAdmin();
+
+ClusterHealth health = admin.getClusterHealth().get();
+System.out.println("Status: " + health.getStatus());
+System.out.println("Replicas: " + health.getInSyncReplicas() + "/" + 
health.getNumReplicas());
+System.out.println("Leaders: " + health.getActiveLeaderReplicas() + "/" + 
health.getNumLeaderReplicas());
+
+if (health.getStatus() == ClusterHealthStatus.GREEN) {
+    // Safe to proceed with the next TabletServer
+}
+```
+
+The API returns the following health metrics:
+
+| Field | Meaning |
+|-------|---------|
+| `status` | Cluster health: GREEN (all replicas in-sync, all leaders active), 
YELLOW (all leaders active, some followers not in-sync), RED (some leaders not 
active) |
+| `numReplicas` | Total number of assigned replicas across all buckets |
+| `inSyncReplicas` | Total number of in-sync replicas across all buckets |
+| `numLeaderReplicas` | Total number of leader slots (one per bucket) |
+| `activeLeaderReplicas` | Number of active leaders (leader alive and 
acknowledged) |
+
+Wait until the status is GREEN before upgrading the next TabletServer. GREEN 
means all replicas are in-sync and all leaders are active — fully recovered.
+
+:::tip
+For Kubernetes deployments, you can enable the Cluster Health API readiness 
probe in the Helm chart to automate this check.
+See [Deploying with Helm Charts — Cluster Health Readiness 
Probe](docs/install-deploy/deploying-with-helm.md#cluster-health-readiness-probe)
 for details.
+:::
+
 ### Upgrade the CoordinatorServer
 
 After all `TabletServers` have been upgraded, you can proceed to upgrade the 
`CoordinatorServer` by following these steps:

Reply via email to