Skip to content

feat: multi-address DNS resolution for contact points and connections (DRIVER-201) - #890

Open
nikagra wants to merge 9 commits into
scylladb:scylla-4.xfrom
nikagra:fix/DRIVER-201-endpoint-resolve-all
Open

feat: multi-address DNS resolution for contact points and connections (DRIVER-201)#890
nikagra wants to merge 9 commits into
scylladb:scylla-4.xfrom
nikagra:fix/DRIVER-201-endpoint-resolve-all

Conversation

@nikagra

@nikagra nikagra commented May 15, 2026

Copy link
Copy Markdown

Problem

DRIVER-201: when a contact point or a cluster node is given as a hostname that maps to multiple IPs (e.g. a DNS round-robin / dynamic-DNS entry), the driver only ever tried the first address — at initial contact, at connection time, and on control-connection reconnect. If that first IP was unreachable the driver raised AllNodesFailedException even though the hostname also resolved to healthy IPs.

This PR fixes DRIVER-201 end-to-end: every such hostname is expanded to all its addresses and each is tried in turn, for every connection the driver opens.

Note: this is a single consolidated PR for DRIVER-201. The work was originally split into #889 (Part 1 — expanding contact-point hostnames in the load-balancing query plan) and #890 (Part 2). #889's approach was an interim one that resolved DNS on the admin event loop under a timeout; it is subsumed here and #889 is closed. Because everything lands together, that interim JVM-DNS path never ships on its own.

Design

Name resolution is a connection-layer concern. ChannelFactory.connect() is the single place that turns "the address this node is known by" into "the addresses to actually try":

  1. EndPoint.resolve() yields one address and does no lookup — it stays safe to call from an event loop.
  2. If that address is a name, ChannelFactory expands it through the bootstrap's Netty AddressResolverGroup.
  3. The candidates are tried in sequence until one connects.
  4. The endpoint is pinned to the address that won, and the pinned copy is what the channel carries.

There is no public API change. EndPoint.resolve() keeps its signature and is not deprecated; third-party implementations keep working unchanged. Its javadoc gains one expectation: return the address as-is rather than looking names up, since resolution now happens in the connection layer.

There is, however, one behaviour change for callers of node.getEndPoint().resolve(), documented on resolve() and in the upgrade guide: for Cloud/SNI and client-route nodes the address is now the configured hostname, unresolved, so getAddress() returns null where it previously returned an IP. Nodes from system.peers and the control node are resolved as before. getHostString() covers both.

Expansion goes through Netty's resolver

Not through InetAddress.getAllByName(). That is the resolver an unresolved address already reached when it was handed to Bootstrap.connect(), so a custom AddressResolverGroup installed via NettyOptions.afterBootstrapInitialized() keeps applying, and Bootstrap.disableResolver() is still honoured. Whether an address needs resolving at all is the resolver's decision (isSupported() / isResolved()), exactly as in Bootstrap#doResolveAndConnect0 — a custom resolver may report an address that already carries an IP as unresolved in order to redirect it, and it still gets that say.

Consequence, unchanged from before this PR: with Netty's default resolver the lookup blocks the I/O event loop it runs on, because DefaultNameResolver calls InetAddress.getAllByName() inline. It is an I/O loop, never the admin loop that connect() is called from. Deployments that need non-blocking resolution can install DnsAddressResolverGroup and have it take effect — for the first time, for the SNI and client-route paths.

One Bootstrap and one EventLoop are picked per logical connect(), and each attempt takes a clone(eventLoop) of the bootstrap. Sharing one loop between resolution and the channel keeps the group's round-robin chooser advancing exactly once per connect (taking a loop for each would park every channel on half the loops), and it means the afterBootstrapInitialized() hook runs once per logical connection rather than once per address.

