Fix dead-seed eviction in /localnodes refresh + hot-path optimizations#89
Closed
CodeLieutenant wants to merge 2 commits into
Closed
Fix dead-seed eviction in /localnodes refresh + hot-path optimizations#89CodeLieutenant wants to merge 2 commits into
CodeLieutenant wants to merge 2 commits into
Conversation
3 tasks
|
@nikagra this is hurting our testing. Can you take a look and merge? |
2 tasks
This was referenced May 25, 2026
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
reviewed
Jun 2, 2026
nikagra
left a comment
There was a problem hiding this comment.
Generally looks good, I would only ask more coverage for parseLocalNodes method
CodeLieutenant
force-pushed
the
fix/dead-seed-rotation
branch
2 times, most recently
from
June 4, 2026 13:31
7f79d7d to
fd6a078
Compare
Author
|
@nikagra Can you review again please |
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
force-pushed
the
fix/dead-seed-rotation
branch
2 times, most recently
from
July 3, 2026 14:27
ae7b15f to
0c4ce25
Compare
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
force-pushed
the
fix/dead-seed-rotation
branch
from
July 3, 2026 15:22
9afb74a to
c58454d
Compare
Author
|
@nikagra PR ready, conflicts resolved. Can we merge it and have a new release? |
Collaborator
|
@CodeLieutenant , thanks for the PR, unfortunately it is not what we do in other clients, it is better to switchover to #136 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 refusederrors 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 rotationTwo root causes in
AlternatorLiveNodes.updateLiveNodes():/localnodesagainst 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.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:
mergeWithInitialNodes()withmergePostRefresh(): the responding peer's view of/localnodesis 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.testRepeatedUpdatesWithUnreachableNodesEventuallyTrySeedNodes).nextAsURI(), which the new selection path no longer calls.Adds
AlternatorLiveNodesDeadSeedTestcovering: 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 efficiencyBundle of small, individually-trivial improvements. None changes observable behavior beyond the
IndexOutOfBoundsExceptionfix.LazyQueryPlan (per-request hot path):
computeNextNonSeeded(): first-call direct pick, second-call linear skip, third+ call HashSet filter. Eliminates the per-requestArrayListallocation on the happy path.usedNodesHashSet lazily allocated only on the third consumption; seeded (key-affinity) mode never touches it. Single-field state for the common case.AlternatorLiveNodes:
nextAsURI()usesMath.floorModinstead ofMath.abs(... % size).Math.abs(Integer.MIN_VALUE)is still negative, so the previous code would throwIndexOutOfBoundsExceptiononce every ~2³¹ calls when theAtomicIntegerwrapped.getLiveNodes()drops the redundantArrayListcopy: the list stored in theAtomicReferenceis always built internally and never mutated./localnodesJSON parsing replaced with a small streaming parser — tolerates whitespace and escaped chars, no regex compile per refresh, skips malformed entries.hostToURI()skipstoURL()validation aftervalidateConfig()has proven the scheme/port pair valid once.hostToURI()(deduplicated).consumeAndClose()uses aThreadLocal<byte[1024]>drain buffer instead of allocating per response cleanup.BasicQueryPlanInterceptor:
modifyHttpRequest()only setsConnection: keep-alivewhen the inbound request doesn't already have aConnectionheader (both Apache and CRT SDK clients add it by default).Test plan
mvn -DskipITs test— 656 / 656 unit tests pass (3 new inAlternatorLiveNodesDeadSeedTest).AlternatorSeedFailoverFunctionalTest, opened as a separate PR against scylladb/YCSB) — pre-fix: 94/200 post-kill updates fail withConnection reset; post-fix: 200/200 OK, zero requests dispatched to the dead seed.Related