Skip to content

Fix dead-seed eviction in /localnodes refresh + hot-path optimizations#89

Closed
CodeLieutenant wants to merge 2 commits into
scylladb:mainfrom
CodeLieutenant:fix/dead-seed-rotation
Closed

Fix dead-seed eviction in /localnodes refresh + hot-path optimizations#89
CodeLieutenant wants to merge 2 commits into
scylladb:mainfrom
CodeLieutenant:fix/dead-seed-rotation

Conversation

@CodeLieutenant

Copy link
Copy Markdown

Summary

Fixes the bug behind scylladb/alternator-client-java#88: when the seed Alternator node went down during a rolling upgrade, the load balancer kept round-robining caller traffic onto it, producing tens of thousands of connection refused errors even though the rest of the cluster was healthy.

Reproducer (in YCSB, scylladb/YCSB PR linked separately) confirmed the root cause is in this library, not in YCSB — see the PR description below and the new AlternatorLiveNodesDeadSeedTest.

The branch contains two commits, intentionally split so the fix can be reviewed in isolation from the unrelated micro-optimizations.

Commit 1 — fix(live-nodes): evict dead seeds from refresh rotation

Two root causes in AlternatorLiveNodes.updateLiveNodes():

  1. One-attempt-per-scope. Each refresh attempted /localnodes against a single node per scope and gave up on the scope if that one call failed. A refresh that happened to pick the dead seed could not see the rest of the cluster.
  2. Seeds unconditionally re-injected. mergeWithInitialNodes() re-added every initial seed to the live list after each refresh, so the dead seed was repeatedly put back into the round-robin rotation.

Fix:

  • Iterate every known live node within each scope before falling back to the next; a node that fails IOException in one scope is still retried in the next (preserves the existing scope-fallback / empty-list symmetry).
  • Replace mergeWithInitialNodes() with mergePostRefresh(): the responding peer's view of /localnodes is treated as authoritative. The responder is retained (it just proved itself alive), nodes that failed earlier in this cycle are excluded, and dead seeds are no longer silently re-injected.
  • When every known node is unreachable, restore the original seed list as a last-resort recovery candidate (preserves testRepeatedUpdatesWithUnreachableNodesEventuallyTrySeedNodes).
  • Mark refresh activity explicitly so the poller stays at the active interval; the old code did this implicitly via nextAsURI(), which the new selection path no longer calls.

Adds AlternatorLiveNodesDeadSeedTest covering: dead-seed eviction once a refresh discovers healthy peers; first-pick failure still contacts a peer; seed list restored when every node is unreachable.

Commit 2 — perf+correctness: hot-path allocations and refresh efficiency

Bundle of small, individually-trivial improvements. None changes observable behavior beyond the IndexOutOfBoundsException fix.

LazyQueryPlan (per-request hot path):

  • Three-tier computeNextNonSeeded(): first-call direct pick, second-call linear skip, third+ call HashSet filter. Eliminates the per-request ArrayList allocation on the happy path.
  • usedNodes HashSet lazily allocated only on the third consumption; seeded (key-affinity) mode never touches it. Single-field state for the common case.

AlternatorLiveNodes:

  • nextAsURI() uses Math.floorMod instead of Math.abs(... % size). Math.abs(Integer.MIN_VALUE) is still negative, so the previous code would throw IndexOutOfBoundsException once every ~2³¹ calls when the AtomicInteger wrapped.
  • getLiveNodes() drops the redundant ArrayList copy: the list stored in the AtomicReference is always built internally and never mutated.
  • /localnodes JSON parsing replaced with a small streaming parser — tolerates whitespace and escaped chars, no regex compile per refresh, skips malformed entries.
  • hostToURI() skips toURL() validation after validateConfig() has proven the scheme/port pair valid once.
  • Constructor builds initial node URIs via hostToURI() (deduplicated).
  • consumeAndClose() uses a ThreadLocal<byte[1024]> drain buffer instead of allocating per response cleanup.

BasicQueryPlanInterceptor:

  • modifyHttpRequest() only sets Connection: keep-alive when the inbound request doesn't already have a Connection header (both Apache and CRT SDK clients add it by default).

Test plan

  • mvn -DskipITs test656 / 656 unit tests pass (3 new in AlternatorLiveNodesDeadSeedTest).
  • YCSB reproducer (AlternatorSeedFailoverFunctionalTest, opened as a separate PR against scylladb/YCSB) — pre-fix: 94/200 post-kill updates fail with Connection reset; post-fix: 200/200 OK, zero requests dispatched to the dead seed.

Related

@soyacz

soyacz commented May 22, 2026

Copy link
Copy Markdown

@nikagra this is hurting our testing. Can you take a look and merge?

@CodeLieutenant

Copy link
Copy Markdown
Author

@dkropachev Can you review this PR, if it's the right direction, or something is off, it's pretty urgent as loadbalacing feature inside YCBS does not work reliably without it.

If the problems are the optimizations I did, they are in a separate commit, I can extract them and send a separate PR

@nikagra nikagra left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Generally looks good, I would only ask more coverage for parseLocalNodes method