The candidate loop

  • Each address is tried in turn; when all fail, one error is propagated — chosen by how callers classify errors, rather than whichever address happened to be tried last — with every other failure attached as a suppressed exception, so no per-address cause is lost. The two failures a caller turns into an irreversible verdict (ChannelPool#handleError maps a cluster-name mismatch and a protocol-version rejection to TopologyEvent.forceDown, which nothing in the driver reverses) are surfaced only when every candidate failed that way, or when the rejection is the node-wide one that stopped the loop: one stale record fronting another cluster must not write off a node whose other addresses merely timed out.
  • The candidates are shuffled per connect and capped: at most advanced.connection.max-candidate-addresses (new option, default 5) are tried per attempt. The shuffle spreads load across a name's records and varies the starting address between attempts, with no per-name counter state; the cap bounds what one attempt can cost — worst case cap × connect-timeout, and with wrong credentials at most cap rejected logins. Addresses beyond the cap are not lost: every attempt samples a fresh random subset. 1 restores one-address-per-attempt behavior.
  • One failure is terminal rather than per-address: an UnsupportedProtocolVersionException against a node whose host id is known. Every address of an identified node is that same node, so replaying the whole negotiation ladder against each remaining IP buys nothing. An unidentified endpoint — a contact point, before host ids have been read — keeps going, since one name may expand to addresses of different nodes. That preserves what collapsing a name into a single Node would otherwise have removed: with advanced.resolve-contact-points = true each resolved address used to be its own Node, and ControlConnection advances its query plan on exactly this error. An authentication failure is never terminal, even for an identified node: with a multi-record name it may be specific to the address (a stale record pointing at a foreign cluster fails at AUTH, which runs before the cluster-name check), and the cap above is what bounds the cost of genuinely wrong credentials.
  • The queried name is re-attached to each candidate. A resolver may return results built from raw bytes, or labelled with a canonical/CNAME name of its own; that label would reach the pinned endpoint and hence be what DefaultSslEngineFactory / SniSslEngineFactory make TLS hostname verification check the certificate against. A nameless address is worse still — reading its host name triggers a blocking reverse lookup on the event loop and validation falls back to the IP or the PTR record. So the configured name always wins, which is also what happened before multi-address support, when Netty resolved only the TCP destination and the channel kept the original endpoint. Scoped IPv6 candidates keep their zone.

Pinning: PinnableEndPoint

A name describes a set of addresses, but a channel is connected to exactly one. ChannelFactory pins the endpoint to the address it used and hands that copy to the channel. This matters twice:

  • Node identity. Once the driver has learnt over a connection that host id X answers at a given IP, that node must keep reconnecting to that IP; a node holding the multi-address endpoint could land on a different node later while still being treated as X. Pinning an identified node therefore deliberately removes its multi-IP fallback.
  • No re-resolution on the channel path. SSL engine creation, GSSAPI service-name lookup and DefaultTopologyMonitor#savePort all call resolve() on the channel's endpoint. On a pinned copy that is a field read: it neither blocks on DNS nor risks a different address than the one the channel is on.

A pinned copy is otherwise indistinguishable from the original — same equals, hashCode, asMetricPrefix() and toString() — because nodes adopt pinned copies, and TaggingMetricIdGenerator tags node metrics with the endpoint's toString(). The pinned address is observable only through resolve(); which address a channel is on is in the channel's own toString(), which Netty builds from its remote address. PinnableEndPoint is internal: endpoints that do not implement it are left untouched.

Accepting a candidate: the connect hook

ChannelFactory walks the candidates only while it is opening the channel, and the control node's identity used to be read afterwards — a system.local query over the channel that won — so by the time that read could fail, the remaining addresses were gone.

Advancing the query plan there writes off a whole hostname on the strength of one of its addresses. With a single contact point and the default advanced.reconnect-on-init = false that meant initialization failed outright while a healthy address sat unused.

So the identity read now happens while the factory still holds the remaining candidates, through a caller-supplied async hook. DriverChannelOptions gains an internal ConnectHook plus a timeout (precedent for a behavioral member there: eventCallback); after protocol initialization succeeds on a candidate, ChannelFactory invokes the hook and treats a rejection, a synchronous throw or a timeout as a per-candidate failure — the channel is closed and the loop advances to the endpoint's next address, exactly like an init failure. There is no retry bookkeeping anywhere: the loop that owns the candidate list does the advancing.

ControlConnection supplies the hook only for a node whose host id is unknown — contact points, the one case with something to learn. Its hook runs TopologyMonitor.getChannelNodeInfo (a custom monitor's identity read is honored), rejects a node that reports no host_id — a node that cannot be registered is not an acceptable control node — and channels what it read straight into a per-attempt holder; once the connect completes, the captured NodeInfo is registered without a second read, after asserting it came from the winning channel. A miss (a ChannelFactory subclass that skips the hook) falls back to a direct read. The options are built fresh per attempt, because the holder is stateful and overlapping connect chains are reachable. Pool connections and reconnects to identified nodes carry no hook and send exactly the bytes they sent before.

REGISTER moved out of the init handshake to keep its ordering property — identity is validated before the channel registers for events. ChannelFactory sends it through AdminRequestHandler after the hook accepts, under the same init-query timeout; a registration failure is a per-candidate failure, as it was as an init step, and the CLIENT_ROUTES_CHANGE rejection keeps its clear message. The one visible cost: the window in which a live channel is not yet registered for events grows by the hook's round trip. Init itself ends at the cluster-name check (or SET_KEYSPACE), byte-identical to the pre-multi-address exchange, which is what ProtocolVersionMixedClusterIT pins; the wire cost of identification is unchanged — one SELECT * FROM system.local WHERE key='local' per contact-point connection, now inside the attempt instead of after it.

Two consequences are visible, both stated against upstream:

  1. A contact point that exhausts every address on identity failures now fires controlConnectionFailed and is marked DOWN pre-init — the treatment connect-phase failures already get, which the plan advance did not do.
  2. AllNodesFailedException reports one entry per contact point, with each address's failure attached as a suppressed exception.

Node registration is the residual: MetadataManager.registerNode() still runs after the connect and can still fail on its own, which advances the query plan as it does upstream. Unlike the identity read it is not address-specific, so there is no reason to think another address of the same name would register any better. And the first-init imprint (cluster name, product type, negotiated protocol version) still runs on init success, so a candidate the hook then rejects has already imprinted — like every other channel abandoned after init, identical to the behavior before this series.

Changes

Contact points stay unresolved

SessionBuilder / ContactPoints no longer resolve contact-point hostnames up front, so the query plan holds one unresolved node per contact point instead of one node per IP. advanced.resolve-contact-points is deprecated and has no effect. A hostname passed programmatically is expanded too, as long as the InetSocketAddress is unresolved (createUnresolved) — an already-resolved one is used as provided, which is what programmatic contact points did before this PR as well.

Endpoints

  • DefaultEndPoint returns its address as-is, resolved or not, and implements PinnableEndPoint.
  • SniEndPoint no longer re-resolves the proxy hostname on every resolve() call; the connection layer expands it, so all proxy A-records are tried within one attempt and a custom Netty resolver applies. A proxy hostname is stored unresolved whichever form it arrived in, so withCloudProxyAddress(new InetSocketAddress("proxy", 9042)) — which resolves eagerly — is not frozen on one proxy IP.
  • ClientRoutesEndPoint returns the route's hostname unresolved instead of resolving it itself, so client-route hostnames are expanded by the connection layer too.

Control-connection reconnection query plan (folded from #889 review)

LoadBalancingPolicyWrapper.newControlReconnectionQueryPlan() composes the contact-point fallback as CompositeQueryPlan(regularPlan, new SimpleQueryPlan(contactNodes)) instead of mutating the policy's plan: built-in QueryPlans reject add()/addAll(), so the previous addAll(...) threw UnsupportedOperationException on every post-init control reconnect once the fallback defaulted on. The fallback is also kept when the live-node plan is empty, even for re-resolving topology monitors, so reconnection can still recover when there is nothing else to try.

NettyOptions.afterBootstrapInitialized

Contract documented: the driver installs its own handler afterwards, so a handler set by the hook is replaced (now warned about once), and the resolver configured there is what the driver expands names with.

OptionalLocalDcHelper

Removes the dead checkLocalDatacenterCompatibility() check. It warned when a contact point's datacenter differed from the configured local DC, but contact-point nodes never get a datacenter assigned during refresh, so it compared against null and could never reflect a real mismatch — while it could fire spuriously. The separate "configured local DC matches no node" warning is retained. Unrelated to the DNS fix itself; called out because it touches a protected extension point.

Tests

  • ChannelFactoryNettyResolverTest — expansion through a custom resolver, disableResolver(), an already-resolved address passed through, a resolver redirecting an already-resolved address, resolution and connect sharing one event loop, a resolver throwing synchronously, and a resolver whose expansion comes back still unresolved (diagnosed by name when nothing is left; dropped before the cap when something is).
  • ChannelFactoryMultiAddressTest — fallback across candidates with suppressed causes (deduplicated, classified error surfaced), a lone cluster-name mismatch not promoted over another address's transport failure, shuffle-without-loss and seeded-order determinism, the candidate cap (loop-level, truncation, clamp to 1), authentication advancing on contact points and identified nodes alike, hostname re-attachment (nameless, CNAME-labelled, IPv6, scoped IPv6, no-name original), and the guards that fail the connect future instead of hanging it.
  • ChannelFactoryConnectHookTest — the hook runs after init and before the candidate completes; rejection, synchronous throw and timeout each advance to the next address (or fail the connect on the last one, cause preserved); a throw in the step after the hook accepts fails the candidate and closes its channel rather than hanging the attempt; REGISTER is sent only after the hook accepts, is skipped without events, fails per-candidate, and the CLIENT_ROUTES_CHANGE rejection keeps its message.
  • ChannelFactoryPinnedEndPointTest, ChannelFactoryBootstrapHookTest, ChannelFactoryProtocolNegotiationTest — pinning, the bootstrap-hook contract, the terminal-vs-retryable version rejection, and a node-wide rejection still surfacing when an earlier address failed on transport.
  • DropwizardNodeMetricUpdaterTest — a rebuilt node updater adopts the pending metrics-expiration countdown from the one it replaces (cancelled there, re-armed here, nothing to hand over twice).
  • DefaultEndPointTest / SniEndPointTest / ClientRoutesEndPointTest — resolve/pin semantics and pin-invisible identity; DefaultNodeTest — endpoint adoption and metric-updater rebuilds; AddressUtilsTest — name vs IP literal.
  • LoadBalancingPolicyWrapperTest — real QueryPlan stubs (the earlier mutable LinkedList stub masked the crash), plus empty-plan and re-resolving-monitor cases.
  • ProtocolInitHandlerTest — init ends at the cluster-name check (or SET_KEYSPACE), with no Register frame even when events are requested: the invariant that keeps the handshake byte-for-byte the pre-multi-address exchange.
  • ControlConnectionTest — the connect hook is armed (with a timeout) for a node whose host id is unknown and absent for one already identified; the hook rejects a NodeInfo without a host id; the captured NodeInfo reaches registerNode with no second identity read.
  • ProtocolVersionMixedClusterITunmodified, and the load-bearing check on the handshake: it pins the exact init sequence, so it is the proof that no bytes changed.
  • MockResolverIT — end-to-end against a live cluster with a JVM-level DNS hook, including a multi-record name carrying a dead record. With a shuffled order the dead record is dialed first in about a third of attempts, so that case loops short-lived sessions (bounded at 20; miss probability (2/3)^20 ≈ 0.03%) until the captured ChannelFactory DEBUG log shows the "trying next address" fall-through for the dead record.

Verified on JDK 11 at the current head: full core unit suite (4003 tests), integration-tests test-compile, javadoc:javadoc, fmt:check, and a per-commit compile of all 9 commits. MockResolverIT's rework has not been run against a live cluster locally; it rides CI.

History

2026-08-14 — self-review pass, folded into the nine commits as fixups (no new commits; the tree is the only thing that changed). Four fixes in the candidate loop, none of them reachable from a happy path but all of them from a plausible one: (1) the error chosen when every candidate fails could still be a lone cluster-name mismatch or protocol-version rejection when it happened to be the last one tried, forcing a healthy node down on the strength of the single address that was never going to work — the unanimity rule above now also covers the fallback, with the node-wide rejection as its documented exception; (2) a throw from the step that runs after the connect hook accepts (the init-query-timeout config read) escaped into a CompletionStage callback nobody consumes, after the hook timeout had already been cancelled, leaving the attempt hung forever rather than failing it; (3) the abandon path force-closed its channel unconditionally, so a hook stage completing off the channel's event loop — which the contract allows — could kill a channel the caller had just been handed, with completeExceptionally silently no-oping; (4) the connect listener's blanket catch failed the candidate without closing the socket it had opened. Plus a diagnostic for a resolver that returns unresolved addresses from resolveAll (previously an opaque UnresolvedAddressException from inside Netty's doConnect, which is exactly what the pass-through guard exists to prevent), the metrics-expiration handover finally under test, and small cleanups: a doc comment that had drifted off ControlNodeState, one duplicated else if arm, a doubled exclusionReason() call, a comment for the benign RUNNING → CLOSING window in newControlReconnectionQueryPlan(), and thread-safe log capture in MockResolverIT (driver I/O threads append to the appender's list while the assertions read it).

2026-08-13 — reworked per review (the changes-requested round of 2026-08-12), and rebased onto the current scylla-4.x tip (the branch had fallen behind the config-reporting merges and stopped being mergeable, which is also why the previous head got no CI). The acceptance mechanism is now the async connect hook described above, replacing the GET_NODE_INFO init step, its fetchNodeInfo flag and the stashed-row plumbing (DriverChannel.NODE_INFO_ROW_KEY / takeNodeInfoRow() are gone; DefaultTopologyMonitor.getChannelNodeInfo is back to a plain query); REGISTER moved out of the handshake behind the hook. The deterministic per-name rotation is replaced by a per-connect shuffle, and the new advanced.connection.max-candidate-addresses cap bounds the number of candidates one attempt dials. With the cap in place, the session-lifetime auth memoization from the 2026-08-11 round is dropped and an authentication failure is no longer terminal for an identified node — no single address's failure writes off an endpoint.

2026-08-11 — the ControlConnection same-entry retry that ff9d8a5095 had introduced is replaced, not layered on: the identity read moved into the init handshake instead, which is what the review comment on ChannelFactory.java:882 asked for. That retry leaned on the per-connect rotation counter to reach another address — bounded and correct, but best-effort by construction, since the counter exists to spread load rather than enumerate addresses. It was a single self-contained commit and the only one of the nine touching ControlConnection, so it was dropped rather than unpicked; only its upgrade-guide bullet travelled with it. ControlConnection is back to near-upstream shape and the PR is smaller for it: churn 4194+/341− against the previous head's 4308+/440−, still exactly equal to the net diff. The full battery above ran green on a stacked branch before this force-push, so no intermediate state was published.

Regrouped on 2026-08-06 into 9 per-concern commits, rebased onto the current scylla-4.x tip.

The branch had grown to 39 commits carrying roughly 1700 added lines that a later commit deleted again — the withdrawn EndPoint.resolveAll() API, a driver-owned resolver thread pool, and two test classes added and then removed. The repo rebase-merges, so all of that would have landed on scylla-4.x verbatim. Churn now equals the net diff exactly, no commit adds anything a later one deletes, and each commit compiles standalone under -Werror. The tree is unchanged by the regroup: git diff against the pre-regroup branch is empty.

The round-by-round detail lives in the review threads below. The larger course corrections, for the record:

  • The first design added an EndPoint.resolveAll() API and had endpoints do their own JVM DNS. Withdrawn: it bypassed a custom Netty resolver, and it put a blocking lookup behind a public method the driver calls from an event loop. Resolution moved into ChannelFactory and the API addition was dropped, along with the resolve() deprecation and every @SuppressWarnings("deprecation") it had required.
  • The interim query-plan-time expansion from fix: expand contact point hostnames to all DNS IPs at connection time (DRIVER-201) — Part 1/2 #889 (MetadataManager.getResolvedContactPoints(), its resolver executor and 3s timeout) is removed: connection-time expansion covers control-connection init and pool connections alike.
  • A driver-owned resolver thread pool existed briefly and is gone with it — resolution runs on the channel's own event loop, so there is no pool to size or shut down.

On the suggestion that fallback-to-original-contact-points be reverted to false: its stated reason was that the flip "enables the blocking DNS fallback path by default", and that path no longer exists — the fallback appends unresolved hostnames and does no DNS at plan time. Moving resolution to the connection layer also makes the flip more necessary, not less: PinnableEndPoint binds the control node to the single address its connection reached, so it no longer re-expands, leaving the contact-point fallback as the only path back to a changed DNS record. TopologyMonitor.reresolvesNodeAddresses() documents that dependency.

Deliberately not fixed here:

  • Duplicate resolver candidates are not collapsed, so a name with a repeated A record spends an extra connect-timeout on an address it already tried. Filed as Duplicate DNS records cause repeated connection attempts to the same address #989.
  • The contact points appended by the reconnection fallback are not deduplicated against the live-node plan either, because at plan time they are hostnames while the live nodes are already-resolved IPs. Same shape as Duplicate DNS records cause repeated connection attempts to the same address #989; the cost is documented in reference.conf and the upgrade guide.
  • DefaultNode.setEndPoint()'s clear→swap→build sequence is not atomic against concurrent metric writes: a write landing in the window goes through the cleared updater, which re-registers on demand, resurrecting one series. Strictly better than the previous ordering, which permanently deleted the new series; closing it properly means having clearMetrics() take the ids to clear rather than recomputing them, in every metrics implementation. Documented in the code.

@nikagra
nikagra marked this pull request as draft May 15, 2026 18:18
nikagra added a commit to nikagra/java-driver that referenced this pull request May 15, 2026
…VER-201)

newControlReconnectionQueryPlan() now creates copies of the original
contact-point nodes (with their unresolved hostname endpoints) instead
of synthetic nodes with resolved IPs. This ensures the control channel
carries the hostname endpoint, which is preserved in metadata after
topology refresh.

DNS expansion for connection fallback is handled by ChannelFactory
(PR scylladb#890), so the control-reconnection path does not need to inject
resolved-IP nodes into the query plan.

Also adds getContactPoints() stub back to LoadBalancingPolicyWrapperTest
so tests that cover the control-reconnect path continue to pass.
nikagra added a commit to nikagra/java-driver that referenced this pull request May 15, 2026
Before-init query plan now uses getContactPoints() (original unresolved
hostname nodes) instead of getResolvedContactPoints(). The DNS expansion
to all IPs happens at the ChannelFactory level (PR scylladb#890), so expanding
here was redundant and broke should_connect_with_mocked_hostname by
replacing hostname endpoints with resolved-IP endpoints.

Also remove the should_connect_when_first_dns_entry_is_non_responsive
integration test from this PR; it belongs in PR scylladb#890 where ChannelFactory
expansion actually enables it to pass.
@nikagra
nikagra requested a balanced review from Copilot May 19, 2026 23:02

Copilot AI 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.

Pull request overview

Part 2/2 of DRIVER-201: extends the EndPoint API and ChannelFactory so that a hostname mapping to multiple IPs is tried address-by-address at the connection layer, instead of only the first IP. The EndPoint.resolve() method is deprecated in favor of a new resolveAll() default method; DefaultEndPoint, SniEndPoint, and ClientRoutesEndPoint override it; ChannelFactory.connect() now iterates over candidates and only fails when all are exhausted, while keeping protocol-version downgrade scoped to a single address.

Changes:

  • Add EndPoint.resolveAll() (default impl delegating to deprecated resolve()); override in DefaultEndPoint, SniEndPoint, ClientRoutesEndPoint.
  • Rework ChannelFactory.connect() into tryNextCandidate / connectToAddress so per-address failures fall back to the next IP while protocol-version downgrades stay scoped to one address.
  • Add unit tests for DefaultEndPoint.resolveAll() and a new SniEndPointTest.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
core/src/main/java/com/datastax/oss/driver/api/core/metadata/EndPoint.java Deprecates resolve(); adds default resolveAll() method.
core/src/main/java/com/datastax/oss/driver/internal/core/metadata/DefaultEndPoint.java Overrides resolveAll() using InetAddress.getAllByName with single-address fallback.
core/src/main/java/com/datastax/oss/driver/internal/core/metadata/SniEndPoint.java Overrides resolveAll() returning one address per sorted A-record.
core/src/main/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesEndPoint.java Overrides resolveAll() to wrap the single topology-monitor address.
core/src/main/java/com/datastax/oss/driver/internal/core/channel/ChannelFactory.java Adds candidate-iteration and per-address protocol-negotiation methods.
core/src/test/java/com/datastax/oss/driver/internal/core/metadata/DefaultEndPointTest.java New tests for resolveAll() (resolved, unresolved expansion, unresolvable fallback).
core/src/test/java/com/datastax/oss/driver/internal/core/metadata/SniEndPointTest.java New test class covering SNI resolveAll() happy path, unresolvable host, and resolve() sanity check.
Comments suppressed due to low confidence (1)

core/src/main/java/com/datastax/oss/driver/internal/core/channel/ChannelFactory.java:303

  • When connectToAddress fails with UnsupportedProtocolVersionException.forNegotiation (i.e. all protocol downgrades exhausted), tryNextCandidate will treat this like any other per-address failure and try the next IP, even though the protocol-negotiation failure is a server-wide condition that will recur on every other IP of the same node. This also reuses the shared attemptedVersions CopyOnWriteArrayList across candidates, so on each subsequent address the downgrade loop re-attempts the same protocol versions and adds duplicate entries, and the final exception ultimately reported will list each version multiple times. Consider distinguishing non-address-specific failures (UnsupportedProtocolVersionException, authentication errors, etc.) and short-circuiting the candidate loop in those cases.
    perAddressFuture.whenComplete(
        (channel, error) -> {
          if (error == null) {
            resultFuture.complete(channel);
          } else if (index + 1 < candidates.length) {
            LOG.debug(
                "[{}] Failed to connect to {} ({}), trying next address",
                logPrefix,
                candidate,
                error.getMessage());
            tryNextCandidate(
                endPoint,
                shardingInfo,
                shardId,
                options,
                nodeMetricUpdater,
                currentVersion,
                isNegotiating,
                attemptedVersions,
                resultFuture,
                candidates,
                index + 1);
          } else {
            // Note: might be completed already if the failure happened in initializer()
            resultFuture.completeExceptionally(error);
          }
        });

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread core/src/main/java/com/datastax/oss/driver/api/core/metadata/EndPoint.java Outdated
@nikagra
nikagra force-pushed the fix/DRIVER-201-endpoint-resolve-all branch from 05553f3 to f631971 Compare May 29, 2026 14:47
@coderabbitai

coderabbitai Bot commented May 29, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds EndPoint.resolveAll() and deprecates single-address resolve(). Default, SNI, and client-route endpoints now provide candidate addresses. ChannelFactory tries resolved candidates sequentially, including protocol downgrade handling. Contact points are expanded through MetadataManager and used in query planning and control-connection reconnection. Reconnection defaults and topology-monitor behavior are updated, while local-datacenter discovery no longer checks contact-point compatibility. Tests cover endpoint resolution, connection guards, metadata expansion, query plans, and integration behavior.

Sequence Diagram(s)

sequenceDiagram
  participant ChannelFactory
  participant EndPoint
  participant tryNextCandidate
  participant connectToAddress
  participant resultFuture

  ChannelFactory->>EndPoint: resolveAll()
  EndPoint-->>ChannelFactory: SocketAddress[] candidates
  ChannelFactory->>tryNextCandidate: attempt candidate at index 0
  tryNextCandidate->>connectToAddress: connect using perAddressFuture
  alt connection succeeds
    connectToAddress-->>tryNextCandidate: DriverChannel
    tryNextCandidate->>resultFuture: complete successfully
  else connection or negotiation fails
    connectToAddress-->>tryNextCandidate: complete perAddressFuture exceptionally
    tryNextCandidate->>tryNextCandidate: attempt next candidate
  end
  tryNextCandidate->>resultFuture: fail after all candidates
Loading

Suggested reviewers: copilot, dkropachev

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.47% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the primary change: multi-address DNS resolution for contact points and connections.
Description check ✅ Passed The description is directly related to the changes and explains the multi-address resolution design, behavior, and tests.

Comment @coderabbitai help to get the list of available commands.

@nikagra
nikagra force-pushed the fix/DRIVER-201-endpoint-resolve-all branch from f631971 to 860a34d Compare May 29, 2026 20:16

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@core/src/main/java/com/datastax/oss/driver/internal/core/channel/ChannelFactory.java`:
- Around line 222-242: The code calls endPoint.resolveAll() and passes the
resulting candidates array into tryNextCandidate() which immediately indexes
candidates[0]; guard against null or empty results by validating the output of
endPoint.resolveAll()—if it returns null or candidates.length == 0, complete
resultFuture exceptionally (or create a specific error) and return; otherwise
call tryNextCandidate(...) with the non-empty candidates. Update the block
around resolveAll(), candidates, and the call to tryNextCandidate() to perform
this check and fail fast via resultFuture.completeExceptionally when
appropriate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7ad3d5b5-6473-4c88-8777-93861f5de639

📥 Commits

Reviewing files that changed from the base of the PR and between c830c20 and 860a34d.

📒 Files selected for processing (12)
  • core/src/main/java/com/datastax/dse/driver/api/core/auth/DseGssApiAuthProviderBase.java
  • core/src/main/java/com/datastax/dse/driver/internal/core/insights/InsightsClient.java
  • core/src/main/java/com/datastax/oss/driver/api/core/metadata/EndPoint.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/channel/ChannelFactory.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesEndPoint.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/metadata/DefaultEndPoint.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/metadata/DefaultTopologyMonitor.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/metadata/SniEndPoint.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/ssl/DefaultSslEngineFactory.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/ssl/SniSslEngineFactory.java
  • core/src/test/java/com/datastax/oss/driver/internal/core/metadata/DefaultEndPointTest.java
  • core/src/test/java/com/datastax/oss/driver/internal/core/metadata/SniEndPointTest.java

@nikagra
nikagra force-pushed the fix/DRIVER-201-endpoint-resolve-all branch from 860a34d to a6d0e48 Compare May 29, 2026 22:00
@nikagra
nikagra marked this pull request as ready for review May 29, 2026 22:00

@coderabbitai coderabbitai Bot 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.

🧹 Nitpick comments (1)
core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryResolveAllGuardTest.java (1)

37-37: ⚡ Quick win

Consider adding test coverage for resolveAll() throwing an exception.

The ChannelFactory.connect() implementation includes a catch block for exceptions thrown by resolveAll() (see context snippet 1, line 232). Adding a third test case where the mocked EndPoint.resolveAll() throws an exception (e.g., UnknownHostException) would ensure all three defensive paths are tested:

  1. ✓ Returns null (covered)
  2. ✓ Returns empty array (covered)
  3. ✗ Throws exception (not covered)
📋 Suggested test case
`@Test`
public void should_fail_future_when_resolve_all_throws_exception() {
  // Given
  when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
  when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
  ChannelFactory factory = newChannelFactory();

  EndPoint badEndPoint = mock(EndPoint.class);
  RuntimeException testException = new RuntimeException("DNS lookup failed");
  when(badEndPoint.resolveAll()).thenThrow(testException);

  // When
  CompletionStage<DriverChannel> channelFuture =
      factory.connect(
          badEndPoint, null, null, DriverChannelOptions.DEFAULT, NoopNodeMetricUpdater.INSTANCE);

  // Then – future must complete exceptionally with the thrown exception
  assertThatStage(channelFuture)
      .isFailed(e -> assertThat(e).isSameAs(testException));
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryResolveAllGuardTest.java`
at line 37, Add a third test in ChannelFactoryResolveAllGuardTest that verifies
ChannelFactory.connect() propagates exceptions thrown by EndPoint.resolveAll():
mock an EndPoint (e.g., badEndPoint) to throw a RuntimeException (or
UnknownHostException) from resolveAll(), create the factory via
newChannelFactory(), call factory.connect(badEndPoint, ...) with
DriverChannelOptions.DEFAULT and NoopNodeMetricUpdater.INSTANCE, and assert the
returned CompletionStage<DriverChannel> completes exceptionally with the same
exception; this mirrors the existing tests for null/empty resolveAll() and
targets the catch path in ChannelFactory.connect().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryResolveAllGuardTest.java`:
- Line 37: Add a third test in ChannelFactoryResolveAllGuardTest that verifies
ChannelFactory.connect() propagates exceptions thrown by EndPoint.resolveAll():
mock an EndPoint (e.g., badEndPoint) to throw a RuntimeException (or
UnknownHostException) from resolveAll(), create the factory via
newChannelFactory(), call factory.connect(badEndPoint, ...) with
DriverChannelOptions.DEFAULT and NoopNodeMetricUpdater.INSTANCE, and assert the
returned CompletionStage<DriverChannel> completes exceptionally with the same
exception; this mirrors the existing tests for null/empty resolveAll() and
targets the catch path in ChannelFactory.connect().

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b702fd48-9ba7-4994-8bb9-351438fb02a8

📥 Commits

Reviewing files that changed from the base of the PR and between 860a34d and a6d0e48.

📒 Files selected for processing (13)
  • core/src/main/java/com/datastax/dse/driver/api/core/auth/DseGssApiAuthProviderBase.java
  • core/src/main/java/com/datastax/dse/driver/internal/core/insights/InsightsClient.java
  • core/src/main/java/com/datastax/oss/driver/api/core/metadata/EndPoint.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/channel/ChannelFactory.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesEndPoint.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/metadata/DefaultEndPoint.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/metadata/DefaultTopologyMonitor.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/metadata/SniEndPoint.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/ssl/DefaultSslEngineFactory.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/ssl/SniSslEngineFactory.java
  • core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryResolveAllGuardTest.java
  • core/src/test/java/com/datastax/oss/driver/internal/core/metadata/DefaultEndPointTest.java
  • core/src/test/java/com/datastax/oss/driver/internal/core/metadata/SniEndPointTest.java
✅ Files skipped from review due to trivial changes (5)
  • core/src/main/java/com/datastax/oss/driver/internal/core/ssl/SniSslEngineFactory.java
  • core/src/main/java/com/datastax/dse/driver/api/core/auth/DseGssApiAuthProviderBase.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/ssl/DefaultSslEngineFactory.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/metadata/DefaultTopologyMonitor.java
  • core/src/main/java/com/datastax/dse/driver/internal/core/insights/InsightsClient.java
🚧 Files skipped from review as they are similar to previous changes (7)
  • core/src/test/java/com/datastax/oss/driver/internal/core/metadata/SniEndPointTest.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesEndPoint.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/metadata/DefaultEndPoint.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/metadata/SniEndPoint.java
  • core/src/test/java/com/datastax/oss/driver/internal/core/metadata/DefaultEndPointTest.java
  • core/src/main/java/com/datastax/oss/driver/api/core/metadata/EndPoint.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/channel/ChannelFactory.java

@nikagra
nikagra requested a review from dkropachev May 29, 2026 23:39
@nikagra
nikagra force-pushed the fix/DRIVER-201-endpoint-resolve-all branch from a6d0e48 to f9265b3 Compare May 29, 2026 23:43
@nikagra

nikagra commented May 29, 2026

Copy link
Copy Markdown
Author

🤖: Valid nitpick. Added a third test should_fail_future_when_resolve_all_throws_exception() to ChannelFactoryResolveAllGuardTest that mocks resolveAll() to throw a RuntimeException and asserts the future completes exceptionally with the same exception instance, covering the catch block in ChannelFactory.connect(). All three defensive paths are now tested: null return, empty array, and thrown exception.

@nikagra
nikagra force-pushed the fix/DRIVER-201-endpoint-resolve-all branch from f9265b3 to 4448119 Compare June 23, 2026 11:38
Copilot AI review requested due to automatic review settings July 21, 2026 22:48
@nikagra
nikagra force-pushed the fix/DRIVER-201-endpoint-resolve-all branch from 4448119 to 1c8dfa2 Compare July 21, 2026 22:48
@nikagra

nikagra commented Jul 21, 2026

Copy link
Copy Markdown
Author

Rebased this PR (Part 2/2) on top of #889 (30a585f) so it now stacks cleanly on Part 1 and refreshes onto current scylla-4.x. Base stays scylla-4.x; the incremental diff will be clean once #889 merges. New head: 1c8dfa2.

Also addressed the outstanding review feedback:

  • SNI round-robin (@dkropachev): SniEndPoint.resolveAll() now rotates the returned candidate order using the same OFFSET counter as resolve() — healthy connections are spread across proxy IPs while the full record set is still returned for in-connection fallback. Added a rotation/completeness test.

Previously-addressed items (Copilot / CodeRabbit) remain in place after the rebase: N×timeout Javadoc on resolveAll(), calling-thread DNS note on DefaultEndPoint, @SuppressWarnings("deprecation") on the 5 internal single-address callers, and the ChannelFactory null/empty-array guard with ChannelFactoryResolveAllGuardTest.

Verified locally on JDK 11: SniEndPointTest, DefaultEndPointTest, ChannelFactoryResolveAllGuardTest, and the full ChannelFactory*Test suite all pass.

Copilot AI 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.

Pull request overview

Copilot reviewed 29 out of 29 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

core/src/main/java/com/datastax/oss/driver/internal/core/metadata/DefaultEndPoint.java:62

  • NETTY_ADMIN_SIZE only configures the number of admin event-loop threads (DefaultDriverOption.java:807-811); it does not configure an AddressResolverGroup. This link gives users an incorrect way to identify or change the resolver. Refer to a custom NettyOptions bootstrap hook instead, or omit the configuration link.
   * <p><b>Note on resolver:</b> DNS lookup is performed via {@link
   * InetAddress#getAllByName(String)} on the calling thread, bypassing any custom Netty {@code
   * AddressResolverGroup} configured via {@link
   * com.datastax.oss.driver.api.core.config.DefaultDriverOption#NETTY_ADMIN_SIZE}. This is

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@core/src/main/java/com/datastax/oss/driver/api/core/config/TypedDriverOption.java`:
- Line 603: Update the public Javadoc for the reconnection-plan option in
TypedDriverOption to state that it appends DNS-expanded candidates returned by
getResolvedContactPoints(), rather than raw original contact points, and that
monitors which re-resolve addresses skip this behavior; retain the documented
default of true.

In
`@core/src/main/java/com/datastax/oss/driver/internal/core/metadata/LoadBalancingPolicyWrapper.java`:
- Around line 147-153: Prevent blocking DNS resolution from query-plan creation
by moving MetadataManager.getResolvedContactPoints() off the caller thread or
introducing bounded caching before using its results. Apply the fix to the
BEFORE_INIT/DURING_INIT path in
core/src/main/java/com/datastax/oss/driver/internal/core/metadata/LoadBalancingPolicyWrapper.java:147-153
and the control-reconnect path in
core/src/main/java/com/datastax/oss/driver/internal/core/metadata/LoadBalancingPolicyWrapper.java:164-184;
update core/src/main/resources/reference.conf:2321-2334 if needed so
fallback-to-original-contact-points is not enabled without bounded, non-blocking
resolution.

In `@core/src/main/resources/reference.conf`:
- Around line 2321-2334: The default for fallback-to-original-contact-points
must not enable the blocking DNS fallback path; change this configuration
default back to false while preserving the existing setting name and
documentation.

In
`@core/src/test/java/com/datastax/oss/driver/internal/core/metadata/MetadataManagerTest.java`:
- Around line 512-529: The test should enforce expansion to the complete DNS
result set, not merely verify that one resolved node exists. Update
should_expand_unresolved_hostname_to_all_ips to obtain
InetAddress.getAllByName("localhost"), compare the returned node count and
endpoint addresses against all expected addresses on port 9042, and retain the
resolved-address assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 648940a1-36ee-47f0-8f02-aff008723307

📥 Commits

Reviewing files that changed from the base of the PR and between 4448119 and 1c8dfa2.

📒 Files selected for processing (29)
  • core/src/main/java/com/datastax/dse/driver/api/core/auth/DseGssApiAuthProviderBase.java
  • core/src/main/java/com/datastax/dse/driver/internal/core/insights/InsightsClient.java
  • core/src/main/java/com/datastax/oss/driver/api/core/config/DefaultDriverOption.java
  • core/src/main/java/com/datastax/oss/driver/api/core/config/OptionsMap.java
  • core/src/main/java/com/datastax/oss/driver/api/core/config/TypedDriverOption.java
  • core/src/main/java/com/datastax/oss/driver/api/core/metadata/EndPoint.java
  • core/src/main/java/com/datastax/oss/driver/api/core/session/SessionBuilder.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/channel/ChannelFactory.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/loadbalancing/helper/OptionalLocalDcHelper.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesEndPoint.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesTopologyMonitor.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/metadata/CloudTopologyMonitor.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/metadata/DefaultEndPoint.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/metadata/DefaultTopologyMonitor.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/metadata/LoadBalancingPolicyWrapper.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/metadata/MetadataManager.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/metadata/SniEndPoint.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/metadata/TopologyMonitor.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/ssl/DefaultSslEngineFactory.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/ssl/SniSslEngineFactory.java
  • core/src/main/resources/reference.conf
  • core/src/test/java/com/datastax/dse/driver/internal/core/insights/InsightsClientTest.java
  • core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryResolveAllGuardTest.java
  • core/src/test/java/com/datastax/oss/driver/internal/core/metadata/DefaultEndPointTest.java
  • core/src/test/java/com/datastax/oss/driver/internal/core/metadata/LoadBalancingPolicyWrapperTest.java
  • core/src/test/java/com/datastax/oss/driver/internal/core/metadata/MetadataManagerTest.java
  • core/src/test/java/com/datastax/oss/driver/internal/core/metadata/SniEndPointTest.java
  • integration-tests/src/test/java/com/datastax/oss/driver/core/heartbeat/HeartbeatIT.java
  • integration-tests/src/test/java/com/datastax/oss/driver/core/resolver/MockResolverIT.java
🚧 Files skipped from review as they are similar to previous changes (11)
  • core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryResolveAllGuardTest.java
  • core/src/main/java/com/datastax/dse/driver/api/core/auth/DseGssApiAuthProviderBase.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/metadata/DefaultTopologyMonitor.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesEndPoint.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/ssl/SniSslEngineFactory.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/metadata/SniEndPoint.java
  • core/src/main/java/com/datastax/oss/driver/api/core/metadata/EndPoint.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/channel/ChannelFactory.java
  • core/src/main/java/com/datastax/dse/driver/internal/core/insights/InsightsClient.java
  • core/src/main/java/com/datastax/oss/driver/internal/core/metadata/DefaultEndPoint.java
  • core/src/test/java/com/datastax/oss/driver/internal/core/metadata/DefaultEndPointTest.java

Comment thread core/src/main/java/com/datastax/oss/driver/api/core/config/TypedDriverOption.java Outdated
Comment thread core/src/main/resources/reference.conf
nikagra added a commit to nikagra/java-driver that referenced this pull request Jul 23, 2026
… (DRIVER-201)

When RESOLVE_CONTACT_POINTS=false (the default) a hostname contact point was
stored as a single unresolved InetSocketAddress, so the query plan tried only
the first DNS IP. Keep contact points unresolved and expand each hostname to all
its DNS IPs at query-plan time via MetadataManager.getResolvedContactPoints(),
so the driver falls back to the next candidate when one IP is unreachable.

Resolution is bounded, concurrent and best-effort. getResolvedContactPoints()
runs on the admin event loop, where nothing should block, so each blocking
InetAddress.getAllByName() call is offloaded to a cached daemon-thread pool and
all unresolved hostnames are resolved concurrently against a single
CONTACT_POINT_RESOLUTION_TIMEOUT deadline. A cached pool (rather than one shared
thread) means each hostname resolves on its own thread, so one slow or blackholed
lookup cannot starve the sibling contact points, nor the next reconnect that
would otherwise queue behind it. If a hostname cannot be resolved or resolution
times out, the original unresolved contact point is kept as-is rather than
dropped, so the query plan is never emptier than the configured contact points
and the address can still be resolved later at connection time (as it was before
DNS expansion existed). This is an interim mitigation, superseded by scylladb#890's
non-blocking EndPoint.resolveAll().

Default advanced.control-connection.reconnection.fallback-to-original-contact-points
to true (no longer Experimental): it is the DNS re-resolution path on reconnect.
Metadata nodes hold an already-resolved endpoint that is never re-resolved, so
falling back to the original unresolved contact points re-expands the hostname
to its current DNS IPs.

Document that DNS-expanded contact points are IP-backed connection candidates
that may be persisted in metadata, and that each synthetic endpoint retains the
original hostname (built from the resolved InetAddress) so TLS peer host / SNI /
hostname verification keep using the configured hostname.

Gate the control-connection reconnection contact-point fallback behind a new
TopologyMonitor.reresolvesNodeAddresses() (default false; true for the
proxy-based ClientRoutesTopologyMonitor and CloudTopologyMonitor). Those
monitors reach nodes through endpoints that already re-resolve on every
connection attempt and maintain an authoritative node set, so appending raw
contact points to their reconnection plan is unnecessary and could resurrect
removed nodes (PrivateLink/Cloud regression safety). The reconnection plan also
appends the contact points only once the load balancing policy is RUNNING, so
the pre-init plan (already built from the resolved contact points) is not
duplicated or re-resolved.

Remove OptionalLocalDcHelper.checkLocalDatacenterCompatibility(): it warned when
a contact point reported a different datacenter than the configured local DC.
Since commit 12e6acb switched initial metadata refresh to hostId-only
matching, contact-point nodes are never reused and their datacenter stays null;
comparing a configured local DC against that null made the check fire as a false
positive for every contact point whenever local-datacenter was set on the
default profile, rather than surface a real mismatch. The node-based "configured
local DC matches no node" warning (against discovered nodes whose datacenters are
populated) is retained, so the only user-visible effect is that the spurious
warning is no longer emitted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 6, 2026 16:29
@nikagra
nikagra force-pushed the fix/DRIVER-201-endpoint-resolve-all branch from ff9d8a5 to 2b64ec2 Compare August 6, 2026 16:29
@nikagra

nikagra commented Aug 6, 2026

Copy link
Copy Markdown
Author

History regrouped, and five self-review fixes folded in. Force-pushed, so the inline threads above are now marked outdated — they all already have replies, and nothing was dropped.

Why now: the branch had grown to 39 commits carrying ~1700 added lines that a later commit deleted again (churn 5949+/2143− against a net of 4246+/440−) — the withdrawn EndPoint.resolveAll() API, a resolver thread pool that no longer exists, and two test classes that were added and then removed. The repo rebase-merges, so all of that would have landed on scylla-4.x verbatim.

Now 9 commits, rebased onto the current scylla-4.x tip. Churn now equals net exactly (4308+/440−): no commit adds anything a later one deletes.

# Commit
1 refactor: remove dead local-DC contact-point compatibility check
2 feat: keep contact points unresolved; deprecate RESOLVE_CONTACT_POINTS
3 feat: let the endpoint layer hand out unresolved addresses
4 feat: try every address a hostname resolves to when connecting
5 fix: keep a node's metric identity stable across endpoint changes
6 fix: retry a contact point on another address when its identity read fails
7 feat: fall back to the original contact points on control reconnection
8 docs: document multi-address DNS resolution
9 test: cover multi-address resolution against a real cluster

The tree is byte-identical to the reviewed head plus the five fixes below — verified by git diff against the pre-regroup branch returning empty (identical tree SHAs). Each commit compiles standalone under -Werror.

Five self-review fixes, all folded into the commit they belong to:

  1. MAX_ADDRESSES_PER_QUERY_PLAN_ENTRY was @VisibleForTesting with nothing referencing it, leaving the backstop as the only branch of retryOrAdvance without coverage. New test walks nine distinct addresses past it; proven to fail with the cap removed. Its javadoc also understated the cost — the cap is tested before the address is recorded, so N addresses means up to N+1 attempts.
  2. pinnedAddressOf() and expandsToSeveralAddresses() shared their resolve() try/catch as resolveQuietly().
  3. The upgrade guide documented neither the walk's cost at initialization nor how it compounds with the reconnection fallback — the nodes that fallback appends are unidentified hostnames, which is exactly what arms the walk.
  4. DefaultDriverOption still described the fallback as expanding contact points "at query-plan time". Stale since resolution moved to the connection layer; TypedDriverOption and reference.conf already said "at connection time".
  5. DefaultNode.setEndPoint()'s clear→swap→build sequence is not atomic against concurrent metric writes. Documented rather than fixed — closing it means having clearMetrics() take the ids to clear instead of recomputing them, in every metrics implementation.

On the earlier suggestion to revert fallback-to-original-contact-points to false (CodeRabbit, on reference.conf): its stated reason was that the flip "enables the blocking DNS fallback path by default". That path no longer exists — the fallback appends unresolved hostnames and does no DNS at plan time; expansion is ChannelFactory's job at connection time, off the admin loop. The move to the connection layer also makes the flip more necessary, not less: PinnableEndPoint binds the control node to the single address its connection reached, so it no longer re-expands, and the contact-point fallback is the only remaining path back to a changed DNS record — TopologyMonitor.reresolvesNodeAddresses()'s javadoc states that dependency outright. Keeping true, with fix 4 above correcting the stale rationale.

One consequence of the move is genuine and now documented in reference.conf: the appended contact points cannot be deduplicated against the live-node plan, because at plan time they are hostnames while the live nodes are already-resolved IPs. Collapsing that duplication is the same shape as #989 and is left as a follow-up rather than widening this PR.

Verified on JDK 11: 3862 core unit tests, javadoc:javadoc clean, fmt:check clean, cd docs && make test warning-free, per-commit -Werror compile, and MockResolverIT against live ScyllaDB.

Copilot AI 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.

Pull request overview

Copilot reviewed 45 out of 45 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

core/src/test/java/com/datastax/oss/driver/internal/core/metadata/TestNodeFactory.java:1

  • Grammar in Javadoc: 'A endpoint' should be 'An endpoint'.
    integration-tests/src/test/java/com/datastax/oss/driver/core/resolver/MockResolverIT.java:1
  • This test mutates the global ChannelFactory logger level, which can cause cross-test interference if integration tests are executed in parallel (or if another test relies on the prior level). Consider avoiding global level changes by adding a DEBUG-level appender with an appropriate filter/threshold (or a dedicated test logger name) so capture is isolated to this test instance.

Comment on lines +211 to +214
// Concatenate rather than mutate: the RUNNING-state regularQueryPlan is a built-in QueryPlan
// whose add()/addAll() throw UnsupportedOperationException (poll() is its only mutator).
// CompositeQueryPlan drains the regular plan first, then the contact-point fallback.
return new CompositeQueryPlan(regularQueryPlan, new SimpleQueryPlan(contactNodes.toArray()));
Copilot AI review requested due to automatic review settings August 6, 2026 17:01

Copilot AI 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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@nikagra
nikagra requested a review from sylwiaszunejko August 7, 2026 13:01
@nikagra
nikagra force-pushed the fix/DRIVER-201-endpoint-resolve-all branch 2 times, most recently from 9b10855 to 8bae94c Compare August 11, 2026 10:07
@nikagra

nikagra commented Aug 11, 2026

Copy link
Copy Markdown
Author

Self-review round — no thread prompted these. Six fix-ups folded in with --autosquash, so the nine per-concern commits stand; the tree is byte-identical to what the fix-ups produced, and every commit in the series compiles on its own.

30df4df80dfix: identify the control node inside the init handshake

  • The acceptance check only rejected a null host_id. UuidCodec reads a zero-length buffer as null and throws on any other length ≠ 16, so a malformed one passed init and then failed in DefaultTopologyMonitor's requireNonNull — which advances to the next node, the exact failure moving this read into the handshake exists to remove. It now rejects anything that cannot decode to a UUID.
  • indexOfHostId returned the spec's position in the list, while AdminRow indexes the row by ColumnSpec.index. Those are equal on every wire-decoded response, so there was no live bug; now the guard and the decode look at the same cell by construction rather than by coincidence.
  • resolveChannelNodeIfNeeded reads hostId a second time, so a metadata refresh landing after the connect was armed left a stashed row nobody would ever take — and takeNodeInfoRow() is the only thing that clears the attribute. Dropped on that branch.
  • Both DriverChannelOptions flavours now state fetchNodeInfo explicitly, instead of relying on the shared builder being read before it is mutated.

41a794fd41feat: try every address a hostname resolves to when connecting

  • AuthenticationException is now node-wide. Credentials are a property of the cluster, not of the address that happened to be tried, so a wrong password was replaying connect + handshake against every candidate and burying the real cause under identical copies of itself.
  • pin() refuses an unresolved address. resolveCandidates has three paths that hand the endpoint's own address straight back (disableResolver(), !isSupported, isResolved), and for an endpoint that reports a name the candidate is still that name; pinning it freezes the endpoint on something that re-expands on every connect anyway.
  • Suppressed causes are deduplicated by identity. Attaching mutates a Throwable the driver does not own, and nothing stops two candidates from failing with the same instance — which would then grow its suppressed list on every connect.
  • The bootstrap-handler warning was latched on a static flag while the message names the session: one report per JVM, every later misconfigured session silent. Now per factory.

e8e0991e72feat: let the endpoint layer hand out unresolved addresses

ClientRoutesEndPoint.pinTo was missing the "already the address I hold" disjunct that DefaultEndPoint and SniEndPoint both have. Since resolve() hands out the route hostname unresolved, pinning it would have frozen the endpoint on a name and silenced topologyMonitor.resolve() for good — worst of both.

fa3374c855feat: keep contact points unresolved; deprecate RESOLVE_CONTACT_POINTS

advanced.resolve-contact-points = true now logs a warning rather than disappearing in silence. It tests the value, not isDefined(): unlike the deprecated options DefaultDriverContext warns about, this key ships uncommented in reference.conf, so it is always defined.

86cfdd49f8docs: document multi-address DNS resolution

The upgrade guide now says what that option bought, not only that it is inert: per-address Nodes, and with them per-address metrics and Metadata visibility, per-address query-plan advance, and one-off resolution at startup.

Timeout note, in three places (e8e0991e72, 41a794fd41, 81f8e435d2)

"Worst case is N × connect-timeout" understated it by about an order of magnitude: each candidate also runs the init handshake, whose steps each arm their own init-query-timeout and accumulate rather than sharing a deadline. Reworded; session init has no overall deadline either, which the note now says.

Nine new unit tests; core is green at 3872.

@nikagra
nikagra force-pushed the fix/DRIVER-201-endpoint-resolve-all branch 2 times, most recently from 37ffbb4 to 51009f0 Compare August 11, 2026 19:20

@dkropachev dkropachev 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.

Please add a cap on number of candidates it retries inside ChannelFactory.tryNextCandidate and randomly shuffle them every time.

* doing this inside the handshake instead of after it: the alternative addresses are still
* available.
*/
private void onNodeInfo(Rows rows) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Instead of that can you please create an interface for a async hook here, so that control connections could supply private method for it, channeling all the information it collected from the hook straight to control connection context

@nikagra
nikagra force-pushed the fix/DRIVER-201-endpoint-resolve-all branch 2 times, most recently from 27e784f to 273060d Compare August 13, 2026 11:22
@nikagra nikagra closed this Aug 13, 2026
@nikagra nikagra reopened this Aug 13, 2026
nikagra and others added 3 commits August 13, 2026 13:33
…VER-201)

OptionalLocalDcHelper.checkLocalDatacenterCompatibility() warned when a contact
point reported a datacenter different from the configured local DC. This has been
dead code on scylla-4.x since 12e6acb: refresh matches nodes by hostId only, so
contact-point nodes never get a datacenter assigned and the warning could never
fire. Remove it. The separate "configured local DC matches no node" warning is
retained.

Nothing covered the removal, and CUSTOMER-588 is the bug the check caused: it
compared the configured local DC against ephemeral placeholder Nodes (built by
MetadataManager#addContactPoints via DefaultNode#newContactPoint, datacenter
always null), so it warned unconditionally whenever a local DC was configured,
no matter where the contact points actually were.

The new test builds a placeholder Node the same way production does, plus a
resolved node that genuinely is in the configured local DC, and asserts no
warning is logged. It asserts on the absence of any WARN rather than of one
particular message, so a regression under different wording is still caught;
should_warn_if_configured_dc_matches_no_node is the positive control for the
same appender, so a silent capture failure cannot make it pass by accident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…S (DRIVER-201)

Contact points backed by a hostname are now always kept unresolved, so the
connection layer can expand them to all their DNS-mapped IPs at connection time.
SessionBuilder no longer reads RESOLVE_CONTACT_POINTS when merging contact
points; the option is deprecated and has no effect. An already-resolved
InetSocketAddress passed programmatically is still used as provided, with no
further expansion.

OptionsMap.fillWithDriverDefaults() still carries the option's reference.conf
value so the defaults map stays complete, and is annotated accordingly -- the
build treats deprecation warnings as errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Groundwork for expanding a hostname to all of its addresses: resolution becomes
the connection layer's job, so everything that produces an EndPoint stops doing
DNS of its own, and an endpoint gains a way to record which address a connection
actually reached.

PinnableEndPoint is the new internal contract: pinTo(SocketAddress) returns a
copy bound to one address, and the pin is excluded from equals(), hashCode(),
asMetricPrefix() and toString(). Endpoints are set and map keys, and node
metrics are named after them, so a pinned copy has to be indistinguishable from
its original everywhere except when the connection layer asks which address
answered. A generic delegating wrapper was rejected: its equals() would be
asymmetric, because DefaultEndPoint#equals tests instanceof and would reject the
wrapper while the wrapper accepted the original, and it would break
SniSslEngineFactory's instanceof SniEndPoint guard. Each implementation
therefore carries a nullable pinnedAddress of its own.

SniEndPoint additionally normalizes a resolved proxy *hostname* back to
unresolved. withCloudProxyAddress(new InetSocketAddress("proxy", 9042)) resolves
eagerly, which froze Cloud on whichever proxy IP the JVM happened to return; an
IP-literal proxy is left alone. Contact points keep the opposite policy on
purpose, since ContactPoints.merge() only ever applied its resolve flag to
config-file entries.

ClientRoutesTopologyMonitor.resolve() likewise returns the client route as an
unresolved address and no longer looks it up, which keeps it a pure in-memory
cache lookup that is safe to call from an event loop, and lets a custom resolver
apply to client routes just as it does to contact points. Its protected
resolveAddress() extension point, which existed only so tests could stub out
InetAddress.getByName, goes with it. This has to move together with
ClientRoutesEndPoint: dropping "throws UnknownHostException" from one and the
matching catch from the other is a single compilable change.

TopologyMonitor gains reresolvesNodeAddresses(), which tells the control
connection's reconnection query plan whether this monitor already keeps
addresses fresh. It defaults to false, correct for DefaultTopologyMonitor, whose
peers hold an already-resolved IP from system.peers. ClientRoutesTopologyMonitor
reports true only when every currently-known node actually has a live route:
where the route set is incomplete, ClientRoutesEndPoint falls back to a static
resolved endpoint, and those nodes still need the contact-point fallback.

The "is this a name" test several of these need is shared as
AddressUtils.carriesName(): a resolved address compares its host string against
the literal its own bytes produce, an unresolved one parses its host string.
Neither isUnresolved() nor the presence of an InetAddress can tell a name from a
literal on its own.

DseGssApiAuthProviderBase.serverName() falls back to getHostString() when
getAddress() returns null, which is now the ordinary case for a Cloud or
client-route endpoint rather than an impossible one.

EndPoint.resolve() keeps its signature and is not deprecated, so third-party
implementations still compile. Its javadoc gains two expectations: return the
address as-is rather than looking names up, since this is now called from an
event loop; and callers are warned that the returned address is no longer always
resolved, so getHostString() is the safe way to read the host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nikagra
nikagra force-pushed the fix/DRIVER-201-endpoint-resolve-all branch from 273060d to 3811079 Compare August 13, 2026 11:44
@nikagra nikagra closed this Aug 13, 2026
@nikagra nikagra reopened this Aug 13, 2026
nikagra and others added 6 commits August 14, 2026 13:24
…R-201)

This is the fix for DRIVER-201. When a contact point or a node address is a
hostname that maps to several IPs, the driver used to try only the first one and
raise AllNodesFailedException if it was unreachable, even though the hostname
also resolved to healthy addresses.

Resolution is a connection-layer concern. ChannelFactory.connect() is now the
single place that turns "the address this node is known by" into "the addresses
to actually try": EndPoint.resolve() yields one address and does no lookup, so
it stays safe to call from an event loop; ChannelFactory expands it through the
bootstrap's Netty AddressResolverGroup; the candidates are tried in sequence
until one connects; and the endpoint is pinned to the address that won, so the
channel carries the address it is really on.

Expansion always goes through the configured resolver, mirroring Netty's own
doResolveAndConnect0 short-circuit (no group, !isSupported, isResolved) rather
than pre-filtering on isUnresolved(). Both isSupported() and isResolved() are
overridable, so a redirecting custom resolver keeps its say over addresses that
merely look resolved. The bootstrap is built once per connect() and cloned per
attempt, with the clone's resolver disabled: Bootstrap.clone() carries the
resolver over, so an enabled clone would resolve each candidate a second time --
through resolve(), singular -- and a redirecting resolver would collapse every
candidate onto its first answer, silently killing the fallback.

Details that took a round each to get right:

- The queried hostname is re-attached to resolver-returned addresses, centrally
  rather than per endpoint, so TLS sees the name the user configured instead of
  an IP or a CNAME label. Scoped IPv6 keeps its zone via the numeric
  Inet6Address.getByAddress overload; the NetworkInterface one re-derives the
  scope and throws when the interface has no address of the same local type.
- One EventLoop is chosen per connect() and shared by resolution and every
  clone(eventLoop), instead of letting Bootstrap.connect() advance the chooser a
  second time and land channels on half the loops.
- Candidates are shuffled per connect and truncated to the new
  advanced.connection.max-candidate-addresses option (default 5). The shuffle
  spreads load and varies the starting address between attempts with no
  per-name state to maintain; the cap bounds what one attempt can cost -- each
  address tried is a full connect plus handshake, and with wrong credentials a
  rejected login -- while successive attempts sample fresh random subsets, so
  no address is permanently out of reach.
- Protocol-version rejection is terminal only for a node whose host id is known.
  The addresses of an unidentified endpoint may belong to different nodes, and
  collapsing a contact-point hostname into one Node must not lose the query-plan
  advance that resolve-contact-points=true used to provide. An authentication
  failure is never terminal, even for an identified node: with a multi-record
  name it may be specific to the address (a stale record pointing at a foreign
  cluster fails at AUTH, which runs before the cluster-name check), a single
  address's failure must not write off the endpoint, and the candidate cap is
  what bounds the cost of genuinely wrong credentials.
- Failures from earlier candidates are attached to the final error as suppressed
  exceptions -- with the surfaced one chosen by how callers classify errors
  rather than by position -- and negotiation history is scoped per candidate
  address.
- Every resolver and Netty callback completes the connect future on failure.
  connect() has no timeout at the resolution stage, so an unguarded throw would
  hang the caller for good.

afterBootstrapInitialized() now runs once per logical connection rather than
once per attempt, and sees the bootstrap before the driver's handler is
installed; a handler set by the hook is overwritten, with a one-time warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…IVER-201)

