Skip to content

feat(server): add a storage-aware GET /readiness endpoint - #3221

Open
SebastianGruza wants to merge 7 commits into
apache:masterfrom
SebastianGruza:feat/server-readiness
Open

SebastianGruza wants to merge 7 commits into
apache:masterfrom
SebastianGruza:feat/server-readiness

Conversation

@SebastianGruza

@SebastianGruza SebastianGruza commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Purpose of the PR

Closes #3212. A Kubernetes readiness probe on /versions keeps a Server in the Service while every graph request fails, because /versions answers 200 as long as the REST layer is up, even with no Store in the cluster (measured under #3132: ready=true with 0 Stores for 150+ s while every GET /graph/vertices/<id> ended in a 500 after the 30 s request bound). Design discussed in the issue.

Main Changes

GET /readiness answers 200 while this Server can serve graph traffic and 503 otherwise, unauthenticated like /versions (whitelisted in AuthenticationFilter and PathFilter, so an httpGet probe needs neither a credential nor a graphspace prefix), with a JSON body that carries no addresses:

{"ready":true,"storage":"hstore","reason":"ok","active_stores":3,"answered_store":6386607161404282741,
 "pd_reachable":true,"pd_checked_age_ms":312,"stores_age_ms":312,"store_millis":3,"cached":false,
 "probes":[{"graph":"hugegraph","storage":"hstore","ready":true,"reason":"ok"}]}

Ready means, from this Server's own view: at least one Store from the last Store list PD answered with answers a direct, local, read-only gRPC call (HgStoreState.getScanState, a read of the node's own scan-pool stats that never touches raft). The Store list is refreshed from PD in the background on every probe and never waited for once a list is known, so PD being down, slow or restarting only flips the reported pd_reachable, never the readiness, as long as a Store answers. The pings run in parallel and the first answer wins, so a Store whose pod just left never eats the budget of the healthy ones.

Scope: ready means this Server knows a Store list and reaches at least one Store over gRPC; a Store whose status RPC answers while its raft or partition path is broken is not detected. A gate on a read through the graph path was measured in three variants (review round 4) and rejected: a failed probe read invalidates the partition cache the queries share, which took the data plane down during a PD outage, and one moving partition stalled every read past the budget, which took every Server out of the Service during a rolling Store restart while the traffic was fine (results/issue-3212/round4-* in the validation repo).

  • hugegraph-hstore: HstoreStorageProbe (pure logic over a Store lister and a Store pinger, one shared time budget through an executor so a hung PD or Store yields "did not answer within N ms" instead of a hung probe; KnownStores holds the last PD answer) and a storage_readiness meta handler in HstoreStore.
  • hugegraph-api: ReadinessAPI and StorageReadiness (every distinct remote backend configuration this Server uses is probed as the internal admin through HugeGraph.metadata(null, "storage_readiness", timeout): graphs sharing a configuration (same pd.peers, or same hbase.hosts and namespace) share one probe, independent clusters are probed side by side within the common budget, ready means all of them are ready and the body lists one probes entry per configuration; the result is cached for readiness.cache_ttl, one in-flight probe is shared by concurrent callers with a bounded wait, no monitor is held during I/O; Servers with no graph on a remote backend (hstore, hbase) answer 200 with storage=embedded); the api module gains no dependency.
  • ServerOptions: readiness.timeout (default 1000 ms), readiness.cache_ttl (default 2000 ms) and readiness.max_waiters (default 16: callers beyond it get an immediate 503 instead of waiting on the in-flight probe).
  • hugegraph-hbase: HbaseStore answers storage_readiness with one admin round trip within the budget (the graph's first table exists, is enabled and is available, so an existing but disabled table is not ready); the body's storage field names the probed backend.

Verifying these changes

  • Add new unit test(s): HstoreStorageProbeTest (18: first answer wins, a hung Store does not hide an answering one, known Stores keep the Server ready while PD is down, a hung PD does not delay a probe with known Stores, the PD answer updates the known list, channels of replaced Stores are shut down only after two consecutive listings, budget bounding, no addresses in the body), StorageReadinessTest (11, including the two filter whitelists, concurrent callers sharing one probe, bounded follower wait, every remote configuration probed with one failing or hung configuration) and HbaseReadinessTest (available, disabled, failing and hung table checks).
  • ReadinessApiTest in the API suite on the memory, rocksdb, hbase and hstore CI jobs.
  • E2E on a three-node k3s with the chart from feat(helm): add hstore-cluster k8s deployment chart #3218 / feat(helm): add HStore deployment chart hugegraph/hugegraph#221, 1 Hz sampling of /readiness, /versions, the pod Ready condition and the Service endpoints on every Server pod, load through the Service: 7 PASS (baseline; Stores→0 pulls every Server out of the Service in ~30 s and back in ~20 s; one Store deleted, no flap; rolling restart of the Stores under load, zero readiness transitions; PD→0 stays 200 with pd_reachable=false; rolling restart of PD, zero transitions; SIGSTOP the busiest Store, zero transitions). Scripts, per-scenario JSON and the two superseded probe designs (an existsTable ping flapped on a roll; probing PD on every request tracked PD instead of storage): https://github.com/SebastianGruza/hugegraph-validation/tree/master/results/issue-3212

Does this PR potentially affect the following parts?

  • Introduce new configurations: readiness.timeout, readiness.cache_ttl, readiness.max_waiters in ServerOptions
  • New unauthenticated endpoint GET /readiness; no data-format change.

Chart side

A companion change adds server.readinessPath (default /versions) to the #3132 chart, mirroring pd.readinessPath; set it to /readiness on an image that serves it. Diff and measurements are in the validation repo above and will go to hugegraph#221.

🤖 Generated with Claude Code

@codecov

codecov Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 52.49344% with 181 lines in your changes missing coverage. Please review.
✅ Project coverage is 41.63%. Comparing base (176fb56) to head (6cdd6ce).

Files with missing lines Patch % Lines
...apache/hugegraph/api/profile/StorageReadiness.java 45.34% 81 Missing and 7 partials ⚠️
...graph/backend/store/hstore/HstoreStorageProbe.java 54.08% 57 Missing and 16 partials ⚠️
...ache/hugegraph/backend/store/hbase/HbaseStore.java 51.51% 11 Missing and 5 partials ⚠️
...e/hugegraph/backend/store/hbase/HbaseSessions.java 50.00% 0 Missing and 2 partials ⚠️
...org/apache/hugegraph/api/profile/ReadinessAPI.java 91.66% 0 Missing and 1 partial ⚠️
...he/hugegraph/backend/store/hstore/HstoreStore.java 66.66% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master    #3221      +/-   ##
============================================
+ Coverage     41.57%   41.63%   +0.05%     
- Complexity     7311     7349      +38     
============================================
  Files           793      796       +3     
  Lines         69106    69486     +380     
  Branches       9258     9301      +43     
============================================
+ Hits          28730    28928     +198     
- Misses        37098    37253     +155     
- Partials       3278     3305      +27     

☔ 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

Choose a reason for hiding this comment

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

Blocking: no. Summary: the probe design (last known Store list, parallel pings, one shared budget) holds up and is well tested, but the unauthenticated endpoint's body can leak PD peer and Store host names through exception text, and a hung PD makes every cache-missed probe park another thread on an unbounded pool. Two doc and resource nits besides. Evidence: static read of the d0f4a25 diff against PDClient (getStub/newBlockingStub, PDConfig.grpcTimeOut=60000), HstoreSessionsImpl.initStoreNode and grpc-core 1.47 DnsNameResolver; CI green on this head apart from codecov, and HstoreStorageProbeTest (15) passes in the hstore job log.

return System.currentTimeMillis() - since;
}

