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

imbajin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hugegraph.git


The following commit(s) were added to refs/heads/master by this push:
     new e85b36a49 feat(pd): add quorum-aware /v1/ready endpoint and raft 
gauges (#3185)
e85b36a49 is described below

commit e85b36a49b5834f720b71de18addd9767ae8951a
Author: KAI <[email protected]>
AuthorDate: Tue Sep 8 19:45:08 2026 +0530

    feat(pd): add quorum-aware /v1/ready endpoint and raft gauges (#3185)
---
 docker/README.md                                   |  26 +++
 hugegraph-pd/README.md                             |   2 +
 hugegraph-pd/docs/api-reference.md                 |  74 ++++++
 .../org/apache/hugegraph/pd/raft/RaftEngine.java   | 171 +++++++++++++-
 .../apache/hugegraph/pd/raft/RaftStateMachine.java |  42 ++++
 hugegraph-pd/hg-pd-service/pom.xml                 |   6 +
 .../org/apache/hugegraph/pd/metrics/PDMetrics.java |  22 ++
 .../org/apache/hugegraph/pd/rest/StoreAPI.java     |  31 +++
 .../rest/interceptor/AuthenticationConfigurer.java |   3 +-
 .../hugegraph/pd/rest/StoreAPIReadyTest.java       | 144 ++++++++++++
 .../apache/hugegraph/pd/core/PDCoreSuiteTest.java  |   2 +
 .../hugegraph/pd/raft/RaftEngineReadinessTest.java | 257 +++++++++++++++++++++
 .../org/apache/hugegraph/pd/rest/RestApiTest.java  |  57 +++++
 .../src/assembly/travis/test-start-hugegraph-pd.sh |  28 +++
 hugegraph-store/docs/deployment-guide.md           |  20 +-
 15 files changed, 879 insertions(+), 6 deletions(-)

diff --git a/docker/README.md b/docker/README.md
index 8ed7ea8fa..6fcfdee42 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -202,6 +202,32 @@ done
 curl -fsS http://localhost:8088/about
 ```
 
+PD answers two unauthenticated probe endpoints. `/v1/health` is liveness only:
+it returns `200` as soon as the REST listener is up, even when the PD has no
+raft leader. `/v1/ready` returns `200` only while the PD sees a raft leader,
+and `503` otherwise. Each PD answers for itself: a single PD elects itself, and
+in a three-PD group the two that can reach each other elect a leader and turn
+ready, while a partitioned third keeps answering `503` until it sees that
+leader.
+
+The healthchecks in these files still gate on `/v1/health`, because
+`/v1/ready` ships from the next release onwards while the files run published
+images. Two things to know before pointing them at readiness:
+
+- Match on the body, not the status code. As of 1.7.0 PD answers `200` with
+  `{"status":-1,"error":"Unauthorized!"}` on every path its auth interceptor
+  does not exclude, a path that does not exist included, so a status-only
+  probe reads a PD too old to have `/v1/ready` as ready. The body match holds
+  whichever status a refusal carries. Gate with
+  `curl -fsS http://localhost:8620/v1/ready | grep -q '"ready":true'` instead.
+- Pin `HUGEGRAPH_VERSION` to a release that carries the endpoint, or build the
+  images from source with `docker-compose.dev.yml`.
+
+The `HEALTHCHECK` baked into `hugegraph-pd/Dockerfile` is `/v1/health` as well.
+Both compose files override it, so it governs `docker run` and anything else
+inheriting the image probe, and those keep reading a PD without a quorum as
+healthy.
+
 Open `http://localhost:8088` and sign in as `admin` with the password from
 `.env`.
 
diff --git a/hugegraph-pd/README.md b/hugegraph-pd/README.md
index 794dba9b9..c940dae4c 100644
--- a/hugegraph-pd/README.md
+++ b/hugegraph-pd/README.md
@@ -284,6 +284,8 @@ docker/docker-compose-3pd-3store-3server.yml
 
 PD exposes metrics via REST API at:
 - Health check: `http://<pd-host>:8620/actuator/health`
+- Liveness: `http://<pd-host>:8620/v1/health` (REST listener is up)
+- Readiness: `http://<pd-host>:8620/v1/ready` (`200` only while the PD sees a 
raft leader)
 - Metrics: `http://<pd-host>:8620/actuator/metrics`
 
 ## Community
diff --git a/hugegraph-pd/docs/api-reference.md 
b/hugegraph-pd/docs/api-reference.md
index aa8cce847..58b969886 100644
--- a/hugegraph-pd/docs/api-reference.md
+++ b/hugegraph-pd/docs/api-reference.md
@@ -774,6 +774,56 @@ curl http://localhost:8620/actuator/health
 }
 ```
 
+### Liveness and Readiness
+
+Two unauthenticated endpoints are meant for probes and startup gates:
+
+| Endpoint | Meaning | Status |
+|----------|---------|--------|
+| `GET /v1/health` | Liveness: the REST listener is up. Does not consult raft. 
| always `200` |
+| `GET /v1/ready` | Readiness: the raft node is active and sees a leader, so 
this PD is inside a quorum. | `200` when ready, `503` otherwise |
+
+```bash
+curl -i http://localhost:8620/v1/ready
+```
+
+**Response** (leader of a healthy cluster):
+```json
+{
+  "ready": true,
+  "state": "STATE_LEADER",
+  "isLeader": true
+}
+```
+
+A follower reports `"state": "STATE_FOLLOWER"` with `"isLeader": false`. When
+the quorum is lost the PD keeps answering `/v1/health` with `200` but
+`/v1/ready` turns into `503` with `"ready": false`. Being unauthenticated, the
+body carries no cluster addresses; the leader's address stays on `/v1/members`.
+
+The answer is served from state the raft callbacks maintain rather than from
+the raft node, so it stays prompt while an election is running and never waits
+on the node lock. `state` is therefore the last change raft announced. A PD
+reports `STATE_UNINITIALIZED` with `"ready": false` from process start until
+its first raft callback, which is the ordinary startup window before a quorum
+first forms, and jraft emits no callback for candidacy or leadership transfer,
+so a candidate reports `STATE_FOLLOWER` with `"ready": false`.
+
+Point Kubernetes readiness probes, `depends_on` healthchecks and any
+"wait for PD" script at `/v1/ready`; keep liveness probes on `/v1/health`
+so a PD that merely lost its leader is not restarted.
+
+Match on the body rather than on the status code alone. A PD that predates this
+endpoint does not reliably answer `404` for it: `RestAuthentication` refuses a
+request it does not exclude by writing an error envelope, and as of 1.7.0 it
+does so without setting a status, so an unknown path answers `200` with
+`{"status":-1,"error":"Unauthorized!"}`. A status-only probe therefore reads
+such a PD as ready. The body match holds whichever status a refusal carries: a
+shell gate should use
+`curl -fsS http://<pd-host>:8620/v1/ready | grep -q '"ready":true'`, and a
+Kubernetes `httpGet` probe should be paired with a PD image that carries the
+endpoint.
+
 ### Metrics
 
 ```bash
@@ -796,6 +846,30 @@ pd_store_count{state="Offline"} 0.0
 pd_partition_count 36.0
 ```
 
+#### Raft membership gauges
+
+Exported on `/actuator/prometheus` for alerting on quorum loss:
+
+| Gauge | Value |
+|-------|-------|
+| `hg_raft_leader` | `1` on the raft leader, `0` elsewhere |
+| `hg_raft_has_leader` | `1` while this PD sees a leader (is inside a quorum), 
`0` otherwise |
+| `hg_raft_alive_peers` | Number of alive peers on the leader, itself 
included; `NaN` elsewhere |
+
+`hg_raft_alive_peers` counts the peers the leader has heard from within the
+leader lease timeout, which jraft derives as 90% of the election timeout by
+default.
+
+A cluster has lost its quorum when `sum(hg_raft_leader) == 0` or when
+`hg_raft_has_leader == 0` on every member. Both are briefly true during a
+normal election, so alert on them with a `for:` clause longer than the
+election timeout rather than on the instantaneous value.
+
+Do not aggregate `hg_raft_alive_peers` across instances: it is `NaN` on every
+node but the leader, and one `NaN` sample turns the result of `sum()` or
+`avg()` into `NaN` as well. Select the leader's series instead, for example
+`hg_raft_alive_peers and on(instance) (hg_raft_leader == 1)`.
+
 ### Partition API
 
 #### List Partitions
diff --git 
a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/raft/RaftEngine.java
 
b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/raft/RaftEngine.java
index 314c9e57e..d39384909 100644
--- 
a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/raft/RaftEngine.java
+++ 
b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/raft/RaftEngine.java
@@ -27,6 +27,8 @@ import java.util.Set;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 import java.util.concurrent.atomic.AtomicReference;
@@ -46,6 +48,7 @@ import com.alipay.sofa.jraft.RaftGroupService;
 import com.alipay.sofa.jraft.Status;
 import com.alipay.sofa.jraft.conf.Configuration;
 import com.alipay.sofa.jraft.core.Replicator;
+import com.alipay.sofa.jraft.core.State;
 import com.alipay.sofa.jraft.entity.PeerId;
 import com.alipay.sofa.jraft.entity.Task;
 import com.alipay.sofa.jraft.error.RaftError;
@@ -63,14 +66,24 @@ import lombok.extern.slf4j.Slf4j;
 @Slf4j
 public class RaftEngine {
 
+    /**
+     * Refresh period of the alive peer count behind {@code 
hg_raft_alive_peers}. jraft's own
+     * step-down timer walks the same peer set every half election timeout, so 
a one second
+     * poll adds nothing next to the work the node already does, and it keeps 
the gauge fresh
+     * well inside any scrape interval.
+     */
+    private static final long ALIVE_PEERS_REFRESH_MS = 1000L;
+
     private volatile static RaftEngine instance = new RaftEngine();
     private RaftStateMachine stateMachine;
     private String groupId = "pd_raft";
     private PDConfig.Raft config;
     private RaftGroupService raftGroupService;
     private RpcServer rpcServer;
-    private Node raftNode;
+    private volatile Node raftNode;
     private RaftRpcClient raftRpcClient;
+    private volatile int alivePeerCount = -1;
+    private ScheduledExecutorService alivePeersRefresher;
 
     public RaftEngine() {
         this.stateMachine = new RaftStateMachine();
@@ -133,6 +146,7 @@ public class RaftEngine {
         this.raftGroupService =
                 new RaftGroupService(groupId, serverId, nodeOptions, 
rpcServer, true);
         this.raftNode = raftGroupService.start(false);
+        startAlivePeersRefresher();
         log.info("RaftEngine start successfully: id = {}, peers list = {}", 
groupId,
                  nodeOptions.getInitialConf().getPeers());
         return this.raftNode != null;
@@ -182,6 +196,24 @@ public class RaftEngine {
     }
 
     public void shutDown() {
+        if (this.alivePeersRefresher != null) {
+            this.alivePeersRefresher.shutdownNow();
+            try {
+                // Best effort: shutdownNow only interrupts, and a refresh 
parked in
+                // listAlivePeers waits on a lock acquire the interrupt does 
not break,
+                // for up to the raft rpc connect timeout per unreachable 
peer. A refresh
+                // that outlives this wait may publish one stale count over 
the reset
+                // below; acceptable while shutDown has no production caller.
+                if (!this.alivePeersRefresher.awaitTermination(1, 
TimeUnit.SECONDS)) {
+                    log.warn("Raft alive-peers refresher still running after 
shutdown; " +
+                             "hg_raft_alive_peers may briefly report a stale 
value");
+                }
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+            }
+            this.alivePeersRefresher = null;
+        }
+        this.alivePeerCount = -1;
         if (this.raftGroupService != null) {
             this.raftGroupService.shutdown();
             try {
@@ -203,7 +235,139 @@ public class RaftEngine {
     }
 
     public boolean isLeader() {
-        return this.raftNode.isLeader(true);
+        Node node = this.raftNode;
+        return node != null && node.isLeader(true);
+    }
+
+    /**
+     * Whether this PD is ready in the sense {@code GET /v1/ready} answers: 
the raft node
+     * is active and sees a leader.
+     * <p>
+     * A follower only keeps its leader while heartbeats keep arriving inside 
the election
+     * timeout, and a leader only keeps its role while it can reach a quorum. 
Seeing a leader
+     * therefore means this node is part of a quorum from its own point of 
view, which is the
+     * signal a readiness probe needs. Served from the state machine 
callbacks, not from the
+     * raft node, so it never waits on the node lock.
+     * <p>
+     * Derived from {@link #getRaftStatus()} so {@code hg_raft_has_leader} 
cannot drift from
+     * the endpoint it is documented to mirror.
+     */
+    public boolean hasLeader() {
+        return getRaftStatus().isReady();
+    }
+
+    /**
+     * Take a view of the local raft state from the volatile copies the state 
machine
+     * callbacks maintain, never from the raft node itself. During an election 
jraft holds
+     * the node lock while it reconnects to peers, so a probe that read the 
node stalled for
+     * the connect timeout instead of answering its 503 promptly. All fields 
derive from one
+     * read of one immutable view, swapped in whole per callback, so they 
cannot contradict
+     * each other.
+     * <p>
+     * The state reported is the last one a callback announced: leader, 
follower, error or
+     * shutdown, and {@code STATE_UNINITIALIZED} until the first callback 
runs. jraft emits
+     * no callback for candidacy or leadership transfer, so a candidate reads 
as a follower
+     * that sees no leader, which yields the same not-ready answer. The view 
trails the node
+     * by whatever sits in the FSM queue ahead of the announcement, which is 
the price of
+     * never waiting on the node lock.
+     * <p>
+     * A missing raft node needs no separate branch: before {@link #init} the 
view still
+     * holds its initial {@code STATE_UNINITIALIZED}, and {@code shutDown()} 
drops the node
+     * only after {@code join()} has let the shutdown callback announce
+     * {@code STATE_SHUTDOWN}. Neither state is active, so neither reads as 
ready, and a
+     * branch on the node would report an uninitialized PD where the callback 
said error or
+     * shutdown.
+     */
+    public RaftStatus getRaftStatus() {
+        RaftStateMachine.ProbeView view = this.stateMachine.getProbeView();
+        return new RaftStatus(view.state.isActive() && view.seesLeader,
+                              view.state.name(), State.STATE_LEADER == 
view.state);
+    }
+
+    /**
+     * Immutable view of the raft state behind {@code GET /v1/ready}. It 
carries no cluster
+     * addresses: the endpoint is unauthenticated, and the leader's address 
stays on the
+     * authenticated {@code /v1/members}.
+     */
+    public static final class RaftStatus {
+
+        private final boolean ready;
+        private final String state;
+        private final boolean localLeader;
+
+        RaftStatus(boolean ready, String state, boolean localLeader) {
+            this.ready = ready;
+            this.state = state;
+            this.localLeader = localLeader;
+        }
+
+        public boolean isReady() {
+            return this.ready;
+        }
+
+        /**
+         * @return the jraft node state name, never null
+         */
+        public String getState() {
+            return this.state;
+        }
+
+        public boolean isLocalLeader() {
+            return this.localLeader;
+        }
+    }
+
+    /**
+     * Number of raft peers, this node included, that the leader has heard 
from within the
+     * leader lease timeout, which jraft derives as 90% of the election 
timeout by default.
+     * Only the leader tracks replication state, so any other node reports -1.
+     * <p>
+     * This is the value the refresher last published, at most one refresh 
period old, and -1
+     * until the first refresh runs. The count cannot be read here: {@code 
listAlivePeers}
+     * takes the node read lock before it checks for leadership, so a caller 
that read the
+     * node would wait out whoever holds the write lock, which during an 
election is a
+     * per-peer reconnect bounded only by the rpc connect timeout.
+     */
+    public int getAlivePeerCount() {
+        return this.alivePeerCount;
+    }
+
+    private void startAlivePeersRefresher() {
+        ScheduledExecutorService refresher =
+                Executors.newSingleThreadScheduledExecutor(runnable -> {
+                    Thread thread = new Thread(runnable, 
"pd-raft-alive-peers");
+                    thread.setDaemon(true);
+                    return thread;
+                });
+        refresher.scheduleWithFixedDelay(this::refreshAlivePeerCount, 0,
+                                         ALIVE_PEERS_REFRESH_MS, 
TimeUnit.MILLISECONDS);
+        this.alivePeersRefresher = refresher;
+    }
+
+    /**
+     * Read the alive peer count from the raft node and publish it for the 
gauge. Runs on the
+     * refresher thread, never on a request thread, because the read can block 
for as long as
+     * an election holds the node write lock. A fixed delay schedule means a 
blocked refresh
+     * only delays the next one, and the gauge keeps reporting the last value 
meanwhile.
+     */
+    void refreshAlivePeerCount() {
+        Node node = this.raftNode;
+        if (node == null || !this.stateMachine.isLeader()) {
+            this.alivePeerCount = -1;
+            return;
+        }
+        try {
+            this.alivePeerCount = node.listAlivePeers().size();
+        } catch (IllegalStateException e) {
+            // Lost leadership between the check and the call
+            this.alivePeerCount = -1;
+        } catch (Throwable e) {
+            // scheduleWithFixedDelay cancels every later run if the task 
throws,
+            // and an Error escaping here would leave the gauge serving its 
last
+            // value forever, so this catch has to be wider than Exception.
+            log.warn("Failed to refresh the raft alive peer count", e);
+            this.alivePeerCount = -1;
+        }
     }
 
     /**
@@ -232,7 +396,8 @@ public class RaftEngine {
     }
 
     public PeerId getLeader() {
-        return raftNode.getLeaderId();
+        Node node = this.raftNode;
+        return node == null ? null : node.getLeaderId();
     }
 
     /**
diff --git 
a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/raft/RaftStateMachine.java
 
b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/raft/RaftStateMachine.java
index 6fad3347f..aab90e533 100644
--- 
a/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/raft/RaftStateMachine.java
+++ 
b/hugegraph-pd/hg-pd-core/src/main/java/org/apache/hugegraph/pd/raft/RaftStateMachine.java
@@ -36,6 +36,7 @@ import com.alipay.sofa.jraft.Closure;
 import com.alipay.sofa.jraft.Iterator;
 import com.alipay.sofa.jraft.Status;
 import com.alipay.sofa.jraft.conf.Configuration;
+import com.alipay.sofa.jraft.core.State;
 import com.alipay.sofa.jraft.core.StateMachineAdapter;
 import com.alipay.sofa.jraft.entity.LeaderChangeContext;
 import com.alipay.sofa.jraft.entity.LocalFileMetaOutter;
@@ -56,6 +57,27 @@ public class RaftStateMachine extends StateMachineAdapter {
     private static final String SNAPSHOT_DIR_NAME = "snapshot";
     private static final String SNAPSHOT_ARCHIVE_NAME = "snapshot.zip";
     private final AtomicLong leaderTerm = new AtomicLong(-1);
+    // Kept for the readiness probe and the raft gauges, which must answer 
without
+    // touching the raft node: during an election jraft holds the node lock 
while it
+    // reconnects to peers, and a reader stalls for the connect timeout. One 
volatile
+    // immutable pair, swapped in whole per callback, so a reader can never 
see the
+    // state of one announcement with the leader visibility of another.
+    private volatile ProbeView probeView = new 
ProbeView(State.STATE_UNINITIALIZED, false);
+
+    /**
+     * Immutable pair of the last announced node state and leader visibility. 
Written only
+     * from the FSM callback thread; both fields always come from the same 
announcement.
+     */
+    static final class ProbeView {
+
+        final State state;
+        final boolean seesLeader;
+
+        ProbeView(State state, boolean seesLeader) {
+            this.state = state;
+            this.seesLeader = seesLeader;
+        }
+    }
     private List<RaftTaskHandler> taskHandlers;
     private List<RaftStateListener> stateListeners;
 
@@ -76,6 +98,15 @@ public class RaftStateMachine extends StateMachineAdapter {
         return this.leaderTerm.get() > 0;
     }
 
+    /**
+     * The last state and leader visibility a raft callback announced, as one 
immutable
+     * view. jraft emits no callback for candidacy or leadership transfer, so 
a candidate
+     * reads as a follower that sees no leader.
+     */
+    ProbeView getProbeView() {
+        return this.probeView;
+    }
+
     @Override
     public void onApply(Iterator iter) {
         while (iter.hasNext()) {
@@ -105,17 +136,24 @@ public class RaftStateMachine extends StateMachineAdapter 
{
 
     @Override
     public void onError(final RaftException e) {
+        // A node that errors after it was leader keeps a positive leaderTerm 
otherwise, and
+        // the alive-peer refresher would go on reading listAlivePeers off a 
broken node
+        this.leaderTerm.set(-1);
+        this.probeView = new ProbeView(State.STATE_ERROR, false);
         log.error("Raft StateMachine on error {}", e);
     }
 
     @Override
     public void onShutdown() {
+        this.leaderTerm.set(-1);
+        this.probeView = new ProbeView(State.STATE_SHUTDOWN, false);
         super.onShutdown();
     }
 
     @Override
     public void onLeaderStart(final long term) {
         this.leaderTerm.set(term);
+        this.probeView = new ProbeView(State.STATE_LEADER, true);
         super.onLeaderStart(term);
 
         log.info("Raft becomes leader");
@@ -129,12 +167,14 @@ public class RaftStateMachine extends StateMachineAdapter 
{
     @Override
     public void onLeaderStop(final Status status) {
         this.leaderTerm.set(-1);
+        this.probeView = new ProbeView(State.STATE_FOLLOWER, false);
         super.onLeaderStop(status);
         log.info("Raft  lost leader ");
     }
 
     @Override
     public void onStartFollowing(final LeaderChangeContext ctx) {
+        this.probeView = new ProbeView(State.STATE_FOLLOWER, true);
         super.onStartFollowing(ctx);
         Utils.runInThread(() -> {
             if (!CollectionUtils.isEmpty(stateListeners)) {
@@ -145,6 +185,8 @@ public class RaftStateMachine extends StateMachineAdapter {
 
     @Override
     public void onStopFollowing(final LeaderChangeContext ctx) {
+        // Callbacks run on the single FSM thread, so reading our own field is 
safe
+        this.probeView = new ProbeView(this.probeView.state, false);
         super.onStopFollowing(ctx);
     }
 
diff --git a/hugegraph-pd/hg-pd-service/pom.xml 
b/hugegraph-pd/hg-pd-service/pom.xml
index ee78863f3..eef38b407 100644
--- a/hugegraph-pd/hg-pd-service/pom.xml
+++ b/hugegraph-pd/hg-pd-service/pom.xml
@@ -162,6 +162,12 @@
             <artifactId>log4j-jul</artifactId>
             <version>2.17.2</version>
         </dependency>
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>${junit.version}</version>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
     <build>
         <plugins>
diff --git 
a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/metrics/PDMetrics.java
 
b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/metrics/PDMetrics.java
index 483974a01..793aa24b0 100644
--- 
a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/metrics/PDMetrics.java
+++ 
b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/metrics/PDMetrics.java
@@ -33,6 +33,7 @@ import org.apache.hugegraph.pd.common.PDException;
 import org.apache.hugegraph.pd.grpc.Metapb;
 import org.apache.hugegraph.pd.grpc.Metapb.ShardGroup;
 import org.apache.hugegraph.pd.model.GraphStatistics;
+import org.apache.hugegraph.pd.raft.RaftEngine;
 import org.apache.hugegraph.pd.service.PDRestService;
 import org.apache.hugegraph.pd.service.PDService;
 
@@ -76,7 +77,28 @@ public final class PDMetrics {
         Gauge.builder(PREFIX + ".terms", () -> setTerms())
              .description("term of partitions in PD")
              .register(registry);
+        registerRaftMeters();
+    }
 
+    /**
+     * Raft membership gauges so operators can alert on quorum loss. They 
mirror what
+     * {@code GET /v1/ready} answers: a PD that sees no leader is outside a 
quorum.
+     */
+    private void registerRaftMeters() {
+        RaftEngine raft = RaftEngine.getInstance();
+        Gauge.builder(PREFIX + ".raft.leader", () -> 
raft.getRaftStatus().isLocalLeader() ? 1 : 0)
+             .description("1 if this PD is the raft leader, 0 otherwise")
+             .register(registry);
+        Gauge.builder(PREFIX + ".raft.has.leader", () -> raft.hasLeader() ? 1 
: 0)
+             .description("1 if this PD sees a raft leader, i.e. is part of a 
quorum, 0 otherwise")
+             .register(registry);
+        Gauge.builder(PREFIX + ".raft.alive.peers", () -> {
+                 int alive = raft.getAlivePeerCount();
+                 return alive < 0 ? Double.NaN : alive;
+             })
+             .description("Number of raft peers, itself included, the leader 
has heard from " +
+                          "within the leader lease timeout; NaN on non-leader 
nodes")
+             .register(registry);
     }
 
     private long updateGraphs() {
diff --git 
a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/rest/StoreAPI.java
 
b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/rest/StoreAPI.java
index 2cddb29fe..b3c30ca9e 100644
--- 
a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/rest/StoreAPI.java
+++ 
b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/rest/StoreAPI.java
@@ -22,6 +22,7 @@ import java.util.ArrayList;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -31,11 +32,14 @@ import org.apache.hugegraph.pd.grpc.Pdpb;
 import org.apache.hugegraph.pd.model.RestApiResponse;
 import org.apache.hugegraph.pd.model.StoreRestRequest;
 import org.apache.hugegraph.pd.model.TimeRangeRequest;
+import org.apache.hugegraph.pd.raft.RaftEngine;
 import org.apache.hugegraph.pd.service.PDRestService;
 import org.apache.hugegraph.pd.util.DateUtil;
 import org.apache.hugegraph.pd.util.StoreRestAddressUtil;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
 import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.DeleteMapping;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PathVariable;
@@ -378,6 +382,10 @@ public class StoreAPI extends API {
      * Check Service Health Status
      * This interface is used to check the health status of the service by 
accessing the /health
      * path via a GET request.
+     * <p>
+     * This is a liveness signal only: it answers 200 as soon as the REST 
listener is up and
+     * does not consult the raft state. Use {@link #checkReady()} to find out 
whether this PD
+     * can actually serve.
      *
      * @return Returns a string indicating the service's health status. 
Typically, an empty
      * string indicates the service is healthy.
@@ -386,4 +394,27 @@ public class StoreAPI extends API {
     public Serializable checkHealthy() {
         return "";
     }
+
+    /**
+     * Check Service Readiness
+     * Answers 200 only when this PD is part of a raft quorum, that is, the 
raft node is active
+     * and knows the current leader. Otherwise answers 503 so that anything 
gating on PD
+     * (the compose healthcheck in front of Stores, a Kubernetes readiness 
probe)
+     * is held back until the PD can serve. Like /health this endpoint needs 
no authentication.
+     *
+     * @return JSON with the readiness flag, the local raft state and whether 
this node is the
+     * leader. It carries no cluster addresses, since the endpoint is 
unauthenticated.
+     */
+    @GetMapping(value = "/ready", produces = MediaType.APPLICATION_JSON_VALUE)
+    public ResponseEntity<Map<String, Object>> checkReady() {
+        RaftEngine.RaftStatus status = 
RaftEngine.getInstance().getRaftStatus();
+
+        Map<String, Object> body = new LinkedHashMap<>();
+        body.put("ready", status.isReady());
+        body.put("state", status.getState());
+        body.put("isLeader", status.isLocalLeader());
+        HttpStatus httpStatus = status.isReady() ? HttpStatus.OK
+                                                 : 
HttpStatus.SERVICE_UNAVAILABLE;
+        return ResponseEntity.status(httpStatus).body(body);
+    }
 }
diff --git 
a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/rest/interceptor/AuthenticationConfigurer.java
 
b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/rest/interceptor/AuthenticationConfigurer.java
index 7d1041696..d4b1e026d 100644
--- 
a/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/rest/interceptor/AuthenticationConfigurer.java
+++ 
b/hugegraph-pd/hg-pd-service/src/main/java/org/apache/hugegraph/pd/rest/interceptor/AuthenticationConfigurer.java
@@ -32,6 +32,7 @@ public class AuthenticationConfigurer implements 
WebMvcConfigurer {
     public void addInterceptors(InterceptorRegistry registry) {
         registry.addInterceptor(restAuthentication)
                 .addPathPatterns("/**")
-                .excludePathPatterns("/actuator/*", "/v1/health", 
"/v1/prom/targets/*");
+                .excludePathPatterns("/actuator/*", "/v1/health", "/v1/ready",
+                                     "/v1/prom/targets/*");
     }
 }
diff --git 
a/hugegraph-pd/hg-pd-service/src/test/java/org/apache/hugegraph/pd/rest/StoreAPIReadyTest.java
 
b/hugegraph-pd/hg-pd-service/src/test/java/org/apache/hugegraph/pd/rest/StoreAPIReadyTest.java
new file mode 100644
index 000000000..75e9581cb
--- /dev/null
+++ 
b/hugegraph-pd/hg-pd-service/src/test/java/org/apache/hugegraph/pd/rest/StoreAPIReadyTest.java
@@ -0,0 +1,144 @@
+/*
+ * 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.hugegraph.pd.rest;
+
+import java.lang.reflect.Proxy;
+import java.util.Map;
+
+import org.apache.hugegraph.pd.raft.RaftEngine;
+import org.apache.hugegraph.pd.raft.RaftStateMachine;
+import org.apache.hugegraph.testutil.Whitebox;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+
+import com.alipay.sofa.jraft.Node;
+import com.alipay.sofa.jraft.Status;
+import com.alipay.sofa.jraft.core.State;
+import com.alipay.sofa.jraft.entity.LeaderChangeContext;
+import com.alipay.sofa.jraft.entity.PeerId;
+
+/**
+ * Pins the status a readiness probe actually receives from {@code GET 
/v1/ready}: 503 while
+ * this PD is outside a raft quorum, 200 once it is inside one, with the same 
body either way.
+ * The live REST suite can only reach the 200 path, because the PD it talks to 
is a single node
+ * group that is always its own leader.
+ */
+public class StoreAPIReadyTest {
+
+    private static final PeerId LEADER = new PeerId("10.0.0.1", 8610);
+
+    private final StoreAPI api = new StoreAPI();
+
+    private Node originalRaftNode;
+    private RaftStateMachine originalStateMachine;
+    private RaftStateMachine stateMachine;
+
+    @Before
+    public void setUp() {
+        RaftEngine engine = RaftEngine.getInstance();
+        this.originalRaftNode = engine.getRaftNode();
+        this.originalStateMachine = Whitebox.getInternalState(engine, 
"stateMachine");
+
+        // A fresh machine so callbacks fired here reach no listeners of the 
real one
+        this.stateMachine = new RaftStateMachine();
+        Whitebox.setInternalState(engine, "stateMachine", this.stateMachine);
+    }
+
+    @After
+    public void tearDown() {
+        RaftEngine engine = RaftEngine.getInstance();
+        Whitebox.setInternalState(engine, "raftNode", this.originalRaftNode);
+        Whitebox.setInternalState(engine, "stateMachine", 
this.originalStateMachine);
+    }
+
+    @Test
+    public void testNotReadyBeforeRaftStartsAnswers503() {
+        Whitebox.setInternalState(RaftEngine.getInstance(), "raftNode", null);
+
+        ResponseEntity<Map<String, Object>> response = this.api.checkReady();
+
+        Assert.assertEquals(HttpStatus.SERVICE_UNAVAILABLE, 
response.getStatusCode());
+        Map<String, Object> body = response.getBody();
+        Assert.assertNotNull(body);
+        Assert.assertEquals(Boolean.FALSE, body.get("ready"));
+        Assert.assertEquals(Boolean.FALSE, body.get("isLeader"));
+        Assert.assertEquals(State.STATE_UNINITIALIZED.name(), 
body.get("state"));
+    }
+
+    @Test
+    public void testLosingTheLeaderAnswers503() {
+        Whitebox.setInternalState(RaftEngine.getInstance(), "raftNode", 
untouchableNode());
+        // The quorum-loss shape: heartbeats arrive, then they stop
+        this.stateMachine.onStartFollowing(context());
+        this.stateMachine.onStopFollowing(context());
+
+        ResponseEntity<Map<String, Object>> response = this.api.checkReady();
+
+        Assert.assertEquals(HttpStatus.SERVICE_UNAVAILABLE, 
response.getStatusCode());
+        Map<String, Object> body = response.getBody();
+        Assert.assertNotNull(body);
+        Assert.assertEquals(Boolean.FALSE, body.get("ready"));
+        Assert.assertEquals(Boolean.FALSE, body.get("isLeader"));
+        Assert.assertEquals(State.STATE_FOLLOWER.name(), body.get("state"));
+    }
+
+    @Test
+    public void testLeaderAnswers200() {
+        Whitebox.setInternalState(RaftEngine.getInstance(), "raftNode", 
untouchableNode());
+        this.stateMachine.onLeaderStart(5);
+
+        ResponseEntity<Map<String, Object>> response = this.api.checkReady();
+
+        Assert.assertEquals(HttpStatus.OK, response.getStatusCode());
+        Map<String, Object> body = response.getBody();
+        Assert.assertNotNull(body);
+        Assert.assertEquals(Boolean.TRUE, body.get("ready"));
+        Assert.assertEquals(Boolean.TRUE, body.get("isLeader"));
+        Assert.assertEquals(State.STATE_LEADER.name(), body.get("state"));
+    }
+
+    private static LeaderChangeContext context() {
+        return new LeaderChangeContext(LEADER, 5, Status.OK());
+    }
+
+    /**
+     * A started raft node that fails the test if the endpoint calls it. 
Readiness is served
+     * from the state machine callbacks precisely so that it never waits on 
the node lock.
+     */
+    private static Node untouchableNode() {
+        return (Node) Proxy.newProxyInstance(
+                Node.class.getClassLoader(), new Class<?>[]{Node.class},
+                (proxy, method, args) -> {
+                    switch (method.getName()) {
+                        case "toString":
+                            return "untouchable raft node";
+                        case "hashCode":
+                            return System.identityHashCode(proxy);
+                        case "equals":
+                            return proxy == args[0];
+                        default:
+                            throw new AssertionError("the ready path must not 
touch the raft " +
+                                                     "node, it called " + 
method.getName());
+                    }
+                });
+    }
+}
diff --git 
a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/PDCoreSuiteTest.java
 
b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/PDCoreSuiteTest.java
index 95b044c76..bdacf7d37 100644
--- 
a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/PDCoreSuiteTest.java
+++ 
b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/PDCoreSuiteTest.java
@@ -22,6 +22,7 @@ import org.apache.hugegraph.pd.core.store.HgKVStoreImplTest;
 import org.apache.hugegraph.pd.raft.IpAuthHandlerTest;
 import org.apache.hugegraph.pd.raft.RaftEngineIpAuthIntegrationTest;
 import org.apache.hugegraph.pd.raft.RaftEngineLeaderAddressTest;
+import org.apache.hugegraph.pd.raft.RaftEngineReadinessTest;
 import org.junit.runner.RunWith;
 import org.junit.runners.Suite;
 
@@ -43,6 +44,7 @@ import lombok.extern.slf4j.Slf4j;
         IpAuthHandlerTest.class,
         RaftEngineIpAuthIntegrationTest.class,
         RaftEngineLeaderAddressTest.class,
+        RaftEngineReadinessTest.class,
         // StoreNodeServiceTest.class,
 })
 @Slf4j
diff --git 
a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/RaftEngineReadinessTest.java
 
b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/RaftEngineReadinessTest.java
new file mode 100644
index 000000000..08c35287d
--- /dev/null
+++ 
b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/RaftEngineReadinessTest.java
@@ -0,0 +1,257 @@
+/*
+ * 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.hugegraph.pd.raft;
+
+import java.util.Arrays;
+
+import org.apache.hugegraph.testutil.Whitebox;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import com.alipay.sofa.jraft.Node;
+import com.alipay.sofa.jraft.Status;
+import com.alipay.sofa.jraft.core.State;
+import com.alipay.sofa.jraft.entity.LeaderChangeContext;
+import com.alipay.sofa.jraft.entity.PeerId;
+import com.alipay.sofa.jraft.error.RaftException;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * Covers the raft-aware readiness signal behind {@code GET /v1/ready} and the
+ * {@code hg.raft.*} gauges. The signal is served from the volatile copies the 
state machine
+ * callbacks maintain, never from the raft node, so the probe stays prompt 
while an election
+ * holds the node lock; these tests drive the callbacks the way jraft does. 
The alive peer
+ * count is the one value that has to come off the node, so it is refreshed 
explicitly here,
+ * the way the refresher thread does it in a running PD.
+ */
+public class RaftEngineReadinessTest {
+
+    private static final PeerId LEADER = new PeerId("10.0.0.1", 8610);
+
+    private Node originalRaftNode;
+    private RaftStateMachine originalStateMachine;
+
+    private Node mockNode;
+    private RaftStateMachine stateMachine;
+
+    @Before
+    public void setUp() {
+        RaftEngine engine = RaftEngine.getInstance();
+        originalRaftNode = engine.getRaftNode();
+        originalStateMachine = Whitebox.getInternalState(engine, 
"stateMachine");
+
+        // A fresh machine so callbacks fired here reach no listeners other 
tests registered
+        mockNode = mock(Node.class);
+        stateMachine = new RaftStateMachine();
+        Whitebox.setInternalState(engine, "raftNode", mockNode);
+        Whitebox.setInternalState(engine, "stateMachine", stateMachine);
+        // The count is cached on the singleton, so clear what an earlier test 
published
+        engine.refreshAlivePeerCount();
+    }
+
+    @After
+    public void tearDown() {
+        RaftEngine engine = RaftEngine.getInstance();
+        Whitebox.setInternalState(engine, "raftNode", originalRaftNode);
+        Whitebox.setInternalState(engine, "stateMachine", 
originalStateMachine);
+    }
+
+    private static LeaderChangeContext ctx() {
+        return new LeaderChangeContext(LEADER, 5, Status.OK());
+    }
+
+    @Test
+    public void testNotReadyBeforeRaftNodeStarts() {
+        Whitebox.setInternalState(RaftEngine.getInstance(), "raftNode", null);
+        RaftEngine engine = RaftEngine.getInstance();
+        engine.refreshAlivePeerCount();
+
+        RaftEngine.RaftStatus status = engine.getRaftStatus();
+        Assert.assertFalse(status.isReady());
+        Assert.assertFalse(status.isLocalLeader());
+        Assert.assertEquals(State.STATE_UNINITIALIZED.name(), 
status.getState());
+        Assert.assertFalse(engine.hasLeader());
+        Assert.assertEquals(-1, engine.getAlivePeerCount());
+    }
+
+    @Test
+    public void testStartedNodeWithoutAnyCallbackIsNotReady() {
+        RaftEngine.RaftStatus status = 
RaftEngine.getInstance().getRaftStatus();
+        Assert.assertFalse(status.isReady());
+        Assert.assertEquals(State.STATE_UNINITIALIZED.name(), 
status.getState());
+    }
+
+    @Test
+    public void testLeaderIsReady() {
+        stateMachine.onLeaderStart(5);
+        when(mockNode.listAlivePeers()).thenReturn(Arrays.asList(LEADER, new 
PeerId("b", 1),
+                                                                 new 
PeerId("c", 1)));
+        RaftEngine engine = RaftEngine.getInstance();
+        engine.refreshAlivePeerCount();
+
+        RaftEngine.RaftStatus status = engine.getRaftStatus();
+        Assert.assertTrue(status.isReady());
+        Assert.assertTrue(status.isLocalLeader());
+        Assert.assertEquals(State.STATE_LEADER.name(), status.getState());
+        Assert.assertTrue(engine.hasLeader());
+        Assert.assertEquals(3, engine.getAlivePeerCount());
+    }
+
+    @Test
+    public void testFollowerWithLeaderIsReady() {
+        stateMachine.onStartFollowing(ctx());
+        RaftEngine engine = RaftEngine.getInstance();
+        engine.refreshAlivePeerCount();
+
+        RaftEngine.RaftStatus status = engine.getRaftStatus();
+        Assert.assertTrue(status.isReady());
+        Assert.assertFalse(status.isLocalLeader());
+        Assert.assertEquals(State.STATE_FOLLOWER.name(), status.getState());
+        Assert.assertTrue(engine.hasLeader());
+        // Only the leader tracks replication, followers cannot count alive 
peers
+        Assert.assertEquals(-1, engine.getAlivePeerCount());
+    }
+
+    @Test
+    public void testFollowerLosingItsLeaderTurnsNotReady() {
+        // The quorum-loss shape from the issue: heartbeats stop, jraft 
announces the loss
+        stateMachine.onStartFollowing(ctx());
+        stateMachine.onStopFollowing(ctx());
+        RaftEngine engine = RaftEngine.getInstance();
+
+        RaftEngine.RaftStatus status = engine.getRaftStatus();
+        Assert.assertFalse(status.isReady());
+        Assert.assertEquals(State.STATE_FOLLOWER.name(), status.getState());
+        Assert.assertFalse(engine.hasLeader());
+    }
+
+    @Test
+    public void testLeaderSteppingDownTurnsNotReady() {
+        stateMachine.onLeaderStart(5);
+        stateMachine.onLeaderStop(Status.OK());
+        RaftEngine engine = RaftEngine.getInstance();
+        engine.refreshAlivePeerCount();
+
+        RaftEngine.RaftStatus status = engine.getRaftStatus();
+        Assert.assertFalse(status.isReady());
+        Assert.assertFalse(status.isLocalLeader());
+        Assert.assertFalse(engine.hasLeader());
+        Assert.assertEquals(-1, engine.getAlivePeerCount());
+    }
+
+    @Test
+    public void testErrorAndShutdownAreNotReadyEvenAfterLeadership() {
+        stateMachine.onLeaderStart(5);
+        stateMachine.onError(mock(RaftException.class));
+        Assert.assertFalse(RaftEngine.getInstance().getRaftStatus().isReady());
+        Assert.assertEquals(State.STATE_ERROR.name(),
+                            
RaftEngine.getInstance().getRaftStatus().getState());
+
+        stateMachine.onShutdown();
+        Assert.assertFalse(RaftEngine.getInstance().getRaftStatus().isReady());
+        Assert.assertEquals(State.STATE_SHUTDOWN.name(),
+                            
RaftEngine.getInstance().getRaftStatus().getState());
+    }
+
+    @Test
+    public void testProbeNeverTouchesTheRaftNode() {
+        // The point of serving from callbacks: an election holds the node 
lock while jraft
+        // reconnects to peers, so the probe and the gauges must answer 
without the node.
+        // getAlivePeerCount() included: it reads what the refresher thread 
published
+        stateMachine.onStartFollowing(ctx());
+        stateMachine.onStopFollowing(ctx());
+        RaftEngine engine = RaftEngine.getInstance();
+
+        engine.getRaftStatus();
+        engine.hasLeader();
+        engine.getAlivePeerCount();
+
+        verifyNoInteractions(mockNode);
+    }
+
+    @Test
+    public void testAlivePeerCountSurvivesLeadershipLossRace() {
+        stateMachine.onLeaderStart(5);
+        when(mockNode.listAlivePeers()).thenThrow(new 
IllegalStateException("Not leader"));
+        RaftEngine.getInstance().refreshAlivePeerCount();
+
+        Assert.assertEquals(-1, RaftEngine.getInstance().getAlivePeerCount());
+    }
+
+    @Test
+    public void testShutdownStateSurvivesTheNodeBeingDropped() {
+        // shutDown() drops the raft node after the shutdown callback ran, so 
the probe has
+        // to keep reporting what the callback announced rather than falling 
back to
+        // uninitialized, which would read as a PD that has not started yet
+        stateMachine.onLeaderStart(5);
+        stateMachine.onShutdown();
+        Whitebox.setInternalState(RaftEngine.getInstance(), "raftNode", null);
+        RaftEngine engine = RaftEngine.getInstance();
+
+        RaftEngine.RaftStatus status = engine.getRaftStatus();
+        Assert.assertFalse(status.isReady());
+        Assert.assertFalse(status.isLocalLeader());
+        Assert.assertEquals(State.STATE_SHUTDOWN.name(), status.getState());
+        Assert.assertFalse(engine.hasLeader());
+    }
+
+    @Test
+    public void testAlivePeerCountStopsAfterALeaderErrorsOrShutsDown() {
+        // The terminal callbacks clear the leader term, so the refresher 
stops reading the
+        // count off a node that errored or shut down and the gauge falls back 
to NaN
+        stateMachine.onLeaderStart(5);
+        when(mockNode.listAlivePeers()).thenReturn(Arrays.asList(LEADER, new 
PeerId("b", 1)));
+        RaftEngine engine = RaftEngine.getInstance();
+        engine.refreshAlivePeerCount();
+        Assert.assertEquals(2, engine.getAlivePeerCount());
+
+        stateMachine.onError(mock(RaftException.class));
+        engine.refreshAlivePeerCount();
+        Assert.assertEquals(-1, engine.getAlivePeerCount());
+
+        stateMachine.onLeaderStart(6);
+        engine.refreshAlivePeerCount();
+        Assert.assertEquals(2, engine.getAlivePeerCount());
+
+        stateMachine.onShutdown();
+        engine.refreshAlivePeerCount();
+        Assert.assertEquals(-1, engine.getAlivePeerCount());
+        // Twice, once per leader phase: the terminal states never reached the 
node
+        verify(mockNode, times(2)).listAlivePeers();
+    }
+
+    @Test
+    public void testAlivePeerCountOnALeaderAlsoSkipsTheNode() {
+        // Even on a leader, where the count means something, the gauge reads 
the published
+        // value: listAlivePeers takes the node read lock before it checks for 
leadership, so
+        // a scrape that called it would wait out an election that holds the 
write lock
+        stateMachine.onLeaderStart(5);
+        RaftEngine engine = RaftEngine.getInstance();
+
+        engine.getAlivePeerCount();
+
+        verifyNoInteractions(mockNode);
+    }
+}
diff --git 
a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/rest/RestApiTest.java
 
b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/rest/RestApiTest.java
index fb2b71d48..da90f6f0f 100644
--- 
a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/rest/RestApiTest.java
+++ 
b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/rest/RestApiTest.java
@@ -25,6 +25,7 @@ import java.net.http.HttpResponse;
 
 import org.json.JSONException;
 import org.json.JSONObject;
+import org.junit.Assert;
 import org.junit.Test;
 
 public class RestApiTest extends BaseServerTest {
@@ -62,6 +63,62 @@ public class RestApiTest extends BaseServerTest {
         assert obj.getInt("status") == 0;
     }
 
+    @Test
+    public void testHealthNeedsNoAuth() throws URISyntaxException, IOException,
+                                             InterruptedException {
+        String url = pdRestAddr + "/v1/health";
+        HttpRequest request = HttpRequest.newBuilder().uri(new 
URI(url)).GET().build();
+        HttpResponse<String> response = client.send(request, 
HttpResponse.BodyHandlers.ofString());
+        Assert.assertEquals(200, response.statusCode());
+        // A 200 alone does not prove the path is anonymous: as of 1.7.0 the 
auth interceptor
+        // refuses with 200 and an error envelope. checkHealthy() returns an 
empty body, which
+        // separates the two whichever status a refusal carries.
+        Assert.assertTrue("expected an empty body, got " + response.body(),
+                          response.body().isEmpty());
+    }
+
+    @Test
+    public void testReadyNeedsNoAuthAndReflectsRaft() throws 
URISyntaxException, IOException,
+                                                            
InterruptedException, JSONException {
+        // The CI PD is a single-node raft group, so it is its own leader and 
must be ready
+        String url = pdRestAddr + "/v1/ready";
+        HttpRequest request = HttpRequest.newBuilder().uri(new 
URI(url)).GET().build();
+        HttpResponse<String> response = client.send(request, 
HttpResponse.BodyHandlers.ofString());
+        Assert.assertEquals("expected 200, body=" + response.body(), 200, 
response.statusCode());
+        JSONObject obj = new JSONObject(response.body());
+        Assert.assertTrue(obj.getBoolean("ready"));
+        Assert.assertTrue(obj.getBoolean("isLeader"));
+        Assert.assertEquals("STATE_LEADER", obj.getString("state"));
+        // Unauthenticated, so it must not disclose cluster addresses
+        Assert.assertFalse("the anonymous body must not carry the leader 
address",
+                           obj.has("leader"));
+    }
+
+    @Test
+    public void testRaftGaugesExported() throws URISyntaxException, 
IOException,
+                                                 InterruptedException {
+        String url = pdRestAddr + "/actuator/prometheus";
+        HttpRequest request = HttpRequest.newBuilder().uri(new 
URI(url)).GET().build();
+        HttpResponse<String> response = client.send(request, 
HttpResponse.BodyHandlers.ofString());
+        Assert.assertEquals(200, response.statusCode());
+        String body = response.body();
+        // Micrometer only writes a {...} block when the meter carries tags, 
and the sample
+        // line is the one that starts with the metric name, unlike its HELP 
and TYPE lines
+        Assert.assertTrue("missing hg_raft_leader gauge",
+                          body.matches("(?sm).*^hg_raft_leader(\\{[^}]*\\})? 
.*"));
+        Assert.assertTrue("missing hg_raft_has_leader gauge",
+                          
body.matches("(?sm).*^hg_raft_has_leader(\\{[^}]*\\})? .*"));
+        Assert.assertTrue("missing hg_raft_alive_peers gauge",
+                          
body.matches("(?sm).*^hg_raft_alive_peers(\\{[^}]*\\})? .*"));
+        // Single-node CI cluster: this PD is the leader and hears from itself
+        Assert.assertTrue("hg_raft_leader should be 1 on a single-node leader",
+                          body.matches("(?sm).*^hg_raft_leader(\\{[^}]*\\})? 
1\\.0.*"));
+        Assert.assertTrue("hg_raft_has_leader should be 1 on a single-node 
leader",
+                          
body.matches("(?sm).*^hg_raft_has_leader(\\{[^}]*\\})? 1\\.0.*"));
+        Assert.assertTrue("hg_raft_alive_peers should be 1 on a single-node 
leader",
+                          
body.matches("(?sm).*^hg_raft_alive_peers(\\{[^}]*\\})? 1\\.0.*"));
+    }
+
     @Test
     public void testQueryClusterMembers() throws URISyntaxException, 
IOException,
                                                  InterruptedException, 
JSONException {
diff --git 
a/hugegraph-server/hugegraph-dist/src/assembly/travis/test-start-hugegraph-pd.sh
 
b/hugegraph-server/hugegraph-dist/src/assembly/travis/test-start-hugegraph-pd.sh
index ab73255b8..754e2baae 100755
--- 
a/hugegraph-server/hugegraph-dist/src/assembly/travis/test-start-hugegraph-pd.sh
+++ 
b/hugegraph-server/hugegraph-dist/src/assembly/travis/test-start-hugegraph-pd.sh
@@ -111,6 +111,27 @@ wait_for_pd() {
     return 1
 }
 
+# Wait until PD readiness answers, or timeout. Same gate the docs recommend:
+# -f rejects the 503, the body match rejects a 200 that is an auth envelope 
rather
+# than a readiness answer. No credentials, and a single-node group elects 
itself.
+# Captured rather than piped: under pipefail a grep -q SIGPIPE could misread a
+# ready PD, and wait_for_pd() above uses the same shape. The curl timeouts 
bound
+# each attempt and the loop bounds itself on the wall clock, so a PD that 
accepts
+# the connection and then stops answering cannot park this loop past 
STARTUP_WAIT.
+# A per-round counter could not: an attempt costs up to --max-time plus the 
sleep,
+# which is more than the counter would add.
+wait_for_pd_ready() {
+    local deadline=$((SECONDS + STARTUP_WAIT))
+    while (( SECONDS < deadline )); do
+        local body
+        body=$(curl --connect-timeout 2 --max-time 5 -fsS \
+            "$PD_URL/v1/ready" 2>/dev/null || true)
+        grep -Eq '"ready"[[:space:]]*:[[:space:]]*true' <<<"$body" && return 0
+        sleep 2
+    done
+    return 1
+}
+
 # Wait until bin/pid is non-empty or timeout
 wait_for_pid_file() {
     local elapsed=0
@@ -199,6 +220,13 @@ else
     fail "PD health endpoint not responding after ${STARTUP_WAIT}s"
 fi
 
+info "Waiting up to ${STARTUP_WAIT}s for PD readiness endpoint..."
+if wait_for_pd_ready; then
+    pass "PD readiness endpoint reports a raft leader at $PD_URL/v1/ready"
+else
+    fail "PD readiness endpoint did not report ready=true after 
${STARTUP_WAIT}s"
+fi
+
 cleanup
 
 # ── test 2: foreground mode blocks ───────────────────────────────────────────
diff --git a/hugegraph-store/docs/deployment-guide.md 
b/hugegraph-store/docs/deployment-guide.md
index de07904d6..40c70b0e0 100644
--- a/hugegraph-store/docs/deployment-guide.md
+++ b/hugegraph-store/docs/deployment-guide.md
@@ -719,10 +719,15 @@ environment:
 ```
 
 **Startup ordering** is enforced via `depends_on` with `condition: 
service_healthy`:
-1. PD nodes start first and must pass healthchecks (`/v1/health`)
+1. PD nodes start first and must pass healthchecks (`/v1/health`, liveness 
only)
 2. Store nodes start after all PD nodes are healthy
 3. Server nodes start after all Store nodes are healthy
 
+`/v1/health` answers `200` as soon as the PD REST listener is up, so step 1 
does
+not wait for a raft quorum to form. PD also serves `/v1/ready`, which answers
+`200` only while the PD sees a raft leader; `docker/README.md` covers what
+pointing the healthchecks at it requires.
+
 > **Note**: The deprecated env var names (`GRPC_HOST`, `RAFT_ADDRESS`, 
 > `RAFT_PEERS`, `PD_ADDRESS`, `BACKEND`, `PD_PEERS`) still work but log a 
 > warning. Use the `HG_*` prefixed names for new deployments.
 
 **Deploy**:
@@ -855,13 +860,24 @@ kubectl port-forward svc/hugegraph-store 8500:8500 -n 
hugegraph
 ### Health Check
 
 ```bash
-# PD health
+# PD liveness (REST listener up)
 curl http://192.168.1.10:8620/v1/health
 
+# PD readiness (200 only while the PD sees a raft leader, 503 otherwise)
+curl -i http://192.168.1.10:8620/v1/ready
+
 # Store health
 curl http://192.168.1.20:8520/v1/health
 ```
 
+> **Note**: `/v1/ready` ships from the release after `1.7.0`, so the Docker
+> examples above, which pin `HUGEGRAPH_VERSION=1.7.0`, need a newer tag or
+> images built from source before this check means anything. On `1.7.0` the PD
+> answers `200` with `{"status":-1,"error":"Unauthorized!"}` on any path its
+> auth interceptor does not exclude, `/v1/ready` included, so match on the body
+> rather than the status code. See
+> [docker/README.md](../../docker/README.md) for the details.
+
 ### Cluster Status
 
 ```bash

Reply via email to