Node metrics are named after the endpoint, so DefaultNode.setEndPoint() has to
re-register them whenever those names change -- which is not the same question
as whether this is a different node, and the old !equals() test got it wrong in
both directions.

It was too narrow: an unresolved hostname and the resolved address it maps to
compare *equal* while their metric prefixes differ, which is exactly what
happens when a contact-point node adopts the endpoint built from its
system.local row. And too wide in the other direction is now possible too, since
a pinned copy differs from its original only by an address that both equals()
and the metric identity ignore by contract.

The test is therefore asMetricPrefix() plus toString(), because both are in use:
the default MetricIdGenerator names node metrics after the prefix, the tagging
one tags them with toString(). The pin is excluded from toString() as well, or
DefaultTopologyMonitor#buildNodeEndPoint returning the control channel's pinned
endpoint for the system.local row would silently retag node metrics on every
refresh and orphan the old series.

The node also adopts the newest endpoint instance even when it compares equal,
since a pinned copy carries the address every subsequent connection will use.

Finally, the rebuild order is clear, then swap, then build. Dropwizard and
MicroProfile do not remember the ids they registered under; their clearMetrics()
recomputes each one from the node's endpoint as it stands at that moment. The
previous order -- swap, build, clear -- therefore deleted exactly the series the
new updater had just registered and left the old ones behind with nothing
writing to them. That ordering is upstream's, but it used to be reached only
when the endpoints compared unequal; keying the rebuild on metric identity
brings the ordinary contact-point transition onto the same path.

