Skip to content

fix(#5002): HA-aware Bolt ROUTE response with multi-server routing table - #5085

Merged
robfrank merged 14 commits into
mainfrom
feat/5002-bolt-ha-route
Jul 7, 2026
Merged

fix(#5002): HA-aware Bolt ROUTE response with multi-server routing table#5085
robfrank merged 14 commits into
mainfrom
feat/5002-bolt-ha-route

Conversation

@robfrank

@robfrank robfrank commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Closes #5002. Part of #4890 (Group C protocol/type-fidelity gaps), epic #4882 (Bolt Driver Compatibility Certification). Certifies conformance scenario CONN-004.

Problem

BoltNetworkExecutor.handleRoute always returned this node's own address as WRITE, READ, and ROUTE, regardless of cluster topology. Against a real HA cluster a neo4j:// driver could not discover the leader/followers or route reads vs writes; routing/discovery was unproven.

Approach

The bolt module reaches the cluster only through the HAServerPlugin interface (ha-raft is test-scope there). The HA layer knew each peer's host plus Raft/HTTP/HTTPS ports, but no Bolt port - and the neo4j:// driver actually connects to whatever addresses ROUTE returns, so they must be genuinely reachable on the Bolt port.

  • Per-peer Bolt address: extend the existing HA_SERVER_LIST object form with an optional bolt:<port> field (host:{raft:2434,http:2480,bolt:7687}), parsed into a boltAddresses map. When a peer omits it, derive peerHost:localBoltPort (homogeneous-cluster assumption) with a one-time WARNING - exactly mirroring the established resolveHttpAddress pattern.
  • Interface bridge: HAServerPlugin gains a single getBoltRoutingTable() returning an immutable BoltRoutingTable(writer, readers) snapshot computed from one getLeaderId() read (so writer and readers cannot disagree about the leader), implemented by RaftHAPlugin -> RaftHAServer. Readers reflect the configured cluster membership (parity with getReplicaAddresses()).
  • ROUTE handler: handleRoute requires an authenticated (READY) session - ROUTE enumerates every peer's Bolt endpoint, so it is not served to an unauthenticated caller. When authenticated, it advertises the leader as WRITE+ROUTE and each follower as READ+ROUTE. The table is rebuilt on every ROUTE call, so reader/writer classification tracks leader changes automatically. Fallbacks: HA-active-but-leaderless advertises READ+ROUTE only (never write to a possible follower); true single-node advertises all three roles - both using the connection's actual bound Bolt port.

Tests

  • Bolt5002RoutingTableIT (3-node BaseRaftHATest, @Tag("slow")): asserts the routing table classifies the true leader as writer and followers as readers, that a neo4j:// driver routes reads and writes end to end, and that writer classification tracks a leader change (stops the leader, waits for re-election, re-checks the table). Polls the queried follower to tolerate the brief leader-unknown window after connect.
  • RaftHAServerAddressParsingTest: bolt: parsing (present/absent, named, positional-form has no Bolt field) and the deriveBoltAddress helper.
  • BoltProtocolIT#routeTableSingleNodeReturnsSelfForAllRoles: single-node (non-HA) ROUTE is unchanged.
  • BoltProtocolIT#routeBeforeLogonIsRejected: ROUTE on a Bolt 5.x deferred-auth session before LOGON is refused.

Conformance

Flips CONN-004 in bolt/conformance/spec.yaml from expected-fail to passing; the residual note documents that heterogeneous Bolt ports require the object-form bolt: field.

Acceptance criteria (#5002)

  • CONN-004 passes against a multi-node cluster; a neo4j:// driver discovers writer + reader(s) and routes accordingly.
  • Single-node (non-HA) ROUTE behavior unchanged.
  • Reader/writer classification tracks leader changes.

Design and plan committed under docs/superpowers/specs and docs/superpowers/plans.

🤖 Generated with Claude Code

robfrank added 7 commits July 7, 2026 10:38
…ct form

Add an optional object-form 'bolt:' field to HA_SERVER_LIST, parsed into a
boltAddresses map, and resolve each peer's client-reachable Bolt address in
RaftHAServer (declared value, else derive peerHost:localBoltPort with a
one-time WARNING). Expose getLeaderBoltAddress()/getReplicaBoltAddresses().
…ship

handleRoute now advertises the leader as WRITE+ROUTE and each follower as
READ+ROUTE from HAServerPlugin, falling back to the single-node self table
when HA is absent or leaderless. Adds a single-node ROUTE regression test.
Verifies the routing table classifies the true leader as writer and followers
as readers, that a neo4j:// driver routes reads and writes end to end, and that
writer classification tracks a leader change.
@mergify

mergify Bot commented Jul 7, 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 Jul 7, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 12 complexity

Metric Results
Complexity 12

View in Codacy

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.

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request implements HA-aware Bolt ROUTE responses to reflect real cluster topology, allowing neo4j:// drivers to discover and route against ArcadeDB clusters. It introduces an optional bolt port field in the HA_SERVER_LIST configuration, resolves these addresses in RaftHAServer, and dynamically builds the routing table in BoltNetworkExecutor.handleRoute. The feedback highlights two key improvements: adding a null check in RaftHAServer.deriveBoltAddress to prevent a potential NullPointerException when a peer's address is unresolved, and catching exceptions within the retry loop of awaitRoutingTable in the new integration test to avoid flakiness during cluster re-elections.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +1338 to +1340
static String deriveBoltAddress(final String raftAddress, final int boltPort) {
if (boltPort <= 0)
return 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.

high

The deriveBoltAddress method calls extractHost(raftAddress) directly without checking if raftAddress is null. If peerRaftAddress(peerId) returns null (e.g., if the peer is not found or not fully initialized), this will lead to a NullPointerException. Adding a null check for raftAddress at the beginning of the method ensures safety, matching the pattern used in deriveHttpAddressWithWarning.

Suggested change
static String deriveBoltAddress(final String raftAddress, final int boltPort) {
if (boltPort <= 0)
return null;
static String deriveBoltAddress(final String raftAddress, final int boltPort) {
if (raftAddress == null || boltPort <= 0)
return null;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added the explicit raftAddress == null guard in 5614c67. Note there was no actual NPE: extractHost(null) already returns null, so deriveBoltAddress returned null safely. The explicit early return makes the contract clearer and matches the guard style, so it is worth keeping regardless.

Comment on lines +173 to +182
private Map<String, Object> awaitRoutingTable(final int boltPort, final String expectedWriter) throws Exception {
Map<String, Object> rt = null;
for (int attempt = 0; attempt < 40; attempt++) {
rt = fetchRoutingTable(boltPort);
if (List.of(expectedWriter).equals(addressesForRole(rt, "WRITE")))
return rt;
Thread.sleep(250);
}
return rt;
}

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.

medium

In integration tests involving cluster re-elections and socket connections, the target port might temporarily be unreachable or throw connection exceptions during startup or failover. Catching and ignoring exceptions inside the retry loop of awaitRoutingTable prevents premature test failure and makes the test suite more robust against CI/CD environment flakiness.

  private Map<String, Object> awaitRoutingTable(final int boltPort, final String expectedWriter) throws Exception {
    Map<String, Object> rt = null;
    for (int attempt = 0; attempt < 40; attempt++) {
      try {
        rt = fetchRoutingTable(boltPort);
        if (List.of(expectedWriter).equals(addressesForRole(rt, "WRITE")))
          return rt;
      } catch (final Exception e) {
        // ignore and retry
      }
      Thread.sleep(250);
    }
    return rt;
  }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Applied in 5614c67: awaitRoutingTable now retries through connection exceptions thrown while contacting a node mid-failover. This matches the EOF/connection-reset observed when a node is hit during re-election.

@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Code Review: HA-aware Bolt ROUTE response (PR 5085)

Nice piece of work overall. The change is well-scoped, cleanly mirrors the established resolveHttpAddress / getReplicaAddresses patterns in RaftHAServer, keeps backward compatibility via default interface methods on HAServerPlugin, and comes with good test coverage at three levels (unit parsing, single-node regression, and a 3-node HA IT that exercises a real neo4j:// driver plus a leader-change). The docs/superpowers design/plan artifacts are a plus. A few observations, mostly minor.

Potential issues

  1. TOCTOU between the two HA calls in handleRoute. getLeaderBoltAddress() and getReplicaBoltAddresses() each independently call getLeaderId(). If a leader change lands between the two reads, the WRITE set and READ set can be momentarily inconsistent (e.g. the new leader appearing as both WRITE and READ, or the old leader missing entirely). It self-heals after the TTL so it is not fatal, but a single snapshot method that computes leader + replicas from one getLeaderId() read would eliminate the window and also avoid re-walking getPeers() twice per ROUTE.

  2. "Live membership" is actually configured membership. getReplicaBoltAddresses() iterates raftGroup.getPeers(), i.e. the configured cluster, so a down/partitioned follower is still advertised as a READ endpoint until it is removed from the group. The driver will fail over, so this is acceptable and consistent with the existing getReplicaAddresses() behavior - but the PR description of "from live membership" slightly overstates it. If advertising unreachable readers is a concern, consider filtering to online replicas (optional, and out of scope if you want parity with the HTTP path).

  3. Fallback branch advertises a follower as WRITE during the leader-unknown window. When HA is active but the leader is not known yet, the code advertises this node for all three roles - including WRITE - even if this node is a follower. A neo4j:// driver could then send a write to a follower and get an error until the TTL expires. It is documented in the comment and transient, so it is a reasonable trade-off, but you could shrink the blast radius by omitting WRITE (or returning only ROUTE) in the ha != null && leaderBolt == null case so drivers do not attempt writes against a follower.

Minor / style

  • getReplicaBoltAddresses() returns a CSV string that handleRoute immediately re-splits on comma and trims. It matches the existing getReplicaAddresses() convention, so it is fine as-is, but returning List<String> would avoid the join/split round-trip and the isBlank() defensive parsing. Not blocking.
  • Bolt5002RoutingTableIT uses hardcoded ports (BASE_BOLT_PORT = 57697, raft 2434+i), which can collide under parallel CI. This is consistent with the other HA tests, and @Tag("slow") is applied correctly per the repo conventions, so just flagging it.
  • Good call switching the fallback to getBoltAddress(socket.getLocalPort()) instead of the global default port - that makes the single-node table correct when the listener is bound to a non-default port.

Things done well

  • Interface evolution via default methods keeps FakeHAPlugin and any external implementors compiling with no changes.
  • deriveBoltAddress correctly handles the IPv6 literal case via extractHost, and there is a direct unit test for it.
  • The one-time boltFallbackWarned WARNING with a clear operator-facing message (declare bolt: in the object form for heterogeneous ports) is good ergonomics and mirrors httpFallbackWarned.
  • Rebuilding the table on every ROUTE so writer/reader classification tracks leader changes, verified end-to-end by writerClassificationTracksLeaderChange.

Nothing here is blocking; items 1 and 3 are the two I would most want a second look at before merge.

…lback

- Collapse getLeaderBoltAddress/getReplicaBoltAddresses into one
  getBoltRoutingTable() snapshot computed from a single getLeaderId() read,
  eliminating the writer/reader TOCTOU window and the CSV round-trip (Claude #1).
- handleRoute: when HA is active but the leader is unknown, advertise this node
  as READ+ROUTE only (never WRITE), so a driver never writes to a follower
  during a leader-unknown window; true single-node still advertises all roles (Claude #3).
- deriveBoltAddress: explicit null guard for raftAddress (Gemini).
- Bolt5002RoutingTableIT.awaitRoutingTable: retry through transient connection
  errors during failover (Gemini).
@robfrank

robfrank commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the review in 5614c67.

#1 TOCTOU between the two HA calls - Fixed. Replaced getLeaderBoltAddress() + getReplicaBoltAddresses() with a single HAServerPlugin.BoltRoutingTable getBoltRoutingTable() that reads getLeaderId() once and derives both the writer and the readers from that snapshot. The writer and reader sets can no longer disagree about the leader, and it walks getPeers() once per ROUTE instead of twice.

#3 fallback advertises a follower as WRITE - Fixed. handleRoute now has three branches: HA with a known leader (full topology); HA active but leader unknown -> advertise this node as READ + ROUTE only, never WRITE, so a driver keeps reading and re-routes after the TTL instead of writing to a possible follower; and true single-node (ha == null) -> all three roles as before.

#2 "live" vs configured membership - Correct, it is configured membership. Kept parity with getReplicaAddresses() (a down/partitioned follower stays advertised until it leaves the group; the driver fails over) rather than adding online-only filtering, and reworded the method Javadoc and PR description to say "configured cluster membership".

Minor - CSV round-trip - Resolved as a side effect of #1: the new BoltRoutingTable record carries List<String> readers, so there is no join/split anymore.

Minor - hardcoded ports in the IT - Acknowledged; kept consistent with the sibling HA ITs (BoltFollowerForwardingIT etc.) and @Tag("slow").

All tests green (resolver unit, single-node ROUTE regression, 3-node Bolt5002RoutingTableIT).

@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Code Review: HA-aware Bolt ROUTE response (#5085)

Reviewed the full diff. This is a well-structured, well-tested change. The design is clean: it reuses the established resolveHttpAddress pattern for per-peer address resolution, keeps the bolt module talking to the cluster only through the HAServerPlugin interface, and preserves single-node behavior via a fallback. Nice work.

One thing worth calling out as an improvement over the committed plan: the final code uses a single BoltRoutingTable snapshot (one getLeaderId() read) instead of the plan's separate getLeaderBoltAddress()/getReplicaBoltAddresses() methods. That's the right call - it removes the window where the writer and reader sets could disagree about who the leader is.

Findings

1. (Security, low/medium) handleRoute is reachable before authentication, and now discloses the full cluster topology.
handleRoute only guards on state == State.FAILED (BoltNetworkExecutor.java:927), while the data-plane handlers (handleRun at :530, etc.) require state == State.READY. With the Bolt 5.1+ deferred-auth handshake, a client can send HELLO with no credentials (which sets state = AUTHENTICATION and returns SUCCESS) and then send ROUTE before LOGON - handleRoute will run and reply.

This gap is pre-existing, but this PR materially amplifies its impact: previously ROUTE leaked only the address the client already connected to; now it enumerates every peer's Bolt endpoint (derived from internal Raft hostnames), which is useful recon for an unauthenticated caller. Consider gating ROUTE behind an authenticated state (e.g. state == READY), matching the other request handlers. Worth at least confirming the intended behavior against the driver's routing flow.

2. (Minor) BoltRoutingTable Javadoc claims "Immutable snapshot" but readers is a mutable ArrayList exposed directly.
In RaftHAServer.getBoltRoutingTable() the record is built with a live ArrayList, and the record accessor returns that reference. It is created fresh per call so there is no real aliasing bug today, but to honor the documented immutability contract consider new BoltRoutingTable(writer, List.copyOf(readers)) (or wrap on the accessor side). Cheap and future-proofs against a caller mutating it.

3. (Behavioral note, not a bug) Stale-leader advertisement on followers.
getBoltRoutingTable() uses getLeaderId(), which on a follower can briefly return the previous leader right after a failover (non-null, so the "leader unknown -> reader-only" fallback in handleRoute does not engage). That node would advertise a dead leader as WRITE until it learns the new one. Drivers recover via the routing TTL / retry, and the IT sidesteps it by polling the new leader directly, so this is acceptable - just flagging the window. The reader-only fallback only helps when getLeaderId() is actually null.

4. (Housekeeping) The committed plan doc is already stale.
docs/superpowers/plans/2026-07-07-bolt-ha-aware-route-5002.md (899 lines) documents the two-method API (getLeaderBoltAddress/getReplicaBoltAddresses) that the implementation deliberately replaced with getBoltRoutingTable. Committing ~1050 lines of plan/spec that no longer match the merged code adds maintenance noise. If these docs are meant to live in the repo, consider updating the plan to reflect the final BoltRoutingTable design; otherwise they may be better left out of the PR.

Things done well

  • Parsing changes are thorough and symmetric with the existing http/https object-form fields, including the unknown-key error message update and IPv6 handling in deriveBoltAddress.
  • getLeaderId() already swallows IOException -> null, and getBoltRoutingTable() handles the null-leader case, so ROUTE won't throw during startup/election.
  • Good test coverage: unit tests for parsing (present/absent/named/positional/unknown-key) and deriveBoltAddress (incl. IPv6 and non-positive port), a single-node regression guard, and a 3-node IT that verifies leader/follower classification, end-to-end neo4j:// read/write routing, and writer re-classification after a leader change - correctly tagged @Tag("slow").
  • Backward compatible: new interface methods are default, positional form is untouched, single-node output preserved (and slightly improved by using the actual bound port via socket.getLocalPort()).

Overall this looks solid and mergeable; item 1 is the only one I'd want addressed or explicitly confirmed before shipping.

- handleRoute now requires an authenticated (READY) session: ROUTE enumerates
  every peer's Bolt endpoint, so an unauthenticated caller (Bolt 5.1+ deferred-auth
  window, pre-LOGON) is refused. Adds routeBeforeLogonIsRejected regression (Claude #1).
- BoltRoutingTable readers returned as List.copyOf to honor the immutability
  contract (Claude #2).
- Reconcile design spec + plan with the final getBoltRoutingTable design,
  auth gate, and three-way fallback (Claude #4).

Claude #3 (brief stale-leader window on a follower right after failover) is an
accepted, self-healing behavior (driver recovers via routing TTL); no code change.
@robfrank

robfrank commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed in 2214dfc.

#1 (security) ROUTE reachable before authentication - Fixed. handleRoute now requires state == READY, matching handleRun and the other request handlers. Verified this is safe for both driver flows: Bolt 4.x authenticates in HELLO (handleHello sets READY at :423), and Bolt 5.1+ reaches READY only after LOGON - a driver never sends ROUTE before that. A ROUTE arriving in AUTHENTICATION/NEGOTIATION now gets a FAILURE instead of the topology. Added BoltProtocolIT.routeBeforeLogonIsRejected, which negotiates Bolt 5.4, sends a credential-less HELLO (deferred auth), and asserts the subsequent ROUTE is refused.

#2 (minor) BoltRoutingTable mutability - Fixed. getBoltRoutingTable() now returns new BoltRoutingTable(writer, List.copyOf(readers)), so the accessor hands back an unmodifiable list, honoring the "immutable snapshot" contract. Also reworded the "live membership" phrasing to "configured cluster membership" in the method Javadoc, the spec, and this PR description.

#3 (behavioral note) stale-leader window on a follower - Acknowledged as accepted behavior. Right after a failover getLeaderId() can briefly return the previous leader (non-null, so the reader-only fallback does not engage) and that node advertises a dead leader as WRITE until it learns the new one; drivers recover via the routing TTL/retry. Fixing it would need a liveness probe per ROUTE, which is out of scope and not worth the cost for a sub-second, self-healing window. No code change; flagged in the spec.

#4 (housekeeping) stale plan doc - Reconciled. The repo convention commits docs/superpowers specs/plans with the feature PR, so rather than drop them I updated the spec's interface/handler sections to the final getBoltRoutingTable design and added a "post-review deltas" note to the plan pointing at the final API, auth gate, and three-way fallback.

All tests green: parsing unit tests, single-node ROUTE regression, the new auth-gate test, and the 3-node Bolt5002RoutingTableIT.

@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Code Review: HA-aware Bolt ROUTE response

Reviewed the net diff against main. Overall this is a clean, well-reasoned change that closes a real gap: neo4j:// drivers can now discover the true leader/followers instead of always getting the local node. The implementation faithfully mirrors the existing resolveHttpAddress/HA_SERVER_LIST patterns, the interface is extended in a backward-compatible way (default method returning null), and the test coverage is genuinely strong (leader-change tracking, single-node regression guard, address parsing). Nice work.

Strengths

  • Snapshot consistency: getBoltRoutingTable() derives writer + readers from a single getLeaderId() read, so a concurrent leader change cannot produce a mutually-inconsistent table. Good call, and the Javadoc documents it.
  • Mid-election safety: when HA is active but the leader is unknown, the node advertises itself as READ+ROUTE only, never WRITE. This avoids drivers sending writes to a follower during that window - a subtle but correct choice.
  • Security tightening: gating ROUTE behind State.READY prevents pre-auth topology disclosure and is Bolt-spec compliant (ROUTE is a READY-state message). Covered by routeBeforeLogonIsRejected.
  • Homogeneous-port fallback is explicit, warned once, and documented in the conformance known_limitation. The per-peer configured check means mixed configs (some peers with explicit bolt:, some derived) still resolve correctly.

Suggestions / minor issues

  1. Test robustness, awaitRoutingTable can return null (Bolt5002RoutingTableIT.java:173-186): after 40 failed attempts the helper returns the last rt, which is null if every attempt threw. The caller then runs addressesForRole(rt, ...) and NPEs with an opaque message instead of a clear assertion failure. Consider asserting rt is not null before the role assertions so a genuine failure is legible.

  2. Duplicated test helpers: readRoutingTable(...) and addressForRole/addressesForRole(...) are copy-pasted between Bolt5002RoutingTableIT and BoltProtocolIT. Minor, but a small shared test util would avoid drift if the SUCCESS/rt wire shape ever changes.

  3. Behavior change worth a changelog note: ROUTE previously succeeded in any non-FAILED state; it now requires READY (and sets FAILED otherwise). This is spec-compliant and desirable, but it is a tightening that could surface with non-conformant clients - worth calling out in release notes.

  4. Dead/partitioned followers advertised as readers: getBoltRoutingTable() iterates configured raftGroup.getPeers(), so a stopped follower is still listed as a READ target until it leaves the group (exactly what happens to the old leader in writerClassificationTracksLeaderChange). The Javadoc acknowledges this and relies on driver failover + TTL. Acceptable, but confirm BOLT_ROUTING_TTL is short enough that clients re-route promptly rather than repeatedly hitting a dead reader.

Things I checked that are fine

  • Only one production HAServerPlugin implementor (RaftHAPlugin); the default method keeps the test mock and external implementors compiling.
  • boltAddresses is a final HashMap populated in the constructor and read-only afterward - same safe-publication pattern as httpAddresses/httpsAddresses.
  • IPv6 handling is consistent between getBoltAddress (brackets loopback/IPv6) and deriveBoltAddress plus extractHost ([::1]:7687), and is unit-tested.
  • readers.isEmpty() falls back to the writer for the READ role - safe (reads go to the leader rather than an empty list).
  • Committing the design/plan under docs/superpowers/ matches the established repo convention.

No blocking concerns from my side - the items above are polish.

Generated with Claude Code

…g table

- Move readRoutingTable/addressesForRole into BoltRouteTestSupport, shared by
  Bolt5002RoutingTableIT and BoltProtocolIT, so the SUCCESS/rt wire shape is
  decoded in one place (Claude review, DRY).
- awaitRoutingTable now asserts the routing table is non-null after its retry
  budget, so a genuine failure reports a legible message instead of an NPE (Claude review).
@robfrank

robfrank commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the polish items in 9e87086.

#1 awaitRoutingTable can return null - Fixed. After the retry budget it now asserts the routing table is non-null (assertThat(rt).as(...).isNotNull()), so a genuine failure reports "no routing table advertising writer X obtained from bolt port Y after retries" instead of an opaque NPE in the role assertions.

#2 duplicated test helpers - Fixed. Extracted readRoutingTable(...) and addressesForRole(...) into a package-private BoltRouteTestSupport, now shared by Bolt5002RoutingTableIT and BoltProtocolIT, so the SUCCESS/rt wire shape is decoded in one place.

#3 ROUTE now requires READY is a behavior change - Acknowledged. It is spec-compliant (ROUTE is a READY-state message) and only affects non-conformant clients that send ROUTE before authenticating; official drivers always authenticate first. Worth a release-note line, which I'll leave to the maintainer's changelog rather than committing here.

#4 dead/partitioned followers advertised until they leave the group - Confirmed acceptable. BOLT_ROUTING_TTL defaults to 300s (matching Neo4j's default routing TTL), but drivers do not wait for the TTL to drop an unreachable reader: on a connection failure the driver marks that server stale and re-routes immediately, so a dead reader is not repeatedly hit. Kept parity with getReplicaAddresses() rather than adding a per-ROUTE liveness probe.

All route/auth/IT tests green.

@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Code review: HA-aware Bolt ROUTE (PR 5085)

Reviewed the full diff (source, tests, conformance, docs). This is a clean, well-scoped change that correctly closes the single-node-only ROUTE gap. The bolt -> HAServerPlugin -> RaftHAServer layering respects the module constraint (no ha-raft reference from bolt/src/main), and the design has clearly absorbed several prior review rounds. Overall: looks good to merge. A few notes below, mostly for discussion.

Strengths

  • Single-snapshot routing table. Collapsing the two-method API into one getBoltRoutingTable() computed from a single getLeaderId() read removes the writer/reader leader-disagreement window - a genuine correctness improvement over the CSV round-trip.
  • Auth gating of ROUTE. Requiring state == READY before enumerating peer Bolt endpoints is the right call for topology non-disclosure, and correctly handles both HELLO-auth (Bolt <5.1) and deferred-auth (5.1+, pre-LOGON). Nicely covered by routeBeforeLogonIsRejected.
  • Three-way fallback (HA+leader / HA+leaderless-READ-only / true single-node) is sound: never advertising WRITE during a leader-unknown window means a driver never posts a write to a follower.
  • socket.getLocalPort() for the self address is more correct than the old global BOLT_PORT when bound to a non-default/ephemeral port.
  • Test coverage is strong: unit parsing (present/absent/named/positional/unknown-key), deriveBoltAddress incl. IPv6, single-node regression, auth gate, and a 3-node IT that asserts leader-change tracking with generous retry budgets. Extracting BoltRouteTestSupport to decode the wire shape in one place is good hygiene.

Discussion points (non-blocking)

  1. Readers reflect configured membership, including down/partitioned followers (documented in the HAServerPlugin Javadoc). Operationally a driver may advertise a dead reader until it leaves the group and rely on driver-side failover + TTL to recover. Standard Bolt-routing behavior, but worth a line in operator docs so a partially-down cluster's intermittent read failures aren't surprising.
  2. Interface vs impl null contract. HAServerPlugin.getBoltRoutingTable() Javadoc says it returns null when HA is inactive or no leader is currently known, but RaftHAServer also returns null when the leader's Bolt address is unresolvable. Minor: worth adding that third null case to the interface doc since callers branch on it.
  3. ROUTE in non-READY transactional states. The gate rejects ROUTE in TX_READY/STREAMING with PROTOCOL_ERROR + FAILED. Per the Bolt spec ROUTE is only valid in READY and real drivers use a dedicated routing connection, so this is correct - just flagging it is a slight tightening of prior behavior for any hand-rolled client that multiplexed ROUTE onto a busy connection.
  4. Docs footprint. ~1600 of the 1686 added lines are the docs/superpowers/ plan + design spec. No objection, just confirm the team wants these agentic-workflow artifacts tracked in-repo long-term.

Nits

  • getBoltRoutingTable() builds an ArrayList then wraps in List.copyOf (one extra copy); negligible given cluster sizes.
  • roleEntry(readers.isEmpty() ? List.of(writer) : readers, "READ") correctly handles the single-member-cluster case; fine as-is.

Nice work - the classification-tracks-leader-change IT in particular gives real confidence this behaves under failover.

Automated review by Claude.

The interface Javadoc listed null for HA-inactive and no-known-leader, but
RaftHAServer also returns null when the leader has no resolvable Bolt address.
Callers branch on null, so the contract now states all three cases (Claude review).
@robfrank

robfrank commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough passes. Addressed in 4ff8da2.

#2 interface vs impl null contract - Fixed. HAServerPlugin.getBoltRoutingTable() Javadoc now lists all three null cases: HA inactive, no leader currently known, and the leader having no resolvable Bolt address.

#1 dead readers in a partially-down cluster - Acknowledged. This is documented on the interface method and in the CONN-004 known_limitation. A dedicated operator-docs note is a good idea but belongs in the HA docs page rather than this PR; the behavior itself is standard Bolt routing (driver-side failover + TTL) and unchanged from getReplicaAddresses().

#3 ROUTE rejected in TX_READY/STREAMING - Confirmed intended. Spec-compliant (ROUTE is a READY-state message) and real drivers use a dedicated routing connection; the tightening only affects a hand-rolled client multiplexing ROUTE onto a busy connection.

#4 docs footprint - Confirmed intended. Per repo convention docs/superpowers/specs and docs/superpowers/plans are committed with the feature PR.

Nits - List.copyOf over a fresh ArrayList is one negligible copy at cluster-size cardinality; left as-is for the immutability guarantee. Single-member-cluster READ fallback is intentional.

No outstanding items on my side.

…losed

CONN-004 was the last #4890-tracked expected-fail scenario. Flipping it to
passing left the connection area with no tracked gap, failing the spec
structural-integrity check. All #4890 gaps are now closed, so the required-gap
set is empty (mirrors how errors/type-roundtrip/protocol were handled).
@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review: HA-aware Bolt ROUTE response (#5002)

Solid, well-scoped PR. The design is clean: the bolt module stays behind the HAServerPlugin interface, the single-snapshot BoltRoutingTable (one getLeaderId() read) neatly removes the writer/reader leader-disagreement window, and the three-way fallback (HA-with-leader / HA-leaderless / true single-node) is careful never to advertise a follower as a writer. The bolt: object-form extension mirrors the established resolveHttpAddress/resolveHttpsAddress pattern, and the docs + conformance note are honest about the homogeneous-port assumption. Prior review rounds already tightened the important bits (auth gate, immutability, null contract). A few observations, mostly minor.

Correctness / robustness

  • BoltNetworkExecutor:966 leaderless fallback advertises only this node. In the HA-leaderless branch you advertise getBoltAddress(socket.getLocalPort()) as the sole READ/ROUTE endpoint. That is safe and self-healing (the driver re-routes after TTL), but during an election a driver that connected via this node only ever learns one router. Transient and documented, so acceptable - just flagging the behavior.

  • Readers reflect configured membership, including down/partitioned followers (documented in the getBoltRoutingTable Javadoc). Correct choice for parity with getReplicaAddresses(), and the driver fails over. No change needed; good that it's spelled out.

Test coverage

  • Bolt5002RoutingTableIT.fetchRoutingTable uses assertThat(response[1]).isEqualTo(SUCCESS) inside the block that awaitRoutingTable wraps in catch (final Exception e). An AssertJ failure throws AssertionError (an Error, not Exception), so it would bypass the retry loop rather than be retried during a failover window. In practice ROUTE always returns SUCCESS for an authenticated session, so this never triggers today - but if you want the retry to be genuinely robust, checking response[1] != SUCCESS and continue-ing (or catching Throwable) would match the stated intent of "retry until it settles."

  • The HA-active-but-leaderless fallback branch (ha != null && table == null -> READ+ROUTE only, no WRITE) is not directly covered. The single-node all-roles path and the full leader/follower path are both tested well; the leaderless branch is admittedly hard to hit deterministically, so this is a note rather than a request.

Minor / style

  • Auth gate error classification (BoltNetworkExecutor:936). Rejecting pre-auth ROUTE and refusing to disclose topology is the right call. PROTOCOL_ERROR + state = FAILED is defensible; a Security.Unauthorized-style failure would arguably be more semantically precise, but this is a judgment call and not worth churn.

  • ROUTE is accepted only in READY (not TX_READY/TX_STREAMING). That matches how neo4j drivers issue ROUTE (dedicated routing connection, READY state), so it's fine.

Nice touches

  • Extracting BoltRouteTestSupport so the SUCCESS/rt wire shape is decoded in one place.
  • The one-time WARNING on derived Bolt addresses with an actionable message pointing at the object-form syntax.
  • deriveBoltAddress IPv6-literal handling is tested ([::1]:7687).

Overall LGTM - the flagged items are minor robustness/coverage notes, not blockers.

REQUIRED_GAP_AREAS is now empty (all #4890 gaps closed), so the test exercises
the missing-gap mechanism against a temporarily patched required area instead of
relying on the live config having 'connection'.
@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Code Review: HA-aware Bolt ROUTE (#5085)

Thorough, well-scoped change. The bolt module correctly reaches the cluster only through the HAServerPlugin interface, the single-leader-read snapshot design is sound, and it faithfully mirrors the established resolveHttpAddress / HA_SERVER_LIST object-form patterns. Test coverage is strong: unit tests for parsing + deriveBoltAddress (incl. IPv6), and a 3-node @Tag("slow") IT that actually exercises leader-change tracking with a real neo4j:// driver. Nicely done.

Strengths

  • No writer/reader disagreement: getBoltRoutingTable() computes both sets from one getLeaderId() read, and the immutable BoltRoutingTable record enforces that at the type level.
  • Security improvement: gating ROUTE behind READY so topology enumeration isn't served to an unauthenticated caller is the right call, and routeBeforeLogonIsRejected locks in the Bolt 5.1+ deferred-auth case.
  • Safe leaderless fallback: advertising a possibly-follower node as READ+ROUTE only (never WRITE) during an election is exactly right - the driver re-routes after TTL instead of writing to a follower.
  • Consistent with getReplicaAddresses() (readers = configured membership), with the down/partitioned-follower tradeoff documented in the Javadoc.

Minor points (non-blocking)

  1. Derived-fallback address collision on shared-host clusters (RaftHAServer.resolveBoltAddress): when no bolt: field is configured, every peer's address is derived as peerHost:localBoltPort. For a genuine homogeneous cluster (distinct hostnames, same port - e.g. k8s StatefulSet) this is correct, and the IT sidesteps it via explicit bolt: config. But if multiple peers share a host and differ only by port (as the IT nodes actually do on localhost), derivation would produce identical localhost:<localBoltPort> entries for all readers, and List.copyOf(readers) preserves the duplicates. This is already a degenerate/misconfigured topology and the one-time WARNING covers it - consider a sentence in the Javadoc noting that same-host/different-port is the one scenario derivation cannot represent.

  2. getBoltRoutingTable() null semantics: it returns null both when no leader is known and when the leader's Bolt address is unresolvable. handleRoute treats both identically (leaderless branch -> local node as READ+ROUTE only). In practice the second case is essentially unreachable (default Bolt port is positive; the leader is always in the peer list), so this is fine - just flagging that if the leader's address ever were unresolvable, a driver hitting a follower would never discover a writer. A one-line comment distinguishing the two null causes would help future readers.

  3. db field can be null in the routing table when neither message.getDatabase() nor databaseName is set. This appears to be pre-existing behavior (not introduced here), and Neo4j drivers pass db, so likely a non-issue - noting for completeness.

Performance / correctness

  • ROUTE is a low-frequency (per-TTL) call, so rebuilding the table each time is fine; no hot-path concern.
  • boltAddresses is populated once at init and read-only thereafter, mirroring httpAddresses; no added concurrency risk. Dynamic peers added via addPeer won't carry a configured Bolt address and correctly fall through to derivation.

Verification

Static review only - I did not run the full mvn build / IT suite (the @Tag("slow") Bolt5002RoutingTableIT in particular needs a real 3-node cluster). Recommend confirming that IT plus RaftHAServerAddressParsingTest and BoltProtocolIT pass in CI before merge.

Overall: clean, well-tested, and consistent with repo conventions. LGTM modulo the minor doc/comment nits above.

🤖 Generated with Claude Code

awaitRoutingTable catches Exception, but the SUCCESS assertion threw an
AssertionError (an Error), bypassing the retry during a failover window. Throw
an IOException on a non-SUCCESS ROUTE so the retry loop keeps polling as intended (Claude review).
@robfrank

robfrank commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the retry-robustness note in 5423acb.

Test #1 (fetchRoutingTable SUCCESS check bypasses the retry) - Fixed. awaitRoutingTable catches Exception, but the AssertJ isEqualTo(SUCCESS) threw an AssertionError (an Error), which would escape the retry during a failover window. fetchRoutingTable now throws an IOException on a non-SUCCESS ROUTE, so the retry loop keeps polling as intended. The single-shot assertion in BoltProtocolIT.routeTableSingleNodeReturnsSelfForAllRoles stays as an assertion - that path should fail loudly on a healthy single node.

Leaderless-branch coverage - Acknowledged. The HA-active-but-leaderless (table == null -> READ+ROUTE only) branch is genuinely hard to hit deterministically (it requires catching the cluster in a sub-second leader-unknown window), so it stays a documented note rather than a flaky timing test.

Auth-gate error classification - Keeping PROTOCOL_ERROR + FAILED for consistency with handleRun's wrong-state handling; a Security.Unauthorized code would be marginally more precise but would diverge from the sibling handlers, so not worth the churn.

Single-router during election / configured-membership readers - Both transient/self-healing and documented on the interface + known_limitation; no change.

Route/auth/IT tests all green locally.

@codacy-production

codacy-production Bot commented Jul 7, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 12 complexity

Metric Results
Complexity 12

View in Codacy

🟢 Coverage 61.33% diff coverage · -7.21% coverage variation

Metric Results
Coverage variation -7.21% coverage variation
Diff coverage 61.33% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (35e9c2e) 135513 101089 74.60%
Head commit (5423acb) 167375 (+31862) 112786 (+11697) 67.39% (-7.21%)

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 (#5085) 75 46 61.33%

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%

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 Jul 7, 2026

Copy link
Copy Markdown

Code Review: HA-aware Bolt ROUTE response

Reviewed the full diff against the CLAUDE.md conventions. This is a high-quality, well-scoped change: the design (single HAServerPlugin.BoltRoutingTable snapshot, homogeneous-cluster fallback mirroring the existing HTTP resolution, three-way ROUTE fallback) is clean, the bolt module stays behind the HAServerPlugin interface as required, and the code follows the repo style (final params, imports over FQNs, no em dashes, throttled one-time WARNINGs). The commit history shows earlier review feedback already addressed (immutable readers via List.copyOf, DRY test support, auth gating, doc reconciliation). Nice work.

Strengths

  • Single-snapshot consistency: getBoltRoutingTable() reads getLeaderId() once and derives writer + readers from it, so a concurrent leader change cannot produce a writer/reader set that disagrees about the leader. A subtle correctness win over the previous two-call design.
  • Security: gating ROUTE behind State.READY closes a real topology-disclosure hole (an unauthenticated ROUTE would enumerate every peer bolt endpoint), with a dedicated routeBeforeLogonIsRejected regression for the Bolt 5.1+ deferred-auth window.
  • Fallback correctness: the leaderless-but-HA-active branch advertising READ+ROUTE only (never WRITE) is the right call - better a transient no-writer table the driver retries than a write routed to a follower.
  • No stale API: the removed getLeaderBoltAddress/getReplicaBoltAddresses interface methods were introduced earlier in this same PR (never released), and the only other implementer (FakeHAPlugin in GetReadyHandlerHATest) relies on the default, so nothing breaks.
  • Tests exercise the real 3-node cluster, the neo4j:// end-to-end read/write routing, and leader-change re-classification, plus parser unit tests and a preserved single-node regression.

Suggestions (non-blocking)

  1. Untested branches worth a cheap unit test. Two production branches are only reached via transient cluster states and are not directly asserted: the leaderless HA fallback in handleRoute (READ+ROUTE, no WRITE); and the derived-address path in RaftHAServer.resolveBoltAddress (homogeneous cluster where bolt: is not declared, plus its one-time WARNING) - note this is likely the common production path, since operators will not always declare bolt:. Only deriveBoltAddress and parsing are unit-tested today. The existing FakeHAPlugin implements HAServerPlugin pattern makes the first one easy: a fake returning getBoltRoutingTable() == null with HA non-null would deterministically cover the READ+ROUTE-only branch without spinning a cluster.

  2. Driver behavior on a writer-less table. When HA is active but leaderless, the table has no WRITE server. Most neo4j:// drivers tolerate this and retry on the routing TTL, but some versions raise SessionExpired / "no write server" immediately on a write. Worth a one-line note in the design doc confirming the target driver matrix recovers on TTL rather than failing hard, since the whole approach leans on that retry.

  3. Stale-reader advertisement. readers reflect configured membership, so a down/partitioned follower is still advertised for reads until it leaves the group (documented in the Javadoc). A reasonable parity choice with getReplicaAddresses(); just flagging that read latency during a follower outage then depends on the driver per-address connect timeout + failover.

  4. Minor - error classification. The auth gate returns PROTOCOL_ERROR for a pre-LOGON ROUTE. It matches the "unexpected message in state" convention used elsewhere, so it is fine, but semantically it is closer to an authorization refusal than a protocol violation. Purely cosmetic.

Performance and style

No concerns. The ROUTE path allocates a few small lists per call (fine for a low-frequency control message), boltAddresses is written once at construction and read-only afterward (same safe-publication pattern as httpAddresses), and boltFallbackWarned is an AtomicBoolean. Naming and structure are consistent with the surrounding HA code.

Overall: solid, tested, and safe to merge after considering the coverage suggestions above.

Reviewed with Claude Code.

@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review: HA-aware Bolt ROUTE response (5085)

Thorough, well-executed PR. The design is clean, the layering is respected (bolt reaches HA only through HAServerPlugin, ha-raft stays test-scope), and the commit history shows the earlier review rounds (single-snapshot table, auth gate, immutable readers, retry-on-non-SUCCESS) already landed. Nice work.

Strengths

  • Single-snapshot routing table. Collapsing getLeaderBoltAddress/getReplicaBoltAddresses into one getBoltRoutingTable() computed from a single getLeaderId() read removes the writer/reader TOCTOU window - the right call. List.copyOf(readers) honors the record's immutability contract.
  • Layering. The bolt module still references only com.arcadedb.server.HAServerPlugin; the ha-raft binding lives behind the default method. resolveBoltAddress mirrors the established resolveHttpAddress pattern (declared value, else derive with a throttled one-time WARNING), so this reads like the surrounding code.
  • IPv6. extractHost keeps the bracket literal, so deriveBoltAddress emits [ipv6]:port correctly, consistent with getBoltAddress on the single-node path.
  • Security-conscious auth gate. Requiring READY before ROUTE so an unauthenticated Bolt 5.1+ deferred-auth caller cannot enumerate every peer's Bolt endpoint is a good, defensible hardening, and it is regression-tested (routeBeforeLogonIsRejected).
  • Test coverage is strong. The 3-node IT asserts writer/reader classification, real neo4j:// end-to-end read+write routing, and leader-change tracking (stop leader, await re-election, re-check). awaitRoutingTable correctly polls through the leader-unknown window, and the fetchRoutingTable fix to throw IOException (an Exception, not an AssertionError) on non-SUCCESS so the retry loop actually retries is a subtle but correct catch. Parser unit tests cover present/absent/named/positional bolt: forms plus the unknown-key error.

Minor observations (non-blocking)

  1. Down/partitioned followers stay advertised as READ. getBoltRoutingTable derives readers from configured membership (raftGroup.getPeers()), so a crashed-but-not-yet-removed follower is still advertised until it leaves the group; the driver has to fail over to it and retry. This is documented in the Javadoc and matches getReplicaAddresses() parity, so it is an accepted trade-off - just flagging that reader liveness is not filtered. If liveness filtering is ever wanted, getLivePeers() already exists.
  2. ROUTE rejected in TX_READY/TX_STREAMING. The gate is state != READY, so a ROUTE arriving mid-transaction is refused as a protocol error. That is fine in practice (drivers send ROUTE on a fresh connection, never mid-tx), but it is slightly stricter than authenticated-only. Worth a one-line comment if you want to make the intent explicit; no change needed otherwise.
  3. REQUIRED_GAP_AREAS is now empty, making the missing_gap_areas check a permanent no-op until a new gap is added. The test was reworked to exercise the mechanism against a temporarily-patched area, which is the pragmatic fix, but the live validator now silently passes that dimension. Consider a short comment in validate_spec.py noting the set is intentionally empty (all 4890 gaps closed) so a future reader does not assume it is a bug.
  4. BOLT_PORT fallback assumes Bolt is enabled cluster-wide. resolveBoltAddress derive path uses this node's BOLT_PORT (default 7687) for every peer, so in a homogeneous cluster the table is effectively always non-null once a leader is known. That is the intended homogeneous-cluster behavior and the WARNING covers heterogeneous ports; just be aware a peer that happens not to run the Bolt plugin would still be advertised. Edge config, not worth guarding.

Verdict
Clean, tested, and layered correctly, with the security and consistency concerns from prior rounds already addressed. The observations above are minor/optional. LGTM.

@robfrank
robfrank merged commit 7772575 into main Jul 7, 2026
23 of 27 checks passed
@robfrank
robfrank deleted the feat/5002-bolt-ha-route branch July 7, 2026 10:14
@codecov

codecov Bot commented Jul 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 56.00000% with 33 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.62%. Comparing base (35e9c2e) to head (5423acb).
⚠️ Report is 6 commits behind head on main.

Files with missing lines Patch % Lines
...java/com/arcadedb/server/ha/raft/RaftHAServer.java 15.62% 25 Missing and 2 partials ⚠️
...in/java/com/arcadedb/bolt/BoltNetworkExecutor.java 85.18% 2 Missing and 2 partials ⚠️
...java/com/arcadedb/server/ha/raft/RaftHAPlugin.java 0.00% 1 Missing ⚠️
.../main/java/com/arcadedb/server/HAServerPlugin.java 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff            @@
##               main    #5085   +/-   ##
=========================================
  Coverage     65.62%   65.62%           
- Complexity      842      846    +4     
=========================================
  Files          1690     1690           
  Lines        135513   135572   +59     
  Branches      28987    29002   +15     
=========================================
+ Hits          88924    88973   +49     
- Misses        34461    34466    +5     
- Partials      12128    12133    +5     

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

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.

Bolt: HA-aware ROUTE response with multi-server routing table

1 participant