Skip to content

feat(pd): add quorum-aware /v1/ready endpoint and raft gauges - #3185

Open
bitflicker64 wants to merge 14 commits into
apache:masterfrom
bitflicker64:fix/pd-ready-endpoint
Open

feat(pd): add quorum-aware /v1/ready endpoint and raft gauges#3185
bitflicker64 wants to merge 14 commits into
apache:masterfrom
bitflicker64:fix/pd-ready-endpoint

Conversation

@bitflicker64

@bitflicker64 bitflicker64 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Purpose of the PR

Main Changes

  • RaftEngine: new hasLeader(), isReady(), getNodeState() and getAlivePeerCount(); isLeader() and getLeader() are now null-safe before the raft node starts.
  • StoreAPI: new unauthenticated GET /v1/ready. Returns 200 with {"ready":true,"state":"STATE_LEADER","isLeader":true} while the raft node is active and sees a leader, 503 with "ready":false otherwise. The body comes from one RaftEngine.getRaftStatus() snapshot and carries no cluster addresses. Added to the auth interceptor exclusion list next to /v1/health.
  • PDMetrics: three gauges for alerting on quorum loss: hg_raft_leader, hg_raft_has_leader, hg_raft_alive_peers (leader only, NaN elsewhere).
  • Docs for PD, Store and docker updated to explain liveness vs readiness. The compose healthchecks deliberately stay on /v1/health: these files run published images, and PD's auth interceptor answers 200 on any path it does not exclude, so a status-only probe reads a PD without the endpoint as ready. The docker README records what switching them over needs, a body match on "ready":true and an image that carries the endpoint.

Why "sees a leader" is the right local signal: jraft resets a follower's leader id once heartbeats stop arriving inside the election timeout, and a leader steps down when it cannot reach a quorum. So a non-null leader id means this node is inside a quorum from its own point of view, which is what a readiness probe needs. This matches the behaviour measured in the issue, where the survivor logged Raft lost leader within a second of the fault.

PD /v1/health vs /v1/ready, today and with this PR

Verify the Changes

  • New RaftEngineReadinessTest (added to PDCoreSuiteTest) covers: no raft node, leader, follower with leader, follower without leader, empty leader id, candidate, transferring, inactive states, and the leadership-loss race in getAlivePeerCount().
  • RestApiTest (runs against the live CI PD) now checks that /v1/health answers 200 with an empty body, that /v1/ready reports ready=true and STATE_LEADER on the single-node PD without disclosing an address, and that the three gauges are exported with the expected values.
  • test-start-hugegraph-pd.sh waits for /v1/ready to return 200 with ready=true after the health endpoint responds.
  • Locally on JDK 11: unit tests 10/10, pd-rest-test 16/16 and test-start-hugegraph-pd.sh 13/13 against a source-built PD. Against a live PD, /v1/ready answers 200 {"ready":true,...} as leader and 503 {"ready":false,...} with two unreachable peers, while /v1/health stays 200 throughout and the gauges move 1/1/1 to 0/0/NaN.

Does this PR potentially affect the following parts?

  • Nope
  • Dependencies (add/update license info)
  • Modify configurations
  • The public API
  • Other affects (typed here)

Notes for reviewers:

  • Kubernetes users should keep liveness probes on /v1/health and point readiness probes at /v1/ready. Using /v1/ready as a liveness probe would restart a PD that merely lost its leader.
  • The Store's own /v1/health is unchanged; this PR only covers PD.
  • A probe on /v1/ready must match the body, not just the status code. RestAuthentication.preHandle rejects by writing an error envelope without calling setStatus, so any non-excluded path answers 200 with {"status":-1,"error":"Unauthorized!"}. Fixing that root cause is out of scope here.

Documentation Status

  • Doc - Updated