The pre-existing pin test was vacuous: a mocked context yields
NoopNodeMetricUpdater, for which the rebuild is skipped entirely. Both tests now
stub MetricsFactory, and the ordering test drives a real MetricRegistry through
a hostname-to-IP rename; it was proven to fail under the old order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e loop (DRIVER-201)

ChannelFactory tries every address a contact-point hostname resolves to while it
opens a channel, but the node's identity was read afterwards, over the channel
that won -- by then the remaining candidates are gone. ControlConnection advanced
its query plan on that failure, and since a contact-point hostname is now a
single Node, that wrote off the whole hostname on the strength of one of its
addresses. With a single contact point and the default reconnect-on-init=false,
session initialization failed outright, and a rebuilt session failed the same
way every time while a healthy address sat unused.

The identity read now happens while the factory still holds the remaining
candidates, through a caller-supplied hook. DriverChannelOptions gains an
internal ConnectHook (precedent for a behavioral member there: eventCallback)
plus a timeout; after protocol initialization succeeds on a candidate,
ChannelFactory invokes the hook and treats a rejection, a synchronous throw or
a timeout as a per-candidate failure: the channel is closed and the loop
advances to the endpoint's next address, exactly like an init failure.

ControlConnection arms the hook only for a node whose host id is unknown --
contact points, the one case with something to learn. The hook runs
TopologyMonitor.getChannelNodeInfo, so a custom monitor's identity read is
honored; it rejects a node that reports no host id, and channels what it read
straight into a per-attempt holder. Once the connect completes, the captured
NodeInfo is registered without a second read, after asserting it came from the
winning channel; a miss (a ChannelFactory subclass that skips the hook) falls
back to a direct read. The options are built fresh per attempt, because the
holder is stateful and overlapping connect chains are reachable: the initial
connect() runs outside Reconnection, and reconnectNow() checks only
initWasCalled. Pool connections and reconnects to identified nodes carry no
hook and send exactly the bytes they sent before.