private static String message(Throwable e) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ /readiness is unauthenticated and the PR says the body carries no addresses, but reason embeds raw exception messages. Two paths:

  • No Store list known yet and the PD client has no live stub (never connected, or reset by closeStub after a watch error) with peers refusing fast: await() unwraps the PDException from newBlockingStub(), whose message is "PD unreachable, pd.peers=" + config.getServerHost() (PDClient.java:142-143), so every PD peer is listed.
  • Every known Store fails and one failed name resolution: UNAVAILABLE: Unable to resolve host <host> (grpc-core 1.47 DnsNameResolver), if Stores register by DNS name.

StorageReadiness.check also appends e.getMessage(); testMapCarriesNoAddresses covers only the ready path.

Could reason use a fixed category (pd unreachable, the gRPC status code or exception class) and log the full message instead?

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.

Done in c9e70b1, thanks, both paths were real: with no Store list yet the PDException from newBlockingStub() carried the whole pd.peers, and with every Store down gRPC carried Unable to resolve host … (it was in my own Stores→0 samples, I had not connected the dots). reason now carries a fixed category only: the gRPC status code for StatusRuntimeException, pd unreachable for PDException, the class name otherwise (HstoreStorageProbe.category()); the full messages go to the log (WARN for PD, DEBUG for Store pings). StorageReadiness.check() likewise: class name only, message to the log. testReasonCarriesNoPdPeersNorStoreHosts feeds a PDException with pd.peers=pd-0.internal:8686,… and an UNAVAILABLE: Unable to resolve host store-0…svc and asserts neither map contains internal, svc or 8686; StorageReadinessTest checks the same for a probe exception. On k3s with this build the reasons read none of 3 known store(s) answered: a store failed: UNAVAILABLE; a store failed: UNAVAILABLE; a store failed: UNAVAILABLE, and a grep of every sampled body from stores-zero and pd-zero for .svc, 8686 and hugegraph-store- finds 0 occurrences.

// Refresh the store list from PD in the background; whatever PD
// answers lands in `known` for this or the next probe
CompletableFuture<List<Metapb.Store>> refresh = new CompletableFuture<>();
executor.execute(() -> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Every cache-missed probe submits a new PD refresh here, even while the previous one is still running, and EXECUTOR is an unbounded cached pool. getActiveStores() is bounded only by the PD client deadline, 60 s by default (PDConfig.grpcTimeOut; HstoreSessionsImpl.initStoreNode does not override it).

If PD hangs instead of refusing connections, the probe still returns in milliseconds from the known Stores, so each probe parks one more thread on PD: up to about 30 at the default 2 s TTL, and one per request with readiness.cache_ttl=0, which the option allows, on an endpoint anyone can call. Without a live stub they also queue on the synchronized PDClient.newBlockingStub() alongside graph traffic.

Could the refresh be single-flight, e.g. keep the in-flight CompletableFuture and skip submitting until it completes?

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.

Done in c9e70b1. The refresh is single-flight: KnownStores keeps an AtomicReference to the in-flight CompletableFuture, refresh() returns the running one while it is not isDone() and only starts a new one after it finished (CAS on the reference, so two racing probes also share one). A hung PD therefore parks one thread per process regardless of readiness.cache_ttl, including 0. testRefreshIsSingleFlight: a lister sleeping 3 s, five probes in a row → one call, all five ready from the known list; after it completes the next probe starts the second. The queueing on the synchronized newBlockingStub() next to graph traffic is thereby bounded to that one thread as well.


/**
* Storage-aware readiness for Kubernetes and load balancers: 200 while this
* server can serve graph traffic, 503 while PD or every Store is unreachable

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 This Javadoc says 503 while PD is unreachable. So do the StorageReadiness Javadoc ("PD answers and one active Store answers"), the HstoreStorageProbe class Javadoc ("a hung PD or Store turns into not ready") and the readiness.timeout description. The probe deliberately stays ready with PD down once a Store list is known (testKnownStoresKeepTheServerReadyWhilePdIsDown). Both option descriptions also say one Store call per probe, while it pings every known Store in parallel. Please reword these to match: PD only matters until the first Store list is known, and the Store cost is one call per known Store.

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.

Done in c9e70b1. The Javadoc of ReadinessAPI, StorageReadiness and HstoreStorageProbe and the descriptions of readiness.timeout and readiness.cache_ttl now say what the code does: PD only matters until the first Store list is known, afterwards it is refreshed in the background; the cost of a probe is one cheap call to every known Store in parallel, first answer wins; 503 only when none answers within the budget (or, before the first list, when PD does not answer).

}

private static void pingScanState(Metapb.Store store, long timeoutMs) {
ManagedChannel channel = CHANNELS.computeIfAbsent(store.getAddress(), address -> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 CHANNELS keeps one plaintext channel per Store address and nothing ever removes or shuts one down. When a Store is replaced or comes back under a different address, the old channel stays allocated for the life of the process. Could channels whose address is no longer in the latest PD list be shut down after a successful refresh in probe?

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.

Done in c9e70b1. After every successful listing from PD, pruneChannels() shuts down (shutdownNow) and removes the channels of addresses no longer listed; a failed listing touches nothing. Wired into the lister lambda of probe(graphName, timeoutMs), so the pure probe(...) stays stateless. testChannelsOfReplacedStoresAreShutDown uses a small ManagedChannel subclass (the hstore module has no Mockito): the channel of a listed address stays open, the replaced one is shut down and removed, a null listing removes nothing.

@bitflicker64 bitflicker64 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking: no. Summary: the design is measured, justified and tested — the simplifications left are local: the probe hand-rolls a ThreadFactory that commons-lang3's BasicThreadFactory (the repo's own ExecutorUtil pattern) does in one expression, carries a graphName parameter nothing reads, and wraps its result in a 55-line Result holder whose only production consumer is toMap(). Evidence: reviewed the full diff at head d0f4a25 (10 files, +965/-0) with the head checked out; grep -n graphName HstoreStorageProbe.java shows the parameter declared at line 196 and never read; grep -rn HstoreStorageProbe.Result outside the file matches only the test; grep -rn BasicThreadFactory shows four uses in hugegraph-common's ExecutorUtil and commons-lang3 already imported in the hstore module. The endpoint itself, KnownStores, the background PD refresh, the first-answer-wins loop and the api/hstore split via the meta handler are all justified in the PR description (two superseded designs measured, seven E2E scenarios) and are not raised.


public static final String META_STORAGE_READINESS = "storage_readiness";

private static final ExecutorService EXECUTOR = Executors.newCachedThreadPool(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ This ten-line anonymous ThreadFactory with its own AtomicInteger is the thing BasicThreadFactory.Builder exists for — and this repo already uses it exactly this way, four times, in hugegraph-common's ExecutorUtil. commons-lang3 is already imported elsewhere in this module (HstoreSessionsImpl, HstoreTable), so no new dependency.

Requested change:

private static final ExecutorService EXECUTOR = Executors.newCachedThreadPool(
        new BasicThreadFactory.Builder().namingPattern("storage-readiness-%d")
                                        .daemon(true).build());

Same names, same daemon flag, eight lines and one import (java.util.concurrent.ThreadFactory + AtomicInteger) gone.

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.

Done in 213fd9c, exactly as proposed: new BasicThreadFactory.Builder().namingPattern("storage-readiness-%d").daemon(true).build(), like ExecutorUtil. The anonymous factory, the AtomicInteger and two imports are gone; same thread names, same daemon flag.

* @param graphName the store-side graph name, kept for the meta handler
* @param timeoutMs the whole budget for PD plus stores
*/
public static Map<String, Object> probe(String graphName, long timeoutMs) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 graphName is never read in this method — the javadoc even says "kept for the meta handler", which is scaffolding for a use that doesn't exist yet. The call site in HstoreStore builds this.namespace + "/" + this.store just to feed it.

Requested change: drop the parameter here and the concat at the call site. Re-add it the day something reads it — pd.getActiveStores(graphName) exists if a per-graph store list is ever wanted.

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.

Done in 213fd9c. probe(long timeoutMs) without the parameter, and the namespace + "/" + store concat in HstoreStore is gone with it. If a per-graph store list is ever wanted, pd.getActiveStores(graphName) comes back together with the parameter.

}
}