Comment thread src/main/java/com/scylladb/alternator/internal/AlternatorLiveNodes.java Outdated
@CodeLieutenant
CodeLieutenant requested a review from nikagra June 4, 2026 13:25
@CodeLieutenant
CodeLieutenant force-pushed the fix/dead-seed-rotation branch 2 times, most recently from 7f79d7d to fd6a078 Compare June 4, 2026 13:31
@CodeLieutenant

Copy link
Copy Markdown
Author

@nikagra Can you review again please

@nikagra nikagra left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM 👍

When the configured seed node went down during a rolling upgrade, the
load balancer kept round-robining caller traffic onto it, producing tens
of thousands of "connection refused" errors even though the rest of the
cluster was healthy.

Two root causes in AlternatorLiveNodes.updateLiveNodes():

1. Each refresh attempted /localnodes against a single node per scope and
   gave up on the scope if that one call failed. A refresh that happened
   to pick the dead seed could not see the rest of the cluster.
2. mergeWithInitialNodes() unconditionally re-added every initial seed to
   the live list after each refresh, so the dead seed was repeatedly
   re-injected into the round-robin rotation.

Fix:

- updateLiveNodes() now iterates every known live node within each scope
  before falling back to the next. A node that fails IOException in a
  given scope is still retried in the next scope, preserving the
  empty-list/IOException symmetry that the existing scope-fallback tests
  rely on.
- Replace mergeWithInitialNodes() with mergePostRefresh(): the responding
  peer's view of /localnodes is treated as authoritative. The responder
  is retained (it just proved itself alive), nodes that failed earlier
  in this cycle are excluded, and dead seeds are no longer silently
  re-injected.
- When every known node is unreachable, restore the original seed list
  as a last-resort recovery candidate (preserves the
  testRepeatedUpdatesWithUnreachableNodesEventuallyTrySeedNodes
  invariant).
- Mark refresh activity explicitly so the poller stays at the active
  interval; the old code did this implicitly via nextAsURI(), which the
  new selection path no longer calls.

Adds AlternatorLiveNodesDeadSeedTest covering:
- a dead seed is evicted once a refresh discovers healthy peers,
- a refresh whose first-picked node fails still contacts a peer,
- the seed list is restored when every known node is unreachable.

All 656 existing unit tests continue to pass.
@CodeLieutenant
CodeLieutenant force-pushed the fix/dead-seed-rotation branch 2 times, most recently from ae7b15f to 0c4ce25 Compare July 3, 2026 14:27
A bundle of small, individually-trivial improvements to the request hot
path and the /localnodes refresh path. None changes observable behavior
beyond the IndexOutOfBoundsException fix.

LazyQueryPlan (per-request hot path):

- computeNextNonSeeded() no longer allocates an ArrayList<URI> on every
  call. Three tiers: first call picks directly off the live list with
  no filtering; second call skips the single previously-used node with
  a linear pass; third+ call uses a HashSet. The common case (first
  attempt succeeds, no retries) hits the first tier.
- usedNodes HashSet is no longer allocated in the constructor. It is
  lazily created on the third consumption, and a single first-used-node
  field carries the second-call state. The seeded (key-affinity) mode
  never touches the set at all.

AlternatorLiveNodes:

- nextAsURI() uses Math.floorMod instead of Math.abs(... % size).
  Math.abs(Integer.MIN_VALUE) is still negative, so the previous code
  would throw IndexOutOfBoundsException once every ~2^31 calls when the
  AtomicInteger wrapped.
- getLiveNodes() drops the redundant defensive copy: the list stored in
  the AtomicReference is always built internally and never mutated, so
  unmodifiableList over the snapshot is sufficient.
- /localnodes JSON parsing replaced with a small streaming parser that
  tolerates whitespace and escaped characters, no longer recompiles a
  regex on every refresh, and logs+skips malformed entries instead of
  failing the whole refresh.
- hostToURI() now skips the toURL() validation after the first call:
  validateConfig() proves the scheme/port combination once at startup,
  and the URI constructor still throws on any bad host string.
- Constructor builds initial node URIs through hostToURI() so the
  validation/build path is no longer duplicated.
- consumeAndClose() uses a ThreadLocal byte[1024] drain buffer instead
  of allocating one per response cleanup.

BasicQueryPlanInterceptor:

- modifyHttpRequest() only sets Connection: keep-alive when no
  Connection header is present on the inbound request. Both the Apache
  and CRT SDK clients already add it, and overwriting forces a header-
  list copy in the SDK builder.

All 656 unit tests continue to pass.
@CodeLieutenant
CodeLieutenant force-pushed the fix/dead-seed-rotation branch from 9afb74a to c58454d Compare July 3, 2026 15:22
@CodeLieutenant

Copy link
Copy Markdown
Author

@nikagra PR ready, conflicts resolved. Can we merge it and have a new release?

@CodeLieutenant CodeLieutenant self-assigned this Jul 3, 2026
@dkropachev

Copy link
Copy Markdown
Collaborator

@CodeLieutenant , thanks for the PR, unfortunately it is not what we do in other clients, it is better to switchover to #136

@dkropachev dkropachev closed this Jul 7, 2026
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.

Failed to recover when node sending /localnodes goes down

4 participants