Fix flaky PeersV2NodeRefreshIT: retry on Simulacron port-bind race - #1017
Fix flaky PeersV2NodeRefreshIT: retry on Simulacron port-bind race#1017roydahan wants to merge 1 commit into
Conversation
PeersV2NodeRefreshIT builds its own Server with withMultipleNodesPerIp(true),
which always binds its nodes starting at 127.0.0.1:49152 -- the first port of
the OS ephemeral/dynamic port range on both Linux and macOS. That range is
also handed out by the kernel as the *source* port for any outbound socket on
the machine, including other integration tests' concurrent driver
connections, so a bind race is expected periodically:
java.lang.RuntimeException: com.datastax.oss.simulacron.server.BindNodeException:
Failed to bind NodeSpec{id=0, name='node1', address=null} to /127.0.0.1:49152
This was previously reported in scylladb#951 and closed as fixed by scylladb#942/scylladb#943, but
those PRs only addressed the unrelated TupleTest flake bundled in the same
issue -- the Simulacron port race was never actually fixed, it just happened
not to reproduce on the CI re-run.
Ideally this would be avoided by binding to a fixed, non-ephemeral port via
Server.Builder#withAddressResolver(...), but decompiling the vendored
com.scylladb.oss.simulacron:simulacron-native-server:0.14.0.0 jar shows that
build() unconditionally replaces the resolver with a fresh
`new NodePerPortResolver()` (hardcoded to port 49152) whenever
withMultipleNodesPerIp(true) is set, silently discarding whatever was passed
to withAddressResolver(...). This is a deviation from upstream
datastax/simulacron, where that override is respected. Since the starting
port can't be changed through the public API, setup() now retries the
register-and-bind attempt (fresh Server each time) with a short backoff when
the failure is a BindNodeException, giving the OS a chance to move its
ephemeral allocator off of 49152 before the next attempt.
Verified locally by reproducing the exact reported stack trace: holding
127.0.0.1:49152 with an external socket makes the original code fail
immediately with the same BindNodeException, while the patched code retries
and passes once the port frees up. The unmodified (no collision) case is
unaffected in both timing and behavior.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: QUIET Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🔗 Linked repositories identifiedCodeRabbit considers these linked repositories for cross-repo context during reviews:
Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review. 📝 WalkthroughWalkthrough
Merge Risk: ⚪ Minimal · up to This localized integration-test change retries a specific transient port-binding failure without affecting production behavior; no actionable merge-blocking risk remains beyond normal checks and review. 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Comment |
|
Thanks for investigating this flake. Closing this PR because retrying here does not reliably solve the collision: every new Please create a PR against scylladb/java-simulacron that fixes After releasing the Simulacron fix, please open a follow-up PR here to bump the dependency and configure a collision-safe resolver. |
|
Follow-up: opened scylladb/java-simulacron#11 against Once this is reviewed and released, I'll open a follow-up PR here to bump the Simulacron dependency and configure a collision-safe resolver alongside |
Summary
PeersV2NodeRefreshIToccasionally fails in CI with:Re-running the same job with no code changes typically makes it pass, which is what makes this "just flaky" -- but it's a real, reproducible race, not test infra noise.
Root cause
PeersV2NodeRefreshIT.setup()builds its own SimulacronServerwithwithMultipleNodesPerIp(true)(needed because it emulates several nodes sharing one IP, sincesystem.peers_v2distinguishes nodes by(peer, peer_port)). That mode binds its nodes starting at127.0.0.1:49152-- which is the first port of the OS ephemeral/dynamic port range on both Linux (ip_local_port_range, typically32768-60999) and macOS (49152-65535).That same range is what the kernel hands out as the source port for any outbound client socket on the machine -- including other integration tests' driver connections running concurrently in the same build (this module's failsafe config runs up to 16 test classes in parallel via
<parallel>classes</parallel>/<threadCountClasses>16</threadCountClasses>, generating a lot of concurrent socket churn). When the kernel happens to hand out49152as an ephemeral source port at the exact moment this test tries tobind()it as a listening socket, the bind fails and Simulacron surfaces it asBindNodeException.This was reported before in #951 and closed as "fixed by #942 and #943" -- but those two PRs only address the unrelated
TupleTest.simpleWriteReadTestflake that was bundled into the same issue. Neither touches Simulacron or port binding. The Simulacron race was never actually fixed; the issue was closed because a CI re-run happened to pass, exactly like the case that prompted this PR.I also tried to just fix the starting port directly (
Server.builder().withMultipleNodesPerIp(true).withAddressResolver(new NodePerPortResolver(nonEphemeralPort))), which is what the public Simulacron API is meant to support. It doesn't work with the version this repo depends on: decompiling the vendoredcom.scylladb.oss.simulacron:simulacron-native-server:0.14.0.0jar shows thatServer.Builder#build()unconditionally replaces the configured resolver with a brand newnew NodePerPortResolver()(hardcoded to port 49152) whenevermultipleNodesPerIpistrue, discarding anything passed towithAddressResolver(...). Upstreamdatastax/simulacrondoes not do this (there,withMultipleNodesPerIp(true)only sets the resolver as a default that a laterwithAddressResolver(...)call can still override) -- this looks like a behavior regression specific to the Scylla fork of Simulacron used here. I didn't have access to that fork's source repo to fix it there, so this PR works around it at the call site instead.Fix
Since the starting port can't be changed through the public API in the vendored Simulacron version,
setup()now retries the whole register-and-bind attempt (freshServer+ fresh resolver state each time) with a short backoff (up to 5 attempts, 200ms * attempt) whenever the failure is specifically aBindNodeException. This gives the OS's ephemeral port allocator a chance to move off of49152before the next attempt. Any other exception is rethrown immediately (no masking of real failures), and each failedServeris closed before retrying so its event-loop threads aren't leaked.Testing
Since this doesn't require CCM/a real cluster (just Simulacron), I verified it directly against the compiled test class:
127.0.0.1:49152with an external socket for 1s causessetup()to fail immediately with the identicalBindNodeExceptionstack trace shown above.setup()to retry and pass once the port frees up (observed total time increases proportionally to the hold duration, confirming the retry path is actually exercised, not just a lucky timing coincidence).PeersV2NodeRefreshITdirectly viaorg.junit.runner.JUnitCore(JDK 17) aftermvn -pl integration-tests -am install -DskipTests, both with and without the artificial port collision.fmt-maven-pluginformatter on the changed file; no reformatting was needed.Not verified: the full CCM/Docker-backed integration suite (
make test-integration-scylla) -- this sandbox doesn't have Docker/CCM available. The change is scoped to a single test's@BeforeClass, doesn't touch any shared test-infra code, and compiles cleanly with the rest of theintegration-testsmodule, so this risk is low, but a full CI run is the last remaining check.🤖 Generated with Claude Code