public static final class Result {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 The only production consumer of Result is probe(String, long) calling .toMap() on it immediately; outside this file the class appears only in HstoreStorageProbeTest. So these ~55 lines — eight final fields, an eight-positional-arg constructor (new Result(false, "...", 0, null, false, -1L, -1L, 0L) is hard to read at the four construction sites), five getters — exist to make test assertions prettier.

Requested change: have probe(...) build the LinkedHashMap directly (one small private result(boolean ready, String reason, ...) helper covers the four return points) and let the tests assert on map entries, the way testMapCarriesNoAddresses already does. Deletes the class and the .toMap() hops.

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.

Done in 213fd9c. Result is gone: probe(...) returns the Map<String, Object> built by one private result(ready, reason, activeStores, answeredStore, pdReachable, pdAgeMs, storesAgeMs, storeMillis) helper at the four return points, and StorageReadiness.check() gets the map directly (no more toMap() hops). The tests assert on map entries the way testMapCarriesNoAddresses already did; 18/18.

bitflicker64 added a commit to hugegraph/hugegraph that referenced this pull request Sep 19, 2026
Add server.readinessPath (default /versions, so nothing changes on
current images), mirroring pd.readinessPath: values.yaml, the schema
(pattern ^/) and the Server readinessProbe in server-deployment.yaml,
plus a README parameter row and a two-case unit suite (74 total).

On a Server image that serves GET /readiness (apache#3212,
proposed in apache#3221), setting the value to /readiness
makes a Server that cannot serve graph traffic answer 503 and drop out
of the Service instead of returning 500 to every graph request.
Startup and liveness stay on /versions so a Server that merely lost
its storage is not restarted.

Implements the change proposed and measured by @SebastianGruza in
#229 (six fault scenarios at 1 Hz sampling, zero
readiness transitions across Store and PD rolling restarts).
SebastianGruza added a commit to SebastianGruza/hugegraph that referenced this pull request Sep 19, 2026
… PD refresh, prune dead Store channels

Review round 1 of apache#3221:
- the reason field carries a fixed category (gRPC status code, 'pd
  unreachable' or the exception class) instead of raw exception text, which
  could name PD peers and Store hosts on an unauthenticated endpoint; the
  full messages go to the log
- the background PD refresh is single-flight (KnownStores keeps the future
  in flight), so a hung PD parks one thread, not one per cache-missed probe
- channels of addresses PD no longer lists are shut down after a successful
  listing
- Javadoc and option descriptions say what the probe does: PD only until the
  first Store list is known, one call per known Store in parallel

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@SebastianGruza

Copy link
Copy Markdown
Contributor Author

Round 1 in c9e70b1: reason carries a category only (gRPC status code, pd unreachable, class name) with the full messages in the log; the PD refresh is single-flight; channels of replaced Stores are shut down after a successful listing; Javadoc and option descriptions match the probe. Tests 18 + 6. On k3s with this build stores-zero and pd-zero PASS, and the sampled bodies contain 0 occurrences of host names or the PD port; results in results/issue-3212/round1-3212g of the validation repo.

SebastianGruza added a commit to SebastianGruza/hugegraph that referenced this pull request Sep 19, 2026
…used graphName, map result)

Review round 2 of apache#3221: the executor uses commons-lang3's BasicThreadFactory
like ExecutorUtil does, the unread graphName parameter and its concat at
the HstoreStore call site are gone, and probe() builds the body map through
one private helper instead of a Result holder whose only consumer was
toMap(); the tests assert on map entries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@SebastianGruza

Copy link
Copy Markdown
Contributor Author

Round 2 in 213fd9c: BasicThreadFactory instead of the hand-rolled factory, the graphName parameter and its concat removed, the body built directly as a map through one helper instead of the Result holder; tests on map entries, 18/18; net -48 lines. Smoke on k3s with this build: the three Server pods answer 200 with the same body as before the refactor, no readiness errors in the log.

@bitflicker64 bitflicker64 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking: no. Summary: the earlier rounds are addressed at 213fd9c and the probe logic holds up. The main gap is that /readiness is not exempt from LoadDetectFilter the way /versions is, so a busy Server fails the probe; separately, an empty PD answer closes the Store channels the probe keeps pinging. Evidence: static read of the full diff at 213fd9c against LoadDetectFilter.WHITE_API_LIST, ServerOptions.MAX_WORKER_THREADS (default 2 * CPUS), KnownStores.update and pruneChannels; gh pr checks green on this head apart from the two codecov statuses.

private static final AntPathMatcher MATCHER = new AntPathMatcher();
private static final Set<String> FIXED_WHITE_API_SET = ImmutableSet.of(
"versions",
"readiness",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ readiness is added to the auth and path whitelists but not to LoadDetectFilter.WHITE_API_LIST ("", apis, metrics, versions). So /readiness goes through the worker-load and free-memory checks, before the result cache is reached, and gets a 503 once max_worker_threads - 1 other requests are in flight. restserver.max_worker_threads defaults to 2 * CPUS, so on a 2-CPU pod the probe fails while 3 other requests run.

Under sustained load Kubernetes then drops busy Servers from the Service and shifts their traffic to the rest, so a deployment that moves its readiness probe from /versions to /readiness can lose every endpoint during a spike while storage is fine.

Could readiness go into WHITE_API_LIST next to versions (LoadReleaseFilter reads the same list, so the counter stays balanced), with a case like testFilter_WhiteListPathIgnored? If shedding load through readiness is intended, please say so in the ReadinessAPI Javadoc.

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.

Done in 4a4aa1c, thanks, that would have been a nasty interaction: a readiness probe shed under load would pull exactly the busiest Servers out of the Service while the storage is healthy. readiness is in LoadDetectFilter.WHITE_API_LIST next to versions; LoadReleaseFilter reads the same list, so the workLoad counter stays balanced. testFilter_ReadinessIgnoredLikeVersions in LoadDetectFilterTest: with a 2-thread limit and one request in flight the filter lets /readiness through without touching the counter and without a log entry, in the same shape as testFilter_WhiteListPathIgnored. Readiness is not meant to shed load; the ReadinessAPI Javadoc says it answers from the storage state.

/** Shut down the channels of addresses PD no longer lists (replaced Stores). */
static void pruneChannels(Map<String, ManagedChannel> channels,
List<Metapb.Store> stores) {
if (stores == null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Follow-up to the channel pruning from round 1. An empty PD answer shuts down every channel here, while KnownStores.update ignores an empty answer and keeps the old list (testEmptyPdAnswerIsNotReadyAndKeepsTheOldList). As long as PD answers with no active Store, each refresh closes the channels to the Stores that the pings still use, and the next probe opens new ones. probe() starts the refresh and pings right away, so a ping in flight when shutdownNow() runs fails with UNAVAILABLE, and the old-list rule that keeps the Server ready can still turn a probe into a 503.

Could this return early on an empty list as well (if (stores == null || stores.isEmpty())), the same rule update applies, and could testChannelsOfReplacedStoresAreShutDown check that an empty listing keeps the channels?

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.

Done in 4a4aa1c. pruneChannels() returns on an empty list as well (stores == null || stores.isEmpty()), the same rule KnownStores.update() applies: as long as the pings use the last known list, their channels stay open. testChannelsOfReplacedStoresAreShutDown now also checks that an empty listing neither shuts down nor removes the channel of a known Store. 18/18.

SebastianGruza added a commit to SebastianGruza/hugegraph that referenced this pull request Sep 19, 2026
…an empty PD answer, add ReadinessApiTest

Review round 3 of apache#3221:
- readiness joins LoadDetectFilter.WHITE_API_LIST next to versions, so a
  busy server still answers its readiness probe from the storage state
  instead of shedding it (LoadReleaseFilter reads the same list); test
  case in LoadDetectFilterTest
- pruneChannels() ignores an empty PD answer, the same rule
  KnownStores.update() applies, so the channels the pings still use stay
  open while PD reports no active Store; the channel test covers it
- ReadinessApiTest drives GET /readiness through the API suite, with and
  without credentials, on every backend of the suite

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@SebastianGruza

Copy link
Copy Markdown
Contributor Author

Round 3 in 4a4aa1c: /readiness on the LoadDetectFilter whitelist (a busy Server still answers its probe from the storage state), an empty PD answer no longer closes the channels, plus ReadinessApiTest in the API suite (with and without credentials, on every backend of the suite) so the endpoint is measured by jacoco in the api-test job. Tests: LoadDetectFilterTest 11/11, HstoreStorageProbeTest 18/18, StorageReadinessTest 6/6, ReadinessApiTest 2/2 against a server built from this head on rocksdb.

@bitflicker64 bitflicker64 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking: no. Summary: both findings from the last round are fixed at 4a4aa1c. readiness is in LoadDetectFilter.WHITE_API_LIST, and LoadReleaseFilter skips it through the same isWhiteAPI, so the work-load counter stays balanced. pruneChannels now returns on an empty PD answer, the same rule KnownStores.update applies. The fixes from the first two rounds are still in place, and I found nothing new in the rest of the diff. Evidence: read the full diff at 4a4aa1c (14 files, +1204/-1) and the 213fd9c..4a4aa1c delta, plus the call sites in LoadReleaseFilter, GraphManager.graphs(), HugeGraphAuthProxy.runAsAdmin and HstoreStore.registerMetaHandlers. gh pr checks is green on this head, codecov included. The hstore job log shows HstoreStorageProbeTest 18/18, and its API suite grew from 155 to 157 tests (the two in ReadinessApiTest) with no failures.

@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 readiness signal can stay green while graph-serving operations are unavailable, and concurrent probes can wait beyond their configured timeout when caching is disabled. Evidence: static trace of HstoreStorageProbe.getScanState() and StorageReadiness.check().

});
HgStoreStateGrpc.newBlockingStub(channel)
.withDeadlineAfter(timeoutMs, TimeUnit.MILLISECONDS)
.getScanState(SubStateReq.getDefaultInstance());

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.

⚠️ A successful getScanState response is treated as proof that this Server can serve graph traffic, but this RPC only reads local scan-pool statistics and never checks Raft or graph-data access. A Store whose status RPC remains healthy while its Raft or partition path is unavailable will still make /readiness return 200, so Kubernetes can keep routing graph requests to an unusable instance. Please validate the graph-serving path, or use a Store health signal that covers it, before reporting ready.

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 diagnosis is right: getScanState touches neither raft nor the data. So I built the gate you suggest and measured it on the k3s cluster from the PR description (3 PD + 3 Store + 3 Server, the same seven scenarios, 1 Hz samples per Server pod, load through the Service), in three variants: a get of a sentinel key on the vertex table through the same Store client the queries use, (a) in one shared partition, (b) in a partition led by the Store that answered the ping (leader codes from PD's partition cache at each refresh), (c) in parallel, one partition per known Store, first success wins. Runs and samples: https://github.com/SebastianGruza/hugegraph-validation/tree/master/results/issue-3212 (round4-graphread-3212{j,k,l}, control round4-final-3212m), write-up in docs/server-readiness.md there.

gate store-one-down store-roll store-freeze pd-zero (load failed) pd-roll (load failed)
(a) shared partition PASS 503 on 3/3 Servers at once, 8-35 s 503 on 3/3, 4 s 503 whole outage (87 %) 503 on 3/3, 28-36 s (7 %)
(b) partition of the answering Store PASS 503 on 3/3, 23-42 s 503 on 3/3, 20-28 s 503 (58 %) PASS (0 %)
(c) parallel, first success 503 on 3/3, 3-12 s ×4 503 on 3/3, 13-35 s 503 on 3/3, 3-7 s 503 (62 %) 503 on 3/3, 53-85 s (47 %)
ping (round 3) + this round's single-flight PASS PASS PASS PASS (2 / 244) PASS (1 / 370)

Two effects, in every variant:

  1. The reads do not fail independently. Every 503 reason is ... partition read(s) did not finish within 1000 ms, also in (c) while two of three Stores are healthy and their partitions are being read. On the first error the Store client invalidates its partition cache and sleeps 1 s before retrying (NodeTxExecutor.retryingInvoke, HgStoreNodePartitionerImpl on a NOT_WORK notice), so one moving partition stalls every read through that client past the budget. During a Store roll all three Servers went 503 together and were pulled out of the Service, while the load through the Service had 2-3 failures in ~45 requests.
  2. A read through the query client is not a passive measurement. A failed probe read invalidates the partition cache shared with the real queries, and with PD down there is nothing to rebuild it from: in pd-zero the load failed 2 of 244 requests with the ping gate and 387-560 of ~640 with any read gate; in pd-roll 1 of 370 versus 106 of 226. A readiness probe that takes the data plane down during a PD outage is worse than one that cannot see raft.

So the gate stays on the ping, and I state its scope in the Javadoc and the PR description instead of implying more: ready means this Server knows a Store list and reaches at least one Store over gRPC; a Store whose status RPC answers while its raft path is broken is not detected. A Store-side signal that covers raft without going through the shared client does not exist today (HgStoreState.getPeers is in the proto, not implemented in the store node). If you want it, I can add a partition-status RPC with the raft leader to the Store as a separate PR and give the Server probe a choice of signal; I would rather not fold a Store-module change into this PR.

return holder.get(0);
}

public static synchronized Map<String, Object> check(Probe probe, long timeoutMs,

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.

⚠️ This synchronized method holds the class monitor while the Store probe performs network I/O for up to readiness.timeout. With the supported readiness.cache_ttl=0 setting, concurrent requests each trigger another probe and queue behind the previous call, so request latency can exceed the configured timeout by multiple probes and occupy REST workers. Please share an in-flight probe or bound each caller's wait without holding the monitor during network I/O.

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.

Right, done in 517dfe6: check() is no longer synchronized. The first caller without a valid cache becomes the owner (AtomicReference<CompletableFuture>) and runs the probe on its own thread; concurrent callers wait for that result, each at most its own readiness.timeout, then get 503 with a probe is still running after N ms; a shared result carries "shared": true. No monitor is held during I/O, so with readiness.cache_ttl=0 a slow Store no longer queues REST workers. StorageReadinessTest: four concurrent callers share one probe (gated by a CountDownLatch), and a follower's wait is bounded (200 ms while the probe is held for 5 s). On k3s: baseline p50 5 ms, p99 13 ms, cache ratio 0.62.

@SebastianGruza

Copy link
Copy Markdown
Contributor Author

Round 4 in 517dfe6: StorageReadiness.check without synchronized, one in-flight probe shared by concurrent callers with a bounded wait (StorageReadinessTest 8/8). The gate on a read through the graph path was built in three variants and measured on k3s: in every one a rolling Store restart takes all Servers out of the Service while the traffic is fine, and with PD down a probe through the shared Store client raises the load failures from 2/244 to 58-87 %, because a failed read invalidates the partition cache the queries use; details in the inline reply and in results/issue-3212/round4-* of the validation repo. The gate stays on the ping, its scope is now stated in the Javadoc and in the description; the control battery is 7/7 PASS on this head.

@bitflicker64 bitflicker64 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking: no. Summary: the new in-flight sharing in StorageReadiness.check fixes the serialised probes from the last round, but it drops the monitor that kept lastResult and lastCheckedAt consistent, so a caller can get the previous probe's answer marked as a fresh cache hit. Evidence: static read of the full diff at 517dfe6 and the 4a4aa1c..517dfe6 delta (StorageReadiness.java, HstoreStorageProbe.java, StorageReadinessTest.java); no CI has run on this head because the branch conflicts with master in UnitTestSuite.java (git merge-tree against 2f827d6), so the new tests are unverified here.

public static Map<String, Object> check(Probe probe, long timeoutMs, long cacheTtlMs) {
long now = System.currentTimeMillis();
Map<String, Object> cached = lastResult;
if (cached != null && now - lastCheckedAt < cacheTtlMs) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: lastResult and lastCheckedAt are two separate volatiles, and since this commit nothing keeps them paired for a reader. The owner writes lastResult (line 111) and then lastCheckedAt (line 112). A caller that reads lastResult at line 99 just before line 111 runs, and lastCheckedAt at line 100 just after line 112, returns the previous probe's result with cached: true as if it were current, for example a 200 right after the new probe found no Store. In 4a4aa1c the synchronized method made the pair atomic. Could the result and its timestamp live in one immutable holder behind a single volatile or AtomicReference, so the TTL check and the returned body always come from the same probe?

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.

Right, thanks, I dropped that pairing together with the monitor in round 4. The result and its timestamp now live in one immutable Cached holder behind a single AtomicReference, so the TTL check and the returned body always come from the same probe.

@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 unauthenticated readiness endpoint bypasses load admission while callers wait on storage, and HBase graphs are reported ready without a remote-storage check. Evidence: exact-head static trace through the filter, readiness implementation, and HBase backend initialization.

"metrics",
"versions"
"versions",
"readiness"

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.

⚠️ This whitelist entry makes LoadDetectFilter.filter() return before it increments WorkLoad or enforces the worker-load and low-memory limits. /readiness is unauthenticated, and StorageReadiness.check() makes concurrent callers wait synchronously on one in-flight probe for up to readiness.timeout (60 seconds); during slow storage, a burst can occupy the REST worker pool and starve graph requests. Please cap concurrent readiness waiters and reject excess calls quickly while preserving one probe under load. Evidence: exact-head static trace through LoadDetectFilter.filter() and StorageReadiness.check().

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.

Right. New option readiness.max_waiters (default 16, range 1..10000): at most that many callers wait at once for the probe in flight, each still bounded by its own readiness.timeout; the rest get an immediate 503 with the reason too many readiness callers waiting for the probe (N) and hold no worker. One probe under load stays. Test testExcessWaitersAreRejectedAtOnce (cap 1: the owner, one waiter, a third caller rejected in under 500 ms, the slot free again afterwards).

for (String name : manager.graphs()) {
try {
HugeGraph graph = manager.graph(name);
if (graph != null && BACKEND_HSTORE.equals(graph.backend())) {

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.

⚠️ firstHstoreGraph() selects only hstore; any HBase graph falls through to the caller's ready=true / storage=embedded branch. HBase remains an allowed, packaged backend, and HbaseStore.open() creates its connection without proving the cluster is reachable, so requests can fail while this probe stays green. Please handle supported remote backends explicitly and probe HBase or report not-ready/unknown when its availability is not checked. Evidence: exact-head static trace plus BackendProviderFactory.ALLOWED_BACKENDS, the dist POM, and HbaseStore.open().

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.

Right. firstRemoteGraph picks the first graph on a remote backend (hstore or hbase), and HbaseStore registers a storage_readiness meta handler: one admin round trip (existsTable of the graph's first table) on its own executor within the readiness.timeout budget; the body carries ready/reason/hbase_millis, no addresses and no raw exception text, and its storage field names the probed backend. I have no HBase cluster in the lab; the path runs in this PR's api-test job on hbase through ReadinessApiTest, and the probe itself is the same executor + Future.get(budget) shape as the hstore one.

@SebastianGruza

Copy link
Copy Markdown
Contributor Author

Round 5 in 7fa8020: the result and its timestamp in one holder, readiness.max_waiters with an immediate 503 for the excess, and a probe for graphs on HBase (HbaseStore.storage_readiness, one admin round trip within the budget). StorageReadinessTest 10/10.

@bitflicker64 bitflicker64 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking: yes. Summary: Round 5 makes a server with an HBase graph report storage "hbase", but ReadinessApiTest still only accepts "embedded" or "hstore", so testReadyOnAHealthyServer fails in the hbase API job that server-ci runs. The rest of the round 5 delta (the Cached holder, readiness.max_waiters, the HbaseStore probe) reads correctly. Evidence: static read of the full diff at 7fa8020 and the 517dfe6..7fa8020 delta. HbaseStoreProvider.type() returns "hbase", StandardHugeGraph.backend() returns that value, StorageReadiness.firstRemoteGraph() now selects it, and probeOnce() puts it in the body as "storage". server-ci.yml runs run-api-test.sh with BACKEND in [memory, rocksdb, hbase], and ApiTestSuite includes ReadinessApiTest. No CI has run on this head because the branch conflicts with master (mergeStateStatus DIRTY), so the failure is not visible yet.

Assert.assertEquals(true, body.get("ready"));
Assert.assertTrue(String.valueOf(body.get("storage")),
"embedded".equals(body.get("storage")) ||
"hstore".equals(body.get("storage")));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Important: This assertion fails on the hbase API job after this round. StorageReadiness.firstRemoteGraph() now picks a graph whose backend is hbase, and probeOnce() writes graph.backend() into the body as storage. HbaseStoreProvider.type() returns "hbase", so the body on that job is {"ready":true,"storage":"hbase",...} and assertTrue fails with message hbase. The storage field alone decides this, so the test fails even when the HBase probe itself succeeds.

server-ci.yml runs run-api-test.sh for memory, rocksdb and hbase, and ApiTestSuite includes ReadinessApiTest. The round 5 reply says the HBase path is covered by this test on that job, but as written the job goes red instead. No CI has run on 7fa8020 yet because the branch conflicts with master, so this has not shown up.

Requested change: accept "hbase" here and check the HBase fields the same way as the hstore ones, for example reason is "ok" and hbase_millis is present. Please also update the class Javadoc, which still lists only embedded and hstore.

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.

Right, thanks, I did not think it through for the hbase job. The test accepts storage embedded, hstore or hbase; for hbase it checks reason == "ok" and the presence of hbase_millis, for both remote backends the presence of the probes list. The class Javadoc lists the three jobs.

@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.

Fix required: update the HBase API assertion, resolve the branch conflict, and add endpoint/configuration documentation.

Define the multi-backend coverage boundary. Only the first remote graph is probed, so one healthy HBase cluster can hide a failed independently configured cluster.

for (String name : manager.graphs()) {
try {
HugeGraph graph = manager.graph(name);
if (graph != null && REMOTE_BACKENDS.contains(graph.backend())) {

@imbajin imbajin Oct 1, 2026 •

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.

[P2] Define readiness for independently configured remote backends

With two graphs backed by independent HBase clusters, this returns the first graph permanently: a healthy first cluster makes /readiness return ready=true while a failed second cluster is never probed.

A unit harness using the exact StorageReadiness source produced: healthy graph metadata called once, failed graph metadata never called, result ready=true. This is a unit-level reproduction, not cluster acceptance.

Either probe each independent backend configuration within the common budget, or document that readiness covers only the selected first remote graph and does not certify other HBase/HStore configurations. The currently documented HStore status-ping limitation is a separate, accepted boundary.

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.

Right. Readiness now covers every distinct remote backend configuration: graphs with the same configuration (the same pd.peers, or the same hbase.hosts and namespace) share one probe, independent clusters are probed side by side within the common budget, and ready means all of them are ready. The body keeps the fields of the first probe, storage lists the backends, and a probes list carries one entry per configuration (graph, backend, ready, reason); on a failure reason names the configuration. Test testEveryRemoteConfigurationIsProbed: a healthy plus a failed configuration, a hung configuration bounded by the budget, a single configuration with the body unchanged.

@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 HBase probe can report ready for an existing but disabled table, and the PD refresh can produce a stale-list false negative. Evidence: static trace from HbaseStore.storageReadiness() to HbaseSessions.existsTable(), and from HstoreStorageProbe.probe() to KnownStores.refresh().

String table = tables.isEmpty() ? null : tables.get(0);
long start = System.currentTimeMillis();
Future<Boolean> exists = READINESS_EXECUTOR.submit(() -> {
return table != null && this.sessions.existsTable(table);

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.

‼️ Table existence does not prove that an HBase graph table can serve traffic. This call uses HbaseSessions.existsTable(), which invokes Admin.tableExists; an existing disabled table is still reported as ready, while graph reads and writes fail. Please check that the selected table is enabled/available (or perform a bounded read through the serving path) before returning ready, and add a disabled-table case.

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.

Right. New HbaseSessions.tableAvailable(table): tableExists && isTableEnabled && isTableAvailable through Admin. The probe logic moved to a static HbaseStore.readinessOf(Callable, timeout, executor), testable without a cluster; HbaseReadinessTest covers: available, existing but disabled (not ready, reason "the graph's first table is not available"), a failing admin call (no exception text in the body), and a hung call (bounded by the budget).

}
return probe(KNOWN, () -> {
List<Metapb.Store> stores = pd.getActiveStores();
pruneChannels(CHANNELS, stores);

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.

⚠️ The PD refresh prunes old-address channels before KnownStores.update() publishes the new list, while a concurrent probe can already have snapshotted known.stores() at line 250. During a full Store replacement, that probe still pings the old list and can return not-ready although PD has returned the replacement list; StorageReadiness then caches the false result for the configured TTL. Please make pruning and pings use one store-list generation, or defer channel shutdown until probes using the previous snapshot finish.

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.

Right. Channel shutdown is deferred: an address missing from a listing first goes into a stale set, its channel is shut down only when it is missing from two consecutive listings, and its return clears the state. A probe that snapshotted the previous list therefore still pings through open channels. testChannelsOfReplacedStoresAreShutDown rewritten for that scenario (the first listing keeps, a return forgets, two in a row shut down, an empty and a failed listing prune nothing).

SebastianGruza and others added 3 commits October 2, 2026 10:52
Closes apache#3212.

A Kubernetes readiness probe on /versions keeps a server in the Service
while every graph request fails, because /versions answers 200 as long as
the REST layer is up, even with no Store in the cluster (measured under
after the 30 s request bound).

GET /readiness answers 200 while this server can serve graph traffic and
503 otherwise, unauthenticated like /versions (whitelisted in both
AuthenticationFilter and PathFilter, so an httpGet probe needs neither a
credential nor a graphspace prefix), with a JSON body that carries no
addresses.

Ready means, from this server's own view:
1. its PD client answers (getActiveStores()), and
2. at least one Store that PD reports as active answers one cheap direct
   call (a node session and Table/EXISTS on the vertex table). The first
   Store that answers ends the probe: a rolling restart of the Stores never
   pulls the servers out of the Service, while zero Stores does.

- hugegraph-hstore: HstoreStorageProbe (pure logic over an active-store
  lister and a store pinger, one shared time budget through an executor so a
  hung PD or Store yields "did not answer within N ms" instead of a hung
  probe) and a "storage_readiness" meta handler in HstoreStore
- hugegraph-api: ReadinessAPI and StorageReadiness (first hstore graph is
  probed through HugeGraph.metadata(null, "storage_readiness", timeout),
  result cached for readiness.cache_ttl; servers without an hstore graph
  answer 200 with storage=embedded); the api module gains no dependency
- ServerOptions: readiness.timeout (default 1000 ms) and readiness.cache_ttl
  (default 2000 ms)

Tests: HstoreStorageProbeTest (9) and StorageReadinessTest (6, including
the filter whitelists).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… PD refresh, prune dead Store channels

Review round 1 of apache#3221:
- the reason field carries a fixed category (gRPC status code, 'pd
  unreachable' or the exception class) instead of raw exception text, which
  could name PD peers and Store hosts on an unauthenticated endpoint; the
  full messages go to the log
- the background PD refresh is single-flight (KnownStores keeps the future
  in flight), so a hung PD parks one thread, not one per cache-missed probe
- channels of addresses PD no longer lists are shut down after a successful
  listing
- Javadoc and option descriptions say what the probe does: PD only until the
  first Store list is known, one call per known Store in parallel

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…used graphName, map result)

Review round 2 of apache#3221: the executor uses commons-lang3's BasicThreadFactory
like ExecutorUtil does, the unread graphName parameter and its concat at
the HstoreStore call site are gone, and probe() builds the body map through
one private helper instead of a Result holder whose only consumer was
toMap(); the tests assert on map entries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
SebastianGruza and others added 4 commits October 2, 2026 10:52
…an empty PD answer, add ReadinessApiTest

Review round 3 of apache#3221:
- readiness joins LoadDetectFilter.WHITE_API_LIST next to versions, so a
  busy server still answers its readiness probe from the storage state
  instead of shedding it (LoadReleaseFilter reads the same list); test
  case in LoadDetectFilterTest
- pruneChannels() ignores an empty PD answer, the same rule
  KnownStores.update() applies, so the channels the pings still use stay
  open while PD reports no active Store; the channel test covers it
- ReadinessApiTest drives GET /readiness through the API suite, with and
  without credentials, on every backend of the suite

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ing callers on a monitor

StorageReadiness.check no longer holds the class monitor while the Store
probe does network I/O: the first caller runs the probe on its own thread,
concurrent callers wait for that result bounded by their own timeout
("a probe is still running after N ms"). With readiness.cache_ttl=0 a slow
Store can no longer queue REST workers behind it.

StorageReadinessTest 8: concurrent callers share one probe, the follower
wait is bounded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…waiting callers, an HBase probe

- StorageReadiness keeps the last result with its timestamp in one
  immutable holder behind a single AtomicReference, so the TTL check and
  the returned body always come from the same probe.
- readiness.max_waiters (default 16): callers beyond it get an immediate
  503 instead of waiting on the in-flight probe, so a burst of probes during
  slow storage cannot hold the REST worker pool (the endpoint is
  unauthenticated and outside the load-shedding filter).
- Graphs on hbase are probed too: HbaseStore answers storage_readiness with
  one admin round trip (does the graph's first table exist) within the
  budget, on its own executor; the body's storage field names the probed
  backend.
- StorageReadinessTest 10: excess waiters rejected at once, storage name
  follows the backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- probe one graph per distinct hstore (pd.peers) / hbase (hosts+namespace)
  configuration in parallel within the budget; ready = all, body lists probes
- hbase: table enabled and available (not only existing), logic testable
- hstore: channels of replaced stores are shut down only after two listings
- ReadinessApiTest accepts the hbase CI job
@SebastianGruza

Copy link
Copy Markdown
Contributor Author

Round 6 in 6cdd6ce (the branch is rebased onto master, hence the force push; the only conflict was UnitTestSuite): one probe per distinct remote backend configuration with a probes list in the body, HBase checks enabled plus available with a disabled-table test, deferred channel shutdown in the hstore probe, ReadinessApiTest accepts the hbase job. Unit tests green. The k3s cluster of the lab is switched off, so I did not rerun the round-4 battery; these changes do not touch the ping gate measured there. The PR body is updated with probes and the "every configuration" scope.

@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 HBase probe can report ready while other graph tables are unavailable, and authenticated multi-configuration requests fail because admin context is not propagated to probe threads. Evidence: HbaseStore.storageReadiness checks one table; StorageReadiness.probeAll runs metadata calls with CompletableFuture.supplyAsync.

long deadline = System.currentTimeMillis() + timeoutMs;
List<CompletableFuture<Map<String, Object>>> futures = new ArrayList<>();
for (RemoteGraph r : remotes) {
futures.add(CompletableFuture.supplyAsync(() -> {

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.

‼️ Critical. Blocking: yes. Summary: With more than one remote configuration, probeAll dispatches metadata checks to CompletableFuture threads, but runAsAdmin installs AuthContext only on the request thread. With authentication enabled, HugeGraphAuthProxy.metadata then throws Missing authentication context and readiness returns 503 even when the backends are healthy. Please propagate the admin context into each async probe and cover an authenticated multi-configuration request. Evidence: AuthContext is a ThreadLocal; this branch uses supplyAsync while the one-configuration path is synchronous.

*/
private Map<String, Object> storageReadiness(long timeoutMs) {
List<String> tables = this.tableNames();
String table = tables.isEmpty() ? null : tables.get(0);

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.

‼️ Critical. Blocking: yes. Summary: This checks only tableNames().get(0), while HbaseGraphStore registers separate vertex, edge and index tables. If the selected table is available while another graph-critical table is disabled or unassigned, readiness still returns 200 although operations using that table fail. Please check the graph-critical tables within the timeout or use a representative graph-serving operation, and add a case where a nonselected table is unavailable. Evidence: storageReadiness selects one table and tableAvailable checks only that table.


private static Map<String, Object> probeEntry(RemoteGraph r, Map<String, Object> result) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("graph", r.name);

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.

⚠️ Important. Blocking: no. Summary: /readiness is unauthenticated, but each probes entry includes the graph name; failure reasons also include graph names. This exposes the graph inventory to callers without the graph-list permissions enforced elsewhere. Please omit graph names from the public response or move per-graph diagnostics behind an authenticated endpoint. Evidence: ReadinessAPI is @permitAll and probeEntry inserts r.name.

} catch (TimeoutException e) {
return result(false, "no store list known and pd did not answer within " +
timeoutMs + " ms", 0, null, false, -1L, -1L, 0L);
} catch (Exception e) {

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.

⚠️ Important. Blocking: no. Summary: On the first probe, await(refresh, deadline) can throw InterruptedException, but this catch treats it as an ordinary PD failure and returns without restoring the interrupt flag. Cancellation or shutdown can therefore be swallowed while waiting for the initial Store list. Please handle InterruptedException separately, restore the flag, and report interruption. Evidence: the Store-ping wait below restores the flag, but this branch catches Exception.

@bitflicker64 bitflicker64 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking: no. Summary: the probe logic reads correctly at this head; one cleanup: the readiness probe opens a thread-local graph transaction on the request or probe thread and never closes it, so closing the graph later fails its all-threads-closed check. The two blocking findings imbajin raised on this head (admin context on the async path, single HBase table) still apply and are not repeated. Evidence: full diff at 6cdd6ce read against StandardHugeGraph.metadata/graphTransaction/close and BackendSessionPool; a standalone run on the memory backend reproduced the IllegalStateException from close(); CI green on this head.

continue;
}
out.add(new RemoteGraph(name, graph.backend(),
t -> graph.metadata(null, STORAGE_READINESS_META, t)));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: graph.metadata(...) here goes through StandardHugeGraph.metadata to graphTransaction(), which calls tx.readWrite() and auto-opens the thread-local transaction, and nothing closes it. With one remote configuration that is the REST worker thread; with several it is a storage-readiness-probe pool thread, which also opens new backend sessions whenever the cached pool replaces an idle thread. StandardHugeGraph.close() ends with E.checkState(this.tx.closed(), "Ensure tx closed in all threads ..."), so after any probe, closing this graph on shutdown or drop throws and gets logged as "Failed to close graph". VertexAPI and EdgeAPI close the transaction in a finally for this reason.

Evidence: against the hugegraph-core classes on the memory backend, calling g.metadata(null, "storage_readiness", 1000L) from a separate thread and then g.close() printed IllegalStateException: Ensure tx closed in all threads when closing graph 'DEFAULT-hugegraph'. The call itself raised NotSupportException on memory, so the transaction opens before the handler runs and hstore and hbase take the same path.

Requested change: close it after the probe, for example:

t -> {
    try {
        return graph.metadata(null, STORAGE_READINESS_META, t);
    } finally {
        if (graph.tx().isOpen()) {
            graph.tx().close();
        }
    }
}

and add a case where the graph closes cleanly after a probe.

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.

[Feature] Server needs a storage-aware readiness signal (readiness stays green with zero Stores)

3 participants