/v1/health answers 200 as soon as the Spring listener is up and never
consults the raft state, so a PD that has lost its leader keeps reporting
healthy to every consumer that gates on it (compose healthchecks, the
Store's wait for PD, Kubernetes probes, wait-storage.sh).

Keep /v1/health as pure liveness and add an unauthenticated /v1/ready
that answers 200 only while the raft node is active and sees a leader,
and 503 otherwise. A follower drops its leader id once heartbeats stop
inside the election timeout and a leader steps down when it cannot reach
a quorum, so "sees a leader" is the local view of being inside a quorum.

Export three gauges next to hg_up so operators can alert on quorum loss:
hg_raft_leader (1 on the leader), hg_raft_has_leader (1 while a leader
is known) and hg_raft_alive_peers (peers the leader heard from inside
the election timeout, NaN on non-leaders).

Point the compose PD healthchecks at /v1/ready so Stores are no longer
released against a leaderless PD, and document both endpoints. The PD
startup CI test now also waits for /v1/ready on the live single-node PD,
and the REST suite checks the endpoint and the gauges against it.

Fixes apache#3183
@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 37.76%. Comparing base (98477f0) to head (2df9a55).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
.../pd/rest/interceptor/AuthenticationConfigurer.java 0.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master    #3185      +/-   ##
============================================
- Coverage     37.78%   37.76%   -0.03%     
- Complexity     6556     6557       +1     
============================================
  Files           800      800              
  Lines         68929    68960      +31     
  Branches       9157     9166       +9     
============================================
- Hits          26046    26043       -3     
- Misses        39824    39850      +26     
- Partials       3059     3067       +8     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@bitflicker64 bitflicker64 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: no. Summary: The liveness and readiness split is the right fix for #3183, the jraft assumptions behind it hold, and the new unit tests pass locally. Four minor notes: one on field visibility, two on comment and doc accuracy, one on image-version compatibility for the compose healthcheck change. Evidence: ran mvn -o -pl hugegraph-pd/hg-pd-test -am -Dtest=RaftEngineReadinessTest test on JDK 11 (9/9 pass) after building hg-pd-core and hg-pd-service; checked jraft 1.3.13 directly, where State.isActive() is ordinal() < STATE_ERROR, NodeImpl.listAlivePeers() throws IllegalStateException off-leader under a read lock, and getLeaderId() already maps an empty peer to null; confirmed MetricsConfig.metricsCommonTags adds hg="pd", so the hg_raft_*{ assertions in RestApiTest will match the Prometheus rendering. CI at 4dd7e71 was still running, with the pd, store and hstore integration jobs incomplete, so the live-PD assertions are unverified here.

Comment thread hugegraph-pd/docs/api-reference.md Outdated
Comment thread docker/README.md Outdated
Make RaftEngine.raftNode volatile so /v1/ready and the hg_raft_* gauges,
which read it from request and scrape threads, do not rely on the
@PostConstruct ordering for safe publication, and let isReady() reuse
the node it already snapshotted instead of re-reading the field.

Drop the wait-storage.sh mention from the /v1/ready javadoc: that script
polls /v1/stores and Stores register over gRPC, so the compose
healthcheck and Kubernetes probes are the real consumers.

Move the raft gauge table below the existing /actuator/metrics example
so the example still reads as that command's response, and note that
both quorum-loss expressions are briefly true during a normal election
and need a for: clause longer than the election timeout.

State in the docker README that /v1/ready first ships in 1.8.0, since an
older HUGEGRAPH_VERSION would leave the PD healthcheck failing and the
Stores never starting, and drop a doubled blank line.
bitflicker64 added a commit to bitflicker64/hugegraph that referenced this pull request Sep 2, 2026
…ment health vs ready

PD's /v1/health answers 200 as soon as the REST listener is up and never
consults raft, so every PD and Store probe and the Store init container's
PD wait count listeners, not quorum members (apache#3183). The
fix, apache#3185, adds /v1/ready from 1.8.0.

- pd.readinessPath and store.waitPath, both defaulting to /v1/health, so the
  switch to /v1/ready is a values change made with the 1.8.0 pin; the
  schema rejects paths without a leading slash
- README: Limitations entries for the liveness-only health endpoint and for
  the 45 second discovery lease (measured 30 to 35 seconds); the Store wait
  is described as a PD wait rather than a quorum wait; the Server now
  registers its Pod IP, not the Service URL
- NOTES and the init container messages no longer claim a quorum
- tests: pd_readiness_path_test.yaml, five cases
bitflicker64 added a commit to bitflicker64/hugegraph that referenced this pull request Sep 2, 2026
Points at apache#3185 and says the defaults flip with the 1.8.0
image pin, so the change is not lost once that PR merges.

@bitflicker64 bitflicker64 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: no. Summary: The endpoint, the raft accessors and the gauges are correct and well covered, and the jraft assumptions behind them hold. Two things to fix before merge: the paragraph added at docker/README.md:210-212 states a failure mode that this PR's own CI disproves, and the compose healthchecks it describes do not actually gate on readiness, because PD answers an unauthenticated request with HTTP 200 and an error body. The pre-PR /v1/health probe had the same property, so this is a missed improvement rather than a regression. Evidence: RestAuthentication.preHandle:61-66 writes the error body and returns false without response.setStatus(...); in CI run 33642694186, job build-server (rocksdb, 11), the hstore smoke pulled Docker Hub hugegraph/pd:latest (git grep '/v1/ready' origin/master -- hugegraph-pd is empty) and logged Container ...-pd-1 Healthy 11 seconds after start; PDCoreSuiteTest (101 run, 2 skipped) and PDRestSuiteTest (16 run) pass at 5bd1b96.

Comment thread docker/docker-compose-hstore.yml Outdated
Comment thread docker/README.md Outdated
PD's auth interceptor rejects a request by writing an error envelope
without setting a status, so every path it does not exclude, including a
path that does not exist, answers 200. A healthcheck that only inspects
the status code therefore reads a PD too old to carry /v1/ready as ready,
which is the same "healthy without a quorum" shape this PR set out to
fix. The compose files run published images, so revert their PD
healthchecks and the manual verification calls to /v1/health and document
what switching them over needs: a body match on "ready":true, and an
image that carries the endpoint.

Build the /v1/ready body from one RaftEngine.getRaftStatus() snapshot,
taken from a single Node reference and a single getLeaderId() read, so a
step-down midway cannot report a ready node that knows no leader.

Drop the leader's raft address from the body. The endpoint is
unauthenticated and the address was the one new disclosure; leadership
itself is already published by the hg_raft_leader gauge, and the address
stays on the authenticated /v1/members.

Call the window in the hg_raft_alive_peers description what jraft
measures, the leader lease timeout, which it derives as 90% of the
election timeout by default, rather than the election timeout.

Assert the empty body in testHealthNeedsNoAuth, since a 200 alone cannot
tell an anonymous path from a rejected one.

@imbajin imbajin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: no. Summary: The readiness implementation is correct, but the deployment guide overstates which compose probe is active. Evidence: JDK 11 RaftEngineReadinessTest passed 10/10; the current compose files still use /v1/health, and the Codecov patch failure is non-blocking.

Comment thread hugegraph-store/docs/deployment-guide.md Outdated
The startup ordering list said PD healthchecks probe /v1/ready, but
c8adc85 put both compose files back on /v1/health and this line was
missed, so the guide described a quorum gate that does not exist.

Name /v1/health, say it is liveness only, and point at docker/README.md
for what pointing the healthchecks at /v1/ready would require.

@bitflicker64 bitflicker64 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: no. Summary: The liveness and readiness split is the right fix for #3183, and at this head the endpoint, the raft accessors and the three gauges all check out; three minor notes remain, all about wording rather than behaviour. Evidence: jraft-core 1.3.13 State.isActive() is ordinal() < STATE_ERROR.ordinal() over LEADER, TRANSFERRING, CANDIDATE, FOLLOWER, ERROR, ..., so a candidate counts as active; NodeImpl.getAliveNodes compares against leaderLeaseTimeoutMs and calls no checkReplicator, so hg_raft_alive_peers is side-effect free on every scrape; hugegraph-pd/hg-pd-dist/src/assembly/static/conf/application.yml:61 has a single-peer peers-list, so the new wait_for_pd_ready gate in test-start-hugegraph-pd.sh is reachable on a one-node dist; PDService redirects non-leader gRPC calls to the leader (putLicense at PDService.java:1376 is the one exception), so a follower that sees a leader really can serve. CI on ffa13f9 is green except codecov/patch.

Comment thread docker/README.md
Comment thread hugegraph-pd/docs/api-reference.md Outdated
State.isActive() is ordinal() < STATE_ERROR over LEADER, TRANSFERRING,
CANDIDATE, FOLLOWER, so a candidate is active too and the isReady()
javadoc was a state short. Name the set jraft actually uses.

Say what the candidate test exercises. jraft clears the leader id before
starting an election, so testCandidateWithoutLeaderIsNotReady passes on
the missing leader rather than on the state, and a second case records
that a candidate does count as active. Mark the empty-peer test as
guarding the Node contract, since NodeImpl maps an empty peer to null.

Date the interceptor behaviour instead of asserting it as a property of
PD. As of 1.7.0 a refusal carries 200 and an error envelope, which is
what makes a status-only probe read an older PD as ready, but the body
match holds whichever status a refusal carries. Same wording in the
docker README and in testHealthNeedsNoAuth.

Note that the HEALTHCHECK baked into hugegraph-pd/Dockerfile is on
liveness as well. Both compose files override it, so it governs docker
run and anything else inheriting the image probe.
RaftEngine.isReady() had no caller outside its own tests: the endpoint
reads getRaftStatus() and the gauges read isLeader() and hasLeader().
Remove it and keep its note on the active-state set where isActive() is
actually called.

testStatusNeverReportsReadyWithoutALeader only repeated the two follower
shapes the tests either side of it already cover, so drop it and let the
rest assert through the snapshot, which is the path production takes.

Reduce wait_for_pd_ready to the gate the docs recommend, curl -f piped
into grep. -f rejects the 503 and the body match rejects a 200 that is an
auth envelope, so the hand-rolled status parsing bought nothing.
The exclusion list was the one line this change left uncovered, and it
carries a contract worth holding: if /v1/ready slips back behind the
interceptor, PD answers a probe with 200 and an auth envelope instead of
a readiness answer, so every healthcheck matching on the body holds
forever while the status still looks healthy.

Drive AuthenticationConfigurer with a real InterceptorRegistry and assert
through MappedInterceptor.matches(), so the test states the behaviour,
that these paths are not intercepted, rather than the literal patterns.
/v1/members and friends stay intercepted in the same test.

Verified by mutation: dropping /v1/ready from the list fails
testProbeEndpointsAreAnonymous.
The pd job runs mvn clean package between the core tests and the codecov
upload, which wipes the exec file the core run appended to, so only the
client and rest profiles reach the report. Move the check to
PDRestSuiteTest, where it also sits closer to the REST layer it covers.
This reverts commit 27f7009. Its reason was wrong: I read the pd job
from a stale checkout, where mvn clean package sat between the core
tests and the upload. On this branch Package runs first, then the four
test profiles append to one exec, and the aggregate report is generated
after the rest test, so core-test coverage reaches Codecov either way.

With that settled the core suite is the better home. The check is a pure
unit test, and the rest profile needs a live PD for the rest of its
suite.

@bitflicker64 bitflicker64 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: no. Summary: The readiness split is well-reasoned and the jraft usage checks out against the pinned jraft-core 1.3.13 (getNodeState()/State.isActive()/listAlivePeers() all behave as the javadoc claims, and getAliveNodes() really does include the node itself); three minor polish items only, none of which should hold up merge. Evidence: git diff 98477f0f5 refs/remotes/pr/3185 for the exact-head diff; javap on com.alipay.sofa.jraft.Node and com.alipay.sofa.jraft.core.State plus NodeImpl.java from the 1.3.13 sources jar (listAlivePeers() throws IllegalStateException off-leader at L2977, getLeaderId() already maps an empty peer to null at L2487, getAliveNodes() adds serverId at L2266); git show 98477f0f5:.../RestAuthentication.java confirms preHandle writes the error envelope without setStatus, so the docs' "match the body, not the status" guidance is correct; MetricsConfig.metricsCommonTags() registers commonTags("hg", "pd"), so the new gauges render with the {...} block the RestApiTest assertions expect; callers of isLeader()/getLeader() audited repo-wide for the new null-safety and none regress. Not verified: no build or test run against this head, and the effective Spring version comes from a parent BOM, so trailing-slash interceptor matching on /v1/ready/ was left out of these comments.

Comment thread hugegraph-server/hugegraph-dist/src/assembly/travis/test-start-hugegraph-pd.sh Outdated

@imbajin imbajin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: yes. Summary: The new interceptor test does not compile at this head, so the PD test and server build jobs cannot pass. Evidence: GitHub Actions run 33839755062 (pd and hstore) and run 33839755249 (both macOS server jobs) report cannot find symbol for AuthenticationConfigurer and RestAuthentication at AuthenticationConfigurerTest.java:53-54; both classes are defined in hg-pd-service.

hg-pd-service is repackaged by spring-boot-maven-plugin, so the artifact
a full mvn install leaves behind is an executable jar with classes under
BOOT-INF/classes, invisible to javac. A mvn test -am reactor compiles
hg-pd-test against target/classes instead, which is why the test passed
in the pd job and locally while build-commons and both macOS server jobs
failed with cannot-find-symbol.

Moving the test into hg-pd-service would not help: every CI package step
runs -Dmaven.test.skip=true and the pd profiles only execute suites in
hg-pd-test, so it would never run. The anonymity of /v1/health and
/v1/ready stays pinned by the live REST tests, which asserted it before
the unit test existed.
Derive the snapshot's leader flag from the state read one line above
instead of a third locked node call. jraft's isLeader(true) is exactly
state == STATE_LEADER, so the value is unchanged while the fields can no
longer contradict each other, which is what the javadoc promised.

Name the gauges hg.raft.has.leader and hg.raft.alive.peers. Micrometer
renders both to the same Prometheus names as before, but dot-separated
segments keep other registries from mixing separators.

Capture the readiness body in wait_for_pd_ready instead of piping into
grep -q: under the script's pipefail a SIGPIPE-killed curl could misread
a ready PD, and wait_for_pd already uses the capture shape.
bitflicker64 added a commit to hugegraph/hugegraph that referenced this pull request Sep 5, 2026
PD gains the quorum-aware /v1/ready endpoint, which answers 503 without a
raft leader, and the hg_raft_* gauges. The endpoint sits outside the auth
interceptor. The pull request is open against master; this branch carries it
so the helm-dev images can be tested with pd.readinessPath and
store.waitPath set to /v1/ready (apache#3183).
bitflicker64 added a commit to hugegraph/hugegraph that referenced this pull request Sep 5, 2026
PD REST now checks the password against auth.secret-key and answers 401 on
refusal (apache#3188). The PD image requires HG_PD_AUTH_SECRET_KEY,
wait-storage.sh sends PD_AUTH_PASSWORD, and Hubble reads
operations.pd.password.

Two conflicts with the pull requests merged before it, both resolved as the
union: the interceptor exclusion list keeps /v1/ready from apache#3185 alongside
the /actuator/** widening from apache#3189, and test-compose.sh runs the startup
timeout asserts from apache#3187 followed by the Hubble helper check from apache#3189.
render_with_timeout from apache#3187 additionally passes HG_PD_AUTH_SECRET_KEY,
which the Compose files require since apache#3189; without it the render step
would fail on the two HStore topologies.

The chart does not yet supply the PD secret, so PD Pods from an image built
at this revision will not start under the chart until that wiring lands.
@bitflicker64
bitflicker64 requested a review from imbajin September 5, 2026 06:57
bitflicker64 added a commit to bitflicker64/hugegraph that referenced this pull request Sep 5, 2026
PD images from 1.8.0 (apache#3189) check the Basic-auth password
of every management call against auth.secret-key and refuse to start without
one. The chart now keeps that value in a kept release-pd-auth Secret, or in
pd.auth.existingSecret, and hands it to the three readers: PD as
HG_PD_AUTH_SECRET_KEY, the Server storage wait as PD_AUTH_PASSWORD, and
Hubble as operations.pd.password written into its properties file by the
existing wrapper. A checksum/pd-auth annotation on the three Pod templates
rolls them when the Secret changes; the Server annotations block is now
rendered unconditionally for it. Priority and lookup semantics mirror
server.auth.token. Older images ignore the password, so the wiring is
harmless on the images the draft currently tracks.

The values schema requires one of existingSecret, value or autoGenerate and
refuses newlines, carriage returns and backslashes in an inline value, since
it lands in a Java properties file; the template guard repeats the first
rule for values that bypass the schema. The three chart-managed variables
join the reserved extraEnv lists.

README: Chart Details bullet, four parameter rows, Disaster Recovery calls
carry the secret, and the Limitations bullet separates the 1.7.0 behaviour
from 1.8.0. NOTES prints how to read the secret. New suite
pd_auth_secret_test.yaml, 9 tests; 58 in total. Lint on three presets;
renders 16 objects by default and 19 with Hubble.

Measured on a kind cluster with images built from master plus apache#3185, apache#3187
and apache#3189: the Secret is created, PD starts with the variable, the Server
storage wait passes with the credential, and Hubble lists all nine nodes.
@bitflicker64

Copy link
Copy Markdown
Contributor Author

Tested end to end on Kubernetes on 2026-09-05, with this branch merged into the hugegraph/hugegraph testing tree.

Build under test. Tag helm-dev-20260905, commit 6ec19838: apache master 36811483 plus this PR at 70744406, #3187 and #3189. PD, Store and Server images built from that tree with docker buildx bake -f docker/bake.hcl pd store server-hstore (linux/amd64), labelled org.opencontainers.image.revision=6ec1983889f58a52995b27a4e1ea89bd00932396, and the label read back from every running pod. kind v0.33.0, Kubernetes 1.37.0, 1 control-plane + 3 workers, the #3132 Helm chart with pd.readinessPath=/v1/ready and store.waitPath=/v1/ready, 3 PD + 3 Store + 3 Server + Hubble.

What held. Deleting two of three PDs with kubectl delete pod, the survivor sampled every 2 s through a port-forward, two runs:

  • /v1/ready on the survivor: 503 with hg_raft_has_leader 0 2.6 s after the delete, back to 200 with hg_raft_has_leader 1 once the replacements rejoined, 12 to 20 s later.
  • /v1/health on the same pod: 200 on all 313 samples of run one and all 90 of run two, so liveness and readiness now disagree exactly when they should.
  • A Store deleted inside the window sat in its wait-for-pd init container (Waiting for 2 PD peers to answer /v1/ready) from T0+7 s and passed at T0+23 s, the same second the first 200 came back. Schema writes through the Server hung inside the window and returned 202 after it.
  • The gauges behaved as documented: hg_raft_alive_peers read 3.0 while the sampled node was leader and NaN once leadership moved to a replacement.

One thing worth a look before merge: the handler stalls during the election. With curl -m 10 -w '%{time_total}', the first /v1/ready request after the delete took 9.79 s to return its 503; every 200 before and after answered in under 5 ms. In the first run, with a 2 s client timeout, every sample inside the leaderless window except the first came back as a timeout rather than a 503. A kubelet probe with timeoutSeconds: 5 fails either way, so the readiness contract is met, but the endpoint promises a prompt 503, not a stall. Reading the path: StoreAPI.checkReady calls RaftEngine.getRaftStatus, which reads node.getNodeState() and node.getLeaderId(), both under NodeImpl's lock; while the node runs its election it holds the write lock and opens connections to two peers that no longer exist. I have not profiled it, so treat the lock as the lead and the 9.79 s as the measurement. One way out is to keep a volatile copy of the last known state and leader, updated from the state machine callbacks (onLeaderStart, onLeaderStop, onStartFollowing, onStopFollowing), and serve the probe from that, so it never waits on the node lock.

The sample logs and scripts are kept with the campaign notes; I can attach them here if useful.

@bitflicker64

Copy link
Copy Markdown
Contributor Author

Logs and scripts from the two runs above, hosted on my fork (branch logs/pr-3185, index):

  • d3q-samples.log and d3q-b-samples.log: one line per 2 s on the surviving PD: epoch, /v1/ready, /v1/health, the hg_raft_* gauges, the Pod Ready condition. Run 2 appends time_total to the ready column as code/seconds.
  • d3q.log and d3q-b.log: script output (pre-fault uids and IPs, T0, the Store gate, post-recovery checks, blocked-line counts).
  • d3q.sh and d3q-b.sh: the scenario scripts, oracle declared before the fault.

The stall, from run 2 (ready=<code>/<seconds>):

1788593443.186162738 ready=200/0.003056 health=200 hg_raft_has_leader=1.0 podReady=True
1788593445.237568308 ready=200/0.002668 health=200 hg_raft_has_leader=1.0 podReady=True
1788593447.290482850 ready=200/0.003203 health=200 hg_raft_has_leader=1.0 podReady=True
1788593449.335970063 ready=503/9.792368 health=200 hg_raft_has_leader=1.0 podReady=True
1788593461.199044573 ready=200/0.002740 health=200 hg_raft_has_leader=1.0 podReady=True
1788593463.250381323 ready=200/0.002739 health=200 hg_raft_has_leader=1.0 podReady=True

T0 was 1788593447, the third line's timestamp. The fourth line's request went out at about T0+2.3 s and got its 503 at about T0+12.1 s, 9.79 s later; the gauge on that same line was read after the stall, once the replacements were back, which is why it already shows has_leader 1.0. The fifth line answered 200 in 2.7 ms. Run 1 shows the same window as ready=000 (2 s timeouts) from T0+4.6 s to T0+22.8 s after one prompt 503 at T0+2.6 s with has_leader 0.0.

Tested on Kubernetes against three PDs, the first /v1/ready request
after two pods were deleted took 9.79 s to return its 503, and with a
2 s client timeout every later sample inside the leaderless window timed
out: while jraft runs an election it holds the node lock as it
reconnects to peers that no longer answer, and the probe read
getNodeState() and getLeaderId() under that lock.

Keep a volatile copy of the last announced state and leader visibility
in RaftStateMachine, written by onLeaderStart, onLeaderStop,
onStartFollowing, onStopFollowing, onError and onShutdown, and serve
getRaftStatus(), hasLeader() and the gauges from it. The probe path no
longer touches the node at all, which the unit tests now pin with
verifyNoInteractions; getAlivePeerCount() checks the lock-free term flag
first and only calls listAlivePeers() on a settled leader.

jraft emits no callback for candidacy or leadership transfer, so state
reports the last announced role and a candidate reads as a follower
without a leader, the same not-ready answer as before.

Measured with two blackholed peers so every reconnect hangs, the shape
of the Kubernetes fault: 15 consecutive /v1/ready samples during the
perpetual election all answered 503 in under 21 ms, and a prometheus
scrape took 51 ms.
@bitflicker64

Copy link
Copy Markdown
Contributor Author

Fixed in a3b9395, taking the callback approach from the end-to-end report above. RaftStateMachine keeps a volatile copy of the last announced state and leader visibility, written by onLeaderStart, onLeaderStop, onStartFollowing, onStopFollowing, onError and onShutdown, and getRaftStatus(), hasLeader() and all three gauges read only that copy. The probe path no longer touches the raft node at all; the unit tests pin that with verifyNoInteractions on the node mock.

Reproduced the stall shape locally before pushing: a PD electing against two blackholed peers, so every reconnect hangs the way connects to deleted pods do. 15 consecutive /v1/ready samples during the perpetual election all answered 503 in under 21 ms (the K8s run measured 9.79 s on the same path), a /actuator/prometheus scrape took 51 ms, and the healthy single-node answer stays at about 2 ms.

Two behaviour notes. state is now the last role raft announced, so a candidate reports STATE_FOLLOWER with "ready": false (jraft has no candidacy callback), and a node that has never joined a quorum reports STATE_UNINITIALIZED; the docs say so. And hg_raft_leader now reflects the term flag rather than isLeader(true), so it can lag a partitioned stale leader by up to the lease window, in exchange for scrapes that never block. Production callers of RaftEngine.isLeader() are untouched.

@bitflicker64 bitflicker64 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: no. Summary: The callback-served rewrite in a3b9395 is the right answer to the 9.79 s stalled 503, and its callback coverage checks out against the pinned jraft-core 1.3.13: every leader and follower transition PD's raft group can reach emits a callback, and the one place jraft emits none lands on the not-ready side. Two minor items: getRaftStatus() no longer takes the single consistent snapshot its javadoc promises, and STATE_UNINITIALIZED is undocumented although it is what a PD reports for the whole pre-quorum startup window. Evidence: git show a3b939525:<path> for every changed file at the exact head, against merge-base 98477f0; NodeImpl.java and FSMCallerImpl.java from the jraft-core 1.3.13 sources jar pinned at hg-pd-core/pom.xml:41 (onLeaderStart at NodeImpl L2340 and L2926, onLeaderStop from stepDown at L1281 and from transferLeadershipTo at L3246 just after it enters STATE_TRANSFERRING, resetLeaderId firing onStopFollowing at L1198 and onStartFollowing at L1203, and all four delivered asynchronously through the FSM disruptor at FSMCallerImpl L291-320); PDService.java:1706 is the one PD caller of transferLeadershipTo, in updatePdRaft, and it is covered by the onLeaderStop above; PDMetrics.java:49 gives PREFIX = "hg", so the new names render as the hg_raft_* the RestApiTest assertions expect; StoreAPI is @RequestMapping("/v1"), so the new AuthenticationConfigurer exclusion covers the handler; callers of isLeader() and getLeader() audited repo-wide, none relied on the removed NPE; gh pr checks 3185 is green at this head on pd, store, hstore and every build lane, with only codecov/patch red. Not verified: no local build or test run in this session.

Comment thread hugegraph-pd/docs/api-reference.md Outdated
getRaftStatus() read probeState and seesLeader as two volatile loads
while its javadoc said the fields cannot contradict each other, so a
reader landing between onLeaderStart's two stores could see ready:false
with state:STATE_LEADER, and the mirror case after onLeaderStop. Hold
both values in one immutable ProbeView, written once per callback on the
single FSM thread and read once by getRaftStatus() and hasLeader(), the
same single-snapshot shape 7074440 gave the node-based version. The
javadoc now also says the view trails the node by the FSM queue instead
of implying it is current.

Document STATE_UNINITIALIZED as what a PD reports until its first raft
callback, which is the ordinary startup window before a quorum first
forms.
The gauge is NaN on every node but the leader, and a single NaN sample
turns sum() or avg() into NaN, so an operator who graphs it across
instances gets nothing back. Note that next to the quorum-loss alerts
and give the leader-scoped query to use instead.
@bitflicker64

Copy link
Copy Markdown
Contributor Author

@imbajin Ready for a review pass when you have time. Pushed 2df9a55, five added lines in hugegraph-pd/docs/api-reference.md.

Why. hg_raft_alive_peers is backed by Node.listAlivePeers(), so it can only answer on the leader and returns Double.NaN elsewhere. Prometheus renders that as NaN, and NaN propagates through sum() and avg(), poisoning the aggregate. The doc gave no query form for that gauge at all, which is the gap that leads someone to sum() it, so 2df9a55 adds one. I left PDMetrics alone: NaN is the honest encoding for "this node cannot answer", where 0 would read as "no peers alive" and poison an aggregate more quietly.

On the two red codecov marks. codecov/patch reports one missing line, and it is the excludePathPatterns registration in AuthenticationConfigurer.addInterceptors. That is a Spring WebMvcConfigurer callback which only runs inside the live PD process, and CI starts that process separately. An in-JVM interceptor test was tried and reverted in f4fb4ea, for a different reason worth stating accurately: hg-pd-service is repackaged by spring-boot-maven-plugin, so a full mvn install leaves an executable jar with classes under BOOT-INF/classes where javac cannot see them, and build-commons plus both macOS server jobs failed with cannot-find-symbol. The behaviour that line configures is covered end to end by testReadyNeedsNoAuthAndReflectsRaft. codecov/project is a ratchet of a few hundredths of a point, not a coverage cliff.

What I checked on the branch. ProbeView publication across all six JRaft callbacks: onLeaderStart, onLeaderStop, onStartFollowing, onStopFollowing, onError, onShutdown. No ordering yields a torn read, since each announcement replaces the whole view.

onStopFollowing is the one place a view mixes fields from two announcements, via new ProbeView(this.probeView.state, false). The field that would actually hurt is isLeader: a carried STATE_LEADER would serve {"ready":false,"state":"STATE_LEADER","isLeader":true} and make hg_raft_leader read 1 on a non-leader. That is unreachable in the jraft 1.3.13 that hg-pd-core pins. onStopFollowing is only emitted from resetLeaderId under state.compareTo(STATE_TRANSFERRING) > 0 at NodeImpl.java:1197, which excludes both STATE_LEADER and STATE_TRANSFERRING; and although transferLeadershipTo sets the state to TRANSFERRING before firing onLeaderStop, stepDown announces onLeaderStop while the state is still LEADER, ahead of resetLeaderId. So the reachable carried states are FOLLOWER, ERROR, SHUTDOWN and UNINITIALIZED, all of which give ready=false and isLeader=false.

RaftEngineReadinessTest asserts on isReady() in seven methods, and testReadyNeedsNoAuthAndReflectsRaft asserts the body keys and the 200 path against a live PD.

Merge-order conflict with #3189, worth deciding before either lands. Both PRs rewrite the same line. This one makes it excludePathPatterns("/actuator/*", "/v1/health", "/v1/ready", "/v1/prom/targets/*"); #3189 makes it excludePathPatterns("/actuator/**", "/v1/health", "/v1/prom/targets/*"). Whichever lands second conflicts, and the two changes disagree about two independent things at once. The correct union is:

excludePathPatterns("/actuator/**", "/v1/health", "/v1/ready", "/v1/prom/targets/*")

Dropping /v1/ready puts the readiness endpoint back behind authentication and defeats this PR; keeping the single star re-breaks the doc lines #3189 corrects.

Two follow-ups I would not hold the merge for.

  1. The 200 versus 503 mapping in checkReady() has no direct test. An inverted ternary would fail CI today, so only an "always return 200" regression could ship undetected. Future test debt rather than a present defect, but it is the branch most worth pinning later, given what the endpoint gates.
  2. LEADER_STOP and STOP_FOLLOWING ride the same FSMCallerImpl disruptor as onApply and onSnapshotLoad, so a follower in a long onSnapshotLoad can keep answering 200 until the queue drains. The getRaftStatus javadoc discloses this; api-reference.md only hints at it, saying the state is the last change raft announced. Staleness is bounded, PD metadata snapshots are small, and a node mid-snapshot-load is not servable anyway, so I left it. Worth remembering if PD snapshots ever grow.

@bitflicker64 bitflicker64 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: no. Summary: The callback-served readiness path is sound and its jraft assumptions hold up, but getAlivePeerCount() still takes the raft node's read lock, so a metrics scrape can block on the very lock /v1/ready was restructured to avoid. Evidence: jraft-core 1.3.13 sources resolved for hg-pd-core (NodeImpl.listAlivePeers, NodeImpl.stepDown, NodeImpl.preVote, AbstractClientService.connect) read against the head diff.

* check is the state machine's lock-free term flag, so a scrape on a non-leader never
* waits on the node lock; {@code listAlivePeers} itself only runs on a settled leader.
*/
public int getAlivePeerCount() {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ The javadoc promises that listAlivePeers "only runs on a settled leader", but no guard can give this method the lock-free property the rest of the readiness path has: NodeImpl.listAlivePeers() (jraft-core 1.3.13, NodeImpl.java:2974-2984) takes readLock before it checks state and throws.

Two ways a scrape blocks there:

  • No FSM lag needed. stepDown runs under the caller's write lock (NodeImpl.java:1268-1289) and on a term bump writes raft metadata before releasing, so a scrape that passed the guard on a genuinely settled leader waits out the rest of it.
  • With FSM lag. stateMachine.isLeader() is leaderTerm.get() > 0, reset only in onLeaderStop, which jraft enqueues on the FSM disruptor, while stepDown sets state = STATE_FOLLOWER synchronously. Should that backlog outlive one election timeout, the scrape reaches preVote(), which holds the write lock across rpcService.connect(...) per peer (NodeImpl.java:2698); that call blocks for rpcConnectTimeoutMs, wired from raft.rpc-timeout, default 10000 ms (PDConfig.java:153).

That is the stall the getRaftStatus() javadoc gives as its reason to serve from callbacks, on the one path that still reads the node, during the fault these gauges exist to observe.

Requested change: drop the "settled leader" guarantee from the javadoc, and move the call off the request thread by refreshing the count into a volatile field that the gauge reads.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] PD /v1/health reports healthy without a raft quorum; add a quorum-aware readiness signal

2 participants