REGISTER moves out of the init handshake to keep its ordering property:
identity is validated before the channel registers for events. ChannelFactory
sends it through AdminRequestHandler after the hook accepts, with the same
init-query timeout; a registration failure is a per-candidate failure, as it
was as an init step, and the CLIENT_ROUTES_CHANGE rejection keeps its clear
message. The one visible cost: the window in which a live channel is not yet
registered for events grows by the hook's round trip.

The wire cost is unchanged from reading identity after the connect: one
"SELECT * FROM system.local WHERE key='local'" per contact-point connection,
now inside the attempt instead of after it. Init itself ends at the
cluster-name check (or SET_KEYSPACE), byte-identical to the pre-multi-address
exchange, which is what ProtocolVersionMixedClusterIT pins.

Two consequences are visible. A contact point that exhausts every address on
identity failures now fires controlConnectionFailed and is marked DOWN
pre-init, the treatment connect-phase failures already get; and
AllNodesFailedException reports one entry per contact point, with the
per-address failures attached as suppressed exceptions.

One pre-existing exposure stays pre-existing rather than closing: ChannelFactory
imprints the cluster name, product type and negotiated protocol version on init
success, so a candidate the hook then rejects has already imprinted -- like
every other channel abandoned after init (a node turned IGNORED, a close during
resolve). The values are properties of the cluster that answered on that
address, so this is identical to the behavior before this series.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n (DRIVER-201)

advanced.control-connection.reconnection.fallback-to-original-contact-points now
defaults to true, and is the driver's DNS re-resolution path.

Nothing else re-resolves. Metadata nodes hold an endpoint built from an
already-resolved system.peers IP, and the control node's own endpoint is pinned
by ChannelFactory to the single address its connection reached, deliberately, so
that a node with a known identity cannot wander to a different host. Once the
records behind a hostname change, appending the original contact points is
therefore the only way back: they are still unresolved hostnames, so
ChannelFactory expands each one to its current IPs at connection time.

The append is gated on the topology monitor not re-resolving addresses itself,
since a proxy-based monitor keeps them fresh and raw contact points could
resurrect nodes it has authoritatively removed. The exception is an empty
regular plan: with no live node to try, reconnection cannot recover on its own.

The plans are concatenated rather than mutated. A RUNNING-state query plan is a
built-in QueryPlan whose add()/addAll() throw UnsupportedOperationException,
poll() being its only mutator, so with the fallback defaulting on every
post-init control reconnect would otherwise have crashed. The append is also
skipped before the LBP reaches RUNNING, where newQueryPlan() has already built
the plan from the contact points and appending would duplicate every entry.

Documented cost: the contact points are appended without being compared against
the live-node plan, because at plan time they are hostnames while the live nodes
are resolved IPs. When DNS has not changed they expand to addresses the plan
just failed on, so an exhausted reconnection round retries roughly twice as many
addresses -- which is why HeartbeatIT has to disable it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rewrites the address-resolution manual page around the connection layer doing
the expansion, and adds an upgrade-guide section covering what changes for
users:

- there is no public API change, but EndPoint.resolve() may now return an
  unresolved address for Cloud/SNI and client-route nodes, so a caller doing
  ((InetSocketAddress) resolve()).getAddress().getHostAddress() gets a NPE where
  it previously worked; getHostString() is the safe read;
- advanced.resolve-contact-points is deprecated and inert;
- fallback-to-original-contact-points defaults to true, with its cost stated;
- a contact point that none of its addresses can identify is now marked down
  before initialization completes, firing an event it did not fire before;
- AllNodesFailedException reports one entry per contact point, with each
  address's failure attached as a suppressed exception;
- the one-time TaggingMetricIdGenerator node-tag rename for hand-built Cloud
  proxy addresses;
- the afterBootstrapInitialized() contract change;
- two protected methods removed from internal classes that a subclass could
  have overridden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MockResolverIT drives the end-to-end fix through a JVM-level InetAddress hook:
a hostname that maps to one dead and one live address must still produce a
working session.

Its multi-address test was one change away from being vacuous. The comment
claimed the dead record was tried first because of resolver insertion order, but
rotate() sorts candidates by toString() and discards that order; the dead
address went first only because the sort is lexicographic. The test now captures
ChannelFactory at DEBUG and requires the "trying next address" event, which was
proven load-bearing: moving the dead IP to one that sorts last makes it fail in
7s instead of passing in 89s.

ClientRoutesIT asserts on host strings rather than resolved IPs, since a client
route now stays unresolved until the connection layer expands it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nikagra
nikagra force-pushed the fix/DRIVER-201-endpoint-resolve-all branch from 3811079 to 1e9bc64 Compare August 14, 2026 11:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants