Skip to content

feat(#6091): the cluster can name a peer's gRPC address, so a follower's refusal is dialable - #6171

Merged
lvca merged 1 commit into
mainfrom
issue-6091-grpc-routing-table
Aug 14, 2026
Merged

feat(#6091): the cluster can name a peer's gRPC address, so a follower's refusal is dialable#6171
lvca merged 1 commit into
mainfrom
issue-6091-grpc-routing-table

Conversation

@lvca

@lvca lvca commented Aug 14, 2026

Copy link
Copy Markdown
Member

Closes #6091 (item 3 of #6083, the one left out of #6089).

The problem

graphBatchLoad refuses a bulk load on a follower - correctly, since the bulk path mutates shared state only the leader can serialize (#4122). To let the caller redirect it named the leader, but the only address HA could expose was the leader's HTTP one, so the refusal had to say:

Reconnect to the leader at '<host:httpPort>' (HTTP address; use its gRPC port) and retry

A client retrying automatically had to already know the deployment's port-mapping convention, and there was no way to learn the leader's gRPC address from the cluster.

Decisions the issue asked to make deliberately

Generalise, don't duplicate. BoltRoutingTable / getBoltRoutingTable() become RoutingTable(protocol, writer, readers) / getRoutingTable(ROUTING_PROTOCOL). A second single-protocol record would have invited a third, and the resolver behind it is the same code with different inputs: the declared-addresses map, this node's local port, and the one-time-warning latch are now selected per protocol, everything else is shared. The Bolt ROUTE path was updated with it; Bolt5002RoutingTableIT passes unchanged.

Derive and declare, exactly as Bolt does. The object form of arcadedb.ha.serverList takes a grpc: field (host:{raft:2434,http:2480,bolt:7687,grpc:50051}); with nothing declared, a peer's endpoint is its Raft host plus this node's gRPC port. That is right for a homogeneous deployment (a K8s StatefulSet) and knowingly wrong for a heterogeneous one, which is why the field exists and why the fallback logs a WARNING naming the syntax. Shipping the derive-only half would have left the heterogeneous case silently wrong - the same reason bolt: exists. The warning latch is per protocol now, so a derived Bolt address no longer mutes the gRPC warning.

One RPC refuses today, but the refusal is not written for one RPC. notTheLeader(ha, rpc, why) builds it, so the next RPC that has to refuse on a follower gets the same answer rather than a second dialect.

Better than what the issue proposed

The issue asked for the address in the refusal message. A message is not a contract - a client redirecting itself would be pattern-matching prose. So the refusal also carries:

  • arcadedb-leader-grpc-address - the address the refused call can be retried on;
  • arcadedb-leader-http-address - always known when a leader is, kept as the diagnostic fallback;
  • arcadedb-exception-class: ServerIsNotTheLeaderException - the trailer this service already uses to preserve exception types.

The gRPC client therefore rebuilds the same exception the HTTP protocol raises for the same situation, ServerIsNotTheLeaderException, with getLeaderAddress() returning the gRPC endpoint. Callers that already handle the HTTP leader redirect handle the gRPC one with no new code. The keys live in the shared grpc protocol module (LeaderRedirectProtocol), next to GraphBatchProtocol, so the two ends cannot drift.

GlobalConfiguration.GRPC_PORT registers the key the gRPC plugin previously read as a bare string with its own literal default. HA resolves peer endpoints from the same constant, so the two cannot disagree about which port to advertise - and the setting now honours env/system properties like every other one.

Testing

Cluster tests, because the follower branch is unreachable on a single server (recorded in #6083: the auth interceptor short-circuits a credential-less call and forcing the branch NPEs on a null ha).

  • Issue6091GraphBatchLoadLeaderRedirectIT (grpcw, 3 nodes, per-node grpc: declared) - the load refused on a follower is retried against the address the refusal advertised, dialled verbatim, and succeeds and replicates. A test comparing the string against the port it configured would pass just as well if the server had named the follower's own port, which is exactly the mistake to catch here.
  • Issue6091GrpcRoutingTableIT (ha-raft) - every node names the same leader endpoint; the Bolt and gRPC tables disagree on a cluster that declares only gRPC ports (a shared resolver's one new failure mode is reading the other protocol's map); the writer is never also a reader.
  • Issue6091GrpcRoutingTableDerivedIT (ha-raft) - the fallback, asserted against a deliberately non-default port so it fails if the resolver stops reading GRPC_PORT.
  • Issue6091LeaderRedirectMappingTest (grpc-client) - gRPC address preferred over HTTP, HTTP as fallback, null address mid-election, and an address trailer alone does not invent a type.
  • RaftHAServerAddressParsingTest - grpc: parsing, independence from bolt:, malformed port, K8s DNS suffix.

Mutation-checked, all four ways: naming only the HTTP address, pointing the gRPC lookup at the Bolt map, deriving from BOLT_PORT, and each fails the test that covers it.

Suites run green: bolt unit + Bolt5002RoutingTableIT, ha-raft unit + the new ITs, grpcw unit + Issue6070*IT, grpc-client unit. (Some local runs of unrelated classes fail on HTTP port 2480 not available - another process on this machine holds it.)

Not in this PR

The user-facing serverList reference lives in the docs repo; the grpc: field wants the same one-paragraph note bolt: got there.

…r's refusal is dialable

A graph batch load refused on a follower named the leader's HTTP address and told the
caller to work out the gRPC port itself, because HA knew a peer's Raft, HTTP and Bolt
endpoints but not its gRPC one. It now names an address that can be dialled, and carries
it on the trailers so a client redirects itself instead of parsing prose.

- HAServerPlugin: BoltRoutingTable/getBoltRoutingTable generalised into
  RoutingTable(protocol, writer, readers) + getRoutingTable(ROUTING_PROTOCOL). One
  record and one resolver for both protocols rather than a second single-protocol copy
  inviting a third.
- HA_SERVER_LIST object form takes a 'grpc:' field alongside 'bolt:'; when absent the
  address derives from the peer's Raft host plus this node's gRPC port, with the same
  one-time WARNING the Bolt fallback logs. The latch is now per protocol.
- GlobalConfiguration.GRPC_PORT registers the key the gRPC plugin already read as a bare
  string, so HA and the plugin cannot disagree about the port (and it now honours the
  environment like every other setting).
- The refusal carries the leader's gRPC and HTTP addresses on trailers and the
  ServerIsNotTheLeaderException class name, so the gRPC client rebuilds the same typed
  exception the HTTP protocol raises, with getLeaderAddress() pointing at the gRPC
  endpoint.

Tests: Issue6091GrpcRoutingTableIT (declared addresses, protocol independence),
Issue6091GrpcRoutingTableDerivedIT (fallback), Issue6091GraphBatchLoadLeaderRedirectIT
(the refused load succeeds against the address the refusal named, dialled verbatim),
Issue6091LeaderRedirectMappingTest (client-side typing). All mutation-checked.
@mergify

mergify Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@codacy-production

codacy-production Bot commented Aug 14, 2026

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 1 minor

Alerts:
⚠ 1 issue (≤ 0 issues of at least minor severity)

Results:
1 new issue

Category Results
CodeStyle 1 minor

View in Codacy

🟢 Metrics 0 complexity

Metric Results
Complexity 0

View in Codacy

🟢 Coverage 94.44% diff coverage

Metric Results
Coverage variation Report missing for de774e31
Diff coverage 94.44% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (de774e3) Report Missing Report Missing Report Missing
Head commit (e872854) 190025 137086 72.14%

Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch: <coverage of head commit> - <coverage of common ancestor commit>

Diff coverage details
Coverable lines Covered lines Diff coverage
Pull request (#6171) 72 68 94.44%

Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: <covered lines added or modified>/<coverable lines added or modified> * 100%

1 Codacy didn't receive coverage data for the commit, or there was an error processing the received data. Check your integration for errors and validate that your coverage setup is correct.

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@claude

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown

Review

Read through the full diff (bolt, engine, grpc, grpc-client, grpcw, ha-raft, server modules) and traced the cross-module wiring by hand (couldn't run mvn compile in this sandbox since it required interactive approval that wasn't available, so this is a careful static read, not a build-verified one).

Overall

Clean generalization of BoltRoutingTable/getBoltRoutingTable() into RoutingTable(protocol, writer, readers)/getRoutingTable(ROUTING_PROTOCOL). Confirmed there are no leftover references to the old BoltRoutingTable/getBoltRoutingTable API anywhere in the tree, and the one other HAServerPlugin implementer in tests (GetReadyHandlerHATest) doesn't override the routing method, so it just inherits the new default - no breakage there.

Things I specifically checked and are correct

  • Trailer key parity: GrpcErrorMapper.EXCEPTION_CLASS_KEY (server) and GrpcClientErrorMapper.EXCEPTION_CLASS_KEY (client) are declared separately but with the identical string literal "arcadedb-exception-class", so Metadata.Key equality holds across the wire. That's a second place someone could let the literal drift; there's already a comment noting they must stay in sync, which is enough.
  • ServerIsNotTheLeaderException(msg, leaderURL) constructor signature matches the new call site in GrpcClientErrorMapper.reconstructFromClassName.
  • GRPC_PORT registration: GrpcServerPlugin.CONFIG_PORT now derives from GlobalConfiguration.GRPC_PORT.getKey(), and the startStandardServer default falls back to GlobalConfiguration.GRPC_PORT.getValueAsInteger() rather than a duplicated 50051 literal - this is exactly the "cannot drift" property the PR description claims, and it holds up.
  • routingFallbackWarned EnumMap: built eagerly for every ROUTING_PROTOCOL value in the field initializer and stored in a final field, so routingFallbackWarned.get(protocol) can never NPE regardless of which protocol is queried first - no lazy-init race here.
  • Single-snapshot semantics preserved: getRoutingTable still reads getLeaderId() once and resolves writer/readers from that one snapshot, now parameterized by protocol - the "writer never also a reader" invariant the new Issue6091GrpcRoutingTableIT test checks is structurally guaranteed the same way it was for Bolt.
  • Blank-vs-null handling: notTheLeader()'s blankToNull means a resolved-but-blank address is never written to trailers, so GrpcClientErrorMapper.leaderAddress() never has to worry about preferring an empty-string gRPC address over a real HTTP one.
  • Confirmed Issue6070GraphBatchLoadLeaderGuardIT's existing assertion (.contains("not the cluster leader")) still holds against the reworded refusal message, so that test wasn't silently broken by the wording change.
  • No new dependencies were introduced (io.grpc.* was already in use), so the ATTRIBUTIONS.md/NOTICE requirement in CLAUDE.md doesn't apply here.

Minor observations (non-blocking)

  • notTheLeader's Javadoc and the LeaderRedirectProtocol Javadoc are both quite prose-heavy for what's ultimately a small mapping helper - not a real problem, just noting the docs carry a lot of the design-rationale weight that might otherwise have lived only in the PR description. Not asking for a change.
  • RaftPeerAddressResolver's object-form parser now supports raft, http, https, bolt, grpc, priority - the "unknown key" error message was updated to match, and malformedGrpcPortThrows covers the new field. Good symmetry with the existing bolt tests.
  • The Issue6091GrpcRoutingTableIT/...DerivedIT split (declared vs. derived) exercises both branches of resolveRoutingAddress well, and the derived test's choice of a non-default port (50081 instead of 50051) is a nice touch - it fails if the resolver ever stops reading GlobalConfiguration.GRPC_PORT and starts reading a hardcoded default instead.

Test coverage

Coverage looks thorough for the described scope: parsing (RaftHAServerAddressParsingTest), routing table resolution both declared and derived (Issue6091GrpcRoutingTableIT/DerivedIT), the actual end-to-end redirect-and-retry against a 3-node cluster (Issue6091GraphBatchLoadLeaderRedirectIT), and client-side exception reconstruction in isolation (Issue6091LeaderRedirectMappingTest). The redirect IT's assertion that the advertised address is not the follower's own port is a good regression guard against the specific "derive-from-local-port" bug class this PR is designed to avoid.

No correctness, security, or performance issues found. Well-scoped, well-tested change.

@lvca

lvca commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

Codacy's one finding (Info, PMD ClassNamingConventions): the enum name ROUTING_PROTOCOL does not match [A-Z][a-zA-Z0-9]*.

Declining it deliberately: the four enums already on HAServerPlugin - QUORUM, ELECTION_STATUS, SERVER_ROLE, READINESS_SIGNAL - are all SCREAMING_SNAKE and trip the same rule; they escape it only because the analyzer looks at the diff. Renaming just the new one would make it the odd member of the set it is declared beside. Happy to rename all five in a separate mechanical PR if the convention should change.

@codecov

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.55556% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.93%. Comparing base (1c5859a) to head (e872854).
⚠️ Report is 5 commits behind head on main.

Files with missing lines Patch % Lines
.../com/arcadedb/server/grpc/ArcadeDbGrpcService.java 55.55% 3 Missing and 5 partials ⚠️
...java/com/arcadedb/server/ha/raft/RaftHAServer.java 88.46% 0 Missing and 3 partials ⚠️
...om/arcadedb/remote/grpc/GrpcClientErrorMapper.java 60.00% 1 Missing and 1 partial ⚠️
...java/com/arcadedb/server/ha/raft/RaftHAPlugin.java 0.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6171      +/-   ##
==========================================
+ Coverage   69.91%   69.93%   +0.02%     
==========================================
  Files        1831     1831              
  Lines      157633   157682      +49     
  Branches    33253    33261       +8     
==========================================
+ Hits       110208   110282      +74     
+ Misses      33641    33600      -41     
- Partials    13784    13800      +16     

☔ 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.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@lvca

lvca commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

CI status

Everything this PR adds is green. The two red lanes are failures this branch did not introduce:

integration-tests - two failures, neither on a path this PR touches:

  • Issue6070GrpcGraphBatchIT.aFailureOnAnInteriorFlushIsNotResentByClose:351 - the identical failure, same line, on PR fix(#6094): a bare CALL to a write CypherProcedure is no longer classified read-only #6162 (a Cypher-only change, since merged): job 94572497347. The test's own javadoc documents the race it rides on: whether the server's async failure is observed by a send() in the body or only at close() depends on grpc-java reporting isReady() == false on a terminated call before the next of 400 sends.
  • Issue5470BatchStreamStallIT.aBodyShorterThanItsContentLengthIsNotReportedAsSuccess:226 - expected 408, got 409, and the server log for that run says the batch was interrupted after 0 vertices and 0 edges on a 10 s streaming read timeout. The runner did not deliver a 100-record body within ten seconds; the HTTP batch path is untouched here (the only server change in this PR is the HAServerPlugin interface).

ha-integration-tests - Issue5569SlotMergeDeleteRaftIT.mergedDeletesReplicateIntact:121. This lane is red on every recent PR with a different test each time (#6165, #6161, #6162 all failed it; #6165's was RaftPriorityRejoinIT), which is the chronic flake tracked in #5668 / #5702.

What this PR added, in the lanes that run it:

Test Lane Result
Issue6091GrpcRoutingTableIT (3 tests) ha-integration pass, 36.9 s
Issue6091GrpcRoutingTableDerivedIT ha-integration pass, 11.6 s
Issue6091GraphBatchLoadLeaderRedirectIT integration pass, 15.7 s
Issue6091LeaderRedirectMappingTest, RaftHAServerAddressParsingTest unit pass

Re-running the two failed jobs to confirm.

@lvca lvca self-assigned this Aug 14, 2026
@lvca lvca added this to the 26.9.1 milestone Aug 14, 2026
@lvca

lvca commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

Re-run result for integration-tests (job 94855534608):

So the only outstanding red in this lane is a known, separately tracked flake.

@lvca

lvca commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

Final CI state after re-running the two red lanes:

Lane Result
ha-integration-tests pass (59m59s) - the earlier Issue5569SlotMergeDeleteRaftIT failure was the chronic flake this lane shows on every PR
integration-tests fail, and only on Issue6070GrpcGraphBatchIT.aFailureOnAnInteriorFlushIsNotResentByClose - #6168 item 3, also red on merged PR #6162. Issue5470BatchStreamStallIT passed on the re-run
everything else pass: unit, slow-unit, vector-unit, build-and-package, builder, opencypher-tck, studio-e2e, java/js/python/go/csharp e2e, CodeQL, Meterian, claude-review

Codacy's single Info-level finding is the enum-naming one addressed above.

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.

graphBatchLoad's follower refusal cannot name a dialable address: HAServerPlugin exposes no gRPC routing table

1 participant