From f9265b3439ead9ed24cd1526e9a49ef34c91800b Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Fri, 15 May 2026 19:26:13 +0200 Subject: [PATCH] feat: add EndPoint.resolveAll() for multi-address DNS expansion (DRIVER-201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the endpoint-API aspect of DRIVER-201. Problem: EndPoint.resolve() returns a single SocketAddress. When a hostname maps to multiple IPs, the driver can only try the first one and fails with AllNodesFailedException if it is unreachable — the remaining IPs are invisible to the connection layer. Solution (per @dkropachev's architectural direction): - Deprecate EndPoint.resolve(). Add EndPoint.resolveAll() with a default implementation that wraps resolve() in a single-element array for backward compatibility with third-party implementations. - DefaultEndPoint.resolveAll(): if the stored InetSocketAddress is unresolved, calls InetAddress.getAllByName() to expand the hostname to all known IPs, returning one InetSocketAddress per IP. Falls back to the single-element unresolved address if DNS fails, so the connect attempt surfaces a descriptive error rather than returning empty. - SniEndPoint.resolveAll(): re-resolves the proxy hostname on each call and returns all A-records sorted by IP, enabling the caller to try each proxy address in sequence. - ClientRoutesEndPoint.resolveAll(): delegates to resolve() (single- address topology-monitor lookup) and wraps in a one-element array. - ChannelFactory.connect(): replaced endPoint.resolve() with endPoint.resolveAll(). Iterates through the returned candidates via tryNextCandidate(); on per-address failure logs and tries the next; only fails the overall resultFuture when all candidates are exhausted. Protocol-version negotiation (downgrade retries) is scoped to the same address via connectToAddress(), which is semantically correct. Tests: - DefaultEndPointTest: 3 new cases — already-resolved passthrough, unresolved hostname expansion, unresolvable hostname fallback. - SniEndPointTest: new class with cases for resolveAll() happy path, unresolvable host exception, and resolve() sanity check. - All 13 existing ChannelFactory tests continue to pass (LocalEndPoint uses the default single-element resolveAll() via the interface default). --- .../core/auth/DseGssApiAuthProviderBase.java | 2 + .../core/insights/InsightsClient.java | 6 + .../driver/api/core/metadata/EndPoint.java | 36 ++++- .../internal/core/channel/ChannelFactory.java | 124 ++++++++++++++++-- .../core/metadata/ClientRoutesEndPoint.java | 14 ++ .../core/metadata/DefaultEndPoint.java | 44 +++++++ .../core/metadata/DefaultTopologyMonitor.java | 4 + .../internal/core/metadata/SniEndPoint.java | 28 ++++ .../core/ssl/DefaultSslEngineFactory.java | 2 + .../core/ssl/SniSslEngineFactory.java | 2 + .../ChannelFactoryResolveAllGuardTest.java | 106 +++++++++++++++ .../core/metadata/DefaultEndPointTest.java | 37 ++++++ .../core/metadata/SniEndPointTest.java | 62 +++++++++ 13 files changed, 456 insertions(+), 11 deletions(-) create mode 100644 core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryResolveAllGuardTest.java create mode 100644 core/src/test/java/com/datastax/oss/driver/internal/core/metadata/SniEndPointTest.java diff --git a/core/src/main/java/com/datastax/dse/driver/api/core/auth/DseGssApiAuthProviderBase.java b/core/src/main/java/com/datastax/dse/driver/api/core/auth/DseGssApiAuthProviderBase.java index 48a0e5b0ef3..593552d77c1 100644 --- a/core/src/main/java/com/datastax/dse/driver/api/core/auth/DseGssApiAuthProviderBase.java +++ b/core/src/main/java/com/datastax/dse/driver/api/core/auth/DseGssApiAuthProviderBase.java @@ -291,6 +291,8 @@ protected static class GssApiAuthenticator extends BaseDseAuthenticator { private SaslClient saslClient; private EndPoint endPoint; + @SuppressWarnings("deprecation") // resolve() is deprecated in favour of resolveAll(); + // Kerberos authentication needs a single canonical hostname for SASL service name resolution. protected GssApiAuthenticator( GssApiOptions options, EndPoint endPoint, String serverAuthenticator) { super(serverAuthenticator); diff --git a/core/src/main/java/com/datastax/dse/driver/internal/core/insights/InsightsClient.java b/core/src/main/java/com/datastax/dse/driver/internal/core/insights/InsightsClient.java index 168477894ed..54d723152a4 100644 --- a/core/src/main/java/com/datastax/dse/driver/internal/core/insights/InsightsClient.java +++ b/core/src/main/java/com/datastax/dse/driver/internal/core/insights/InsightsClient.java @@ -288,6 +288,8 @@ private InsightsStatusData createStatusData() { .build(); } + @SuppressWarnings("deprecation") // resolve() is deprecated in favour of resolveAll(); + // address reporting needs a single canonical address per node. private Map getConnectedNodes() { Map pools = driverContext.getPoolManager().getPools(); return pools.entrySet().stream() @@ -302,6 +304,8 @@ private SessionStateForNode constructSessionStateForNode(Map.Entry startupOptions = driverContext.getStartupOptions(); return InsightsStartupData.builder() @@ -454,6 +458,8 @@ private PoolSizeByHostDistance getPoolSizeByHostDistance() { 0); } + @SuppressWarnings("deprecation") // resolve() is deprecated in favour of resolveAll(); + // address reporting needs a single canonical address for the control connection. private String getControlConnectionSocketAddress() { SocketAddress controlConnectionAddress = controlConnection.channel().getEndPoint().resolve(); return AddressFormatter.nullSafeToString(controlConnectionAddress); diff --git a/core/src/main/java/com/datastax/oss/driver/api/core/metadata/EndPoint.java b/core/src/main/java/com/datastax/oss/driver/api/core/metadata/EndPoint.java index 530f2ad38ac..dd698ae603f 100644 --- a/core/src/main/java/com/datastax/oss/driver/api/core/metadata/EndPoint.java +++ b/core/src/main/java/com/datastax/oss/driver/api/core/metadata/EndPoint.java @@ -18,28 +18,58 @@ package com.datastax.oss.driver.api.core.metadata; import edu.umd.cs.findbugs.annotations.NonNull; -import java.net.InetSocketAddress; import java.net.SocketAddress; /** * Encapsulates the information needed to open connections to a node. * *

By default, the driver assumes plain TCP connections, and this is just a wrapper around an - * {@link InetSocketAddress}. However, more complex deployment scenarios might use a custom + * {@link java.net.InetSocketAddress}. However, more complex deployment scenarios might use a custom * implementation that contains additional information; for example, if the nodes are accessed * through a proxy with SNI routing, an SNI server name is needed in addition to the proxy address. */ public interface EndPoint { /** - * Resolves this instance to a socket address. + * Resolves this instance to a single socket address. * *

This will be called each time the driver opens a new connection to the node. The returned * address cannot be null. + * + * @deprecated Use {@link #resolveAll()} instead. When a hostname maps to multiple IPs (e.g. in + * dynamic DNS environments) only one address is returned here, causing the driver to miss + * fallback IPs when the first one is unreachable. {@code resolveAll()} returns the full set. */ + @Deprecated @NonNull SocketAddress resolve(); + /** + * Resolves this instance to all known socket addresses. + * + *

This is called each time the driver opens a new connection to the node. For endpoints backed + * by a plain IP address the array contains exactly one element. For endpoints whose hostname + * resolves to multiple IPs (e.g. a DNS round-robin entry) all addresses are returned so that the + * driver can try each one in sequence and fall back gracefully when individual IPs are + * unreachable. + * + *

The default implementation wraps {@link #resolve()} and returns a single-element array. + * Implementations that can supply multiple addresses should override this method. + * + *

The returned array must not be null and must contain at least one element. + * + *

Timeout note: {@link com.datastax.oss.driver.internal.core.channel.ChannelFactory} + * tries each address in sequence. If a hostname resolves to N addresses and each attempt times + * out, the worst-case connection time before declaring a node unreachable is {@code N × + * advanced.connection.connect-timeout}. In practice DNS round-robin entries have only a small + * number of records, so this is rarely a concern, but callers should be aware of this when + * configuring connect timeouts. + */ + @NonNull + default SocketAddress[] resolveAll() { + return new SocketAddress[] {resolve()}; + } + /** * Returns an alternate string representation for use in node-level metric names. * diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/channel/ChannelFactory.java b/core/src/main/java/com/datastax/oss/driver/internal/core/channel/ChannelFactory.java index 6bfc355f910..bf72885bf80 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/channel/ChannelFactory.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/channel/ChannelFactory.java @@ -219,14 +219,119 @@ private void connect( List attemptedVersions, CompletableFuture resultFuture) { - SocketAddress resolvedAddress; + SocketAddress[] candidates; try { - resolvedAddress = endPoint.resolve(); + candidates = endPoint.resolveAll(); + if (candidates == null || candidates.length == 0) { + resultFuture.completeExceptionally( + new IllegalArgumentException( + "EndPoint.resolveAll() must return a non-null, non-empty array: " + endPoint)); + return; + } } catch (Exception e) { resultFuture.completeExceptionally(e); return; } + tryNextCandidate( + endPoint, + shardingInfo, + shardId, + options, + nodeMetricUpdater, + currentVersion, + isNegotiating, + attemptedVersions, + resultFuture, + candidates, + 0); + } + + /** + * Iterates through the candidate addresses from {@link EndPoint#resolveAll()}. Tries each one in + * sequence; if an address fails for a reason other than protocol-version negotiation exhaustion, + * the next candidate is tried. Only when all candidates are exhausted is the overall {@code + * resultFuture} failed. + * + *

Timeout note: addresses are tried serially, so the worst-case time before failure is + * {@code N × connect-timeout} where N is the number of candidates. This is an intentional + * tradeoff: failing immediately on the first unreachable IP would prevent fallback to healthy + * ones. In practice DNS entries have only a small number of records. + */ + private void tryNextCandidate( + EndPoint endPoint, + NodeShardingInfo shardingInfo, + Integer shardId, + DriverChannelOptions options, + NodeMetricUpdater nodeMetricUpdater, + ProtocolVersion currentVersion, + boolean isNegotiating, + List attemptedVersions, + CompletableFuture resultFuture, + SocketAddress[] candidates, + int index) { + + SocketAddress candidate = candidates[index]; + CompletableFuture perAddressFuture = new CompletableFuture<>(); + connectToAddress( + endPoint, + shardingInfo, + shardId, + options, + nodeMetricUpdater, + currentVersion, + isNegotiating, + attemptedVersions, + perAddressFuture, + candidate); + + 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); + } + }); + } + + /** + * Performs a Netty bootstrap connect to a single, already-resolved address. Handles + * protocol-version negotiation (downgrade retries) internally, staying on the same address. Uses + * {@code perAddressFuture} so {@link #tryNextCandidate} can distinguish a per-address TCP failure + * (try the next IP) from a successful protocol handshake. + */ + private void connectToAddress( + EndPoint endPoint, + NodeShardingInfo shardingInfo, + Integer shardId, + DriverChannelOptions options, + NodeMetricUpdater nodeMetricUpdater, + ProtocolVersion currentVersion, + boolean isNegotiating, + List attemptedVersions, + CompletableFuture perAddressFuture, + SocketAddress resolvedAddress) { + NettyOptions nettyOptions = context.getNettyOptions(); Bootstrap bootstrap = @@ -235,7 +340,8 @@ private void connect( .channel(nettyOptions.channelClass()) .option(ChannelOption.ALLOCATOR, nettyOptions.allocator()) .handler( - initializer(endPoint, currentVersion, options, nodeMetricUpdater, resultFuture)); + initializer( + endPoint, currentVersion, options, nodeMetricUpdater, perAddressFuture)); nettyOptions.afterBootstrapInitialized(bootstrap); @@ -294,7 +400,7 @@ private void connect( ConsistencyLevel.LOCAL_QUORUM.name())); } } - resultFuture.complete(driverChannel); + perAddressFuture.complete(driverChannel); } else { Throwable error = connectFuture.cause(); if (error instanceof UnsupportedProtocolVersionException && isNegotiating) { @@ -307,7 +413,8 @@ private void connect( logPrefix, currentVersion, downgraded.get()); - connect( + // Stay on the same address for protocol-version downgrade retries. + connectToAddress( endPoint, shardingInfo, shardId, @@ -316,16 +423,17 @@ private void connect( downgraded.get(), true, attemptedVersions, - resultFuture); + perAddressFuture, + resolvedAddress); } else { - resultFuture.completeExceptionally( + perAddressFuture.completeExceptionally( UnsupportedProtocolVersionException.forNegotiation( endPoint, attemptedVersions)); } } else { // Note: might be completed already if the failure happened in initializer(), this is // fine - resultFuture.completeExceptionally(error); + perAddressFuture.completeExceptionally(error); } } }); diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesEndPoint.java b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesEndPoint.java index 15d825b2efc..246ae562379 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesEndPoint.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesEndPoint.java @@ -76,6 +76,20 @@ public SocketAddress resolve() { return fallbackEndPoint.resolve(); } + /** + * Returns all socket addresses for this endpoint. + * + *

Delegates to {@link #resolve()} to obtain the single address provided by the topology + * monitor (or the fallback endpoint), then returns it as a one-element array. The topology + * monitor resolves each node to exactly one address by design (via a per-host-id lookup), so + * multi-address expansion is not applicable here. + */ + @NonNull + @Override + public SocketAddress[] resolveAll() { + return new SocketAddress[] {resolve()}; + } + @Override public boolean equals(Object other) { if (other == this) { diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/DefaultEndPoint.java b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/DefaultEndPoint.java index 7ffbee8e4bb..a139f958296 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/DefaultEndPoint.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/DefaultEndPoint.java @@ -20,7 +20,10 @@ import com.datastax.oss.driver.api.core.metadata.EndPoint; import edu.umd.cs.findbugs.annotations.NonNull; import java.io.Serializable; +import java.net.InetAddress; import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.net.UnknownHostException; import java.util.Objects; public class DefaultEndPoint implements EndPoint, Serializable { @@ -41,6 +44,47 @@ public InetSocketAddress resolve() { return address; } + /** + * Returns all socket addresses for this endpoint. + * + *

If the stored address is unresolved (i.e. the driver was configured with {@code + * RESOLVE_CONTACT_POINTS=false} and the hostname has not been looked up yet), this method calls + * {@link InetAddress#getAllByName(String)} to expand the hostname to every IP it resolves to. + * Each resolved IP is returned as an {@link InetSocketAddress} with the same port as the + * original. If the hostname resolves to only one IP, or if the address is already resolved, a + * single-element array is returned. + * + *

If DNS resolution fails, falls back to a single-element array containing {@link #resolve()}. + * + *

Note on resolver: 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 + * consistent with how {@link SniEndPoint} and {@link + * com.datastax.oss.driver.internal.core.metadata.MetadataManager#getResolvedContactPoints()} + * perform DNS resolution elsewhere in the driver. Users who rely on a custom Netty resolver + * should supply pre-resolved {@link java.net.InetSocketAddress} instances instead of hostnames. + */ + @NonNull + @Override + public SocketAddress[] resolveAll() { + if (!address.isUnresolved()) { + return new SocketAddress[] {address}; + } + try { + InetAddress[] all = InetAddress.getAllByName(address.getHostString()); + SocketAddress[] result = new SocketAddress[all.length]; + for (int i = 0; i < all.length; i++) { + result[i] = new InetSocketAddress(all[i], address.getPort()); + } + return result; + } catch (UnknownHostException e) { + // Fallback: return the single unresolved address; the connect attempt will fail with a + // descriptive error rather than silently returning an empty array. + return new SocketAddress[] {address}; + } + } + @Override public boolean equals(Object other) { if (other == this) { diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/DefaultTopologyMonitor.java b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/DefaultTopologyMonitor.java index 5a82bfe2c86..2098a3e6553 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/DefaultTopologyMonitor.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/DefaultTopologyMonitor.java @@ -701,6 +701,8 @@ private Optional findInPeers( // Current versions of Cassandra (3.11 at the time of writing), require the same port for all // nodes. As a consequence, the port is not stored in system tables. // We save it the first time we get a control connection channel. + @SuppressWarnings("deprecation") // resolve() is deprecated in favour of resolveAll(); a single + // canonical address is all that is needed here to extract the port. protected void savePort(DriverChannel channel) { if (port < 0) { SocketAddress address = channel.getEndPoint().resolve(); @@ -723,6 +725,8 @@ protected void savePort(DriverChannel channel) { * otherwise. */ @Nullable + @SuppressWarnings("deprecation") // resolve() is deprecated in favour of resolveAll(); a single + // canonical address is all that is needed here for the peer-vs-local comparison. protected InetSocketAddress getBroadcastRpcAddress( @NonNull AdminRow row, @NonNull EndPoint localEndPoint) { diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/SniEndPoint.java b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/SniEndPoint.java index d1ab8eec98d..4c3bdc67b6f 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/SniEndPoint.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/SniEndPoint.java @@ -22,6 +22,7 @@ import edu.umd.cs.findbugs.annotations.NonNull; import java.net.InetAddress; import java.net.InetSocketAddress; +import java.net.SocketAddress; import java.net.UnknownHostException; import java.util.Arrays; import java.util.Comparator; @@ -75,6 +76,33 @@ public InetSocketAddress resolve() { } } + /** + * Returns all socket addresses for this SNI proxy endpoint. + * + *

Re-resolves the proxy hostname on each call and returns one {@link InetSocketAddress} per + * A-record, so that the driver can try every proxy IP in sequence if one is unreachable. + */ + @NonNull + @Override + public SocketAddress[] resolveAll() { + try { + InetAddress[] aRecords = InetAddress.getAllByName(proxyAddress.getHostName()); + if (aRecords.length == 0) { + throw new IllegalArgumentException( + "Could not resolve proxy address " + proxyAddress.getHostName()); + } + Arrays.sort(aRecords, IP_COMPARATOR); + SocketAddress[] result = new SocketAddress[aRecords.length]; + for (int i = 0; i < aRecords.length; i++) { + result[i] = new InetSocketAddress(aRecords[i], proxyAddress.getPort()); + } + return result; + } catch (UnknownHostException e) { + throw new IllegalArgumentException( + "Could not resolve proxy address " + proxyAddress.getHostName(), e); + } + } + @Override public boolean equals(Object other) { if (other == this) { diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/ssl/DefaultSslEngineFactory.java b/core/src/main/java/com/datastax/oss/driver/internal/core/ssl/DefaultSslEngineFactory.java index 343d3f9e4e7..c64a334b1fe 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/ssl/DefaultSslEngineFactory.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/ssl/DefaultSslEngineFactory.java @@ -116,6 +116,8 @@ protected String hostNoLookup(InetSocketAddress addr) { @NonNull @Override + @SuppressWarnings("deprecation") // resolve() is deprecated in favour of resolveAll(); SSL + // factories legitimately need a single address for hostname verification. public SSLEngine newSslEngine(@NonNull EndPoint remoteEndpoint) { SSLEngine engine; SocketAddress remoteAddress = remoteEndpoint.resolve(); diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/ssl/SniSslEngineFactory.java b/core/src/main/java/com/datastax/oss/driver/internal/core/ssl/SniSslEngineFactory.java index 4d2cb69fbfc..424120a99f3 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/ssl/SniSslEngineFactory.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/ssl/SniSslEngineFactory.java @@ -51,6 +51,8 @@ public SniSslEngineFactory(SSLContext sslContext, boolean allowDnsReverseLookupS @NonNull @Override + @SuppressWarnings("deprecation") // resolve() is deprecated in favour of resolveAll(); SSL + // factories legitimately need a single address for SNI hostname verification. public SSLEngine newSslEngine(@NonNull EndPoint remoteEndpoint) { if (!(remoteEndpoint instanceof SniEndPoint)) { throw new IllegalArgumentException( diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryResolveAllGuardTest.java b/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryResolveAllGuardTest.java new file mode 100644 index 00000000000..5dba268c5ce --- /dev/null +++ b/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryResolveAllGuardTest.java @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.datastax.oss.driver.internal.core.channel; + +import static com.datastax.oss.driver.Assertions.assertThatStage; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.datastax.oss.driver.api.core.DefaultProtocolVersion; +import com.datastax.oss.driver.api.core.config.DefaultDriverOption; +import com.datastax.oss.driver.api.core.metadata.EndPoint; +import com.datastax.oss.driver.internal.core.metrics.NoopNodeMetricUpdater; +import java.util.concurrent.CompletionStage; +import org.junit.Test; + +/** + * Verifies that {@link ChannelFactory#connect} completes the result future exceptionally (rather + * than throwing or hanging) when {@link EndPoint#resolveAll()} returns {@code null}, an empty + * array, or throws an exception. + */ +public class ChannelFactoryResolveAllGuardTest extends ChannelFactoryTestBase { + + @Test + public void should_fail_future_when_resolve_all_returns_null() { + // Given + when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false); + when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4); + ChannelFactory factory = newChannelFactory(); + + EndPoint badEndPoint = mock(EndPoint.class); + when(badEndPoint.resolveAll()).thenReturn(null); + + // When + CompletionStage channelFuture = + factory.connect( + badEndPoint, null, null, DriverChannelOptions.DEFAULT, NoopNodeMetricUpdater.INSTANCE); + + // Then – future must complete exceptionally without hanging + assertThatStage(channelFuture) + .isFailed( + e -> + assertThat(e) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("resolveAll() must return a non-null, non-empty array")); + } + + @Test + public void should_fail_future_when_resolve_all_returns_empty_array() { + // Given + when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false); + when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4); + ChannelFactory factory = newChannelFactory(); + + EndPoint badEndPoint = mock(EndPoint.class); + when(badEndPoint.resolveAll()).thenReturn(new java.net.SocketAddress[0]); + + // When + CompletionStage channelFuture = + factory.connect( + badEndPoint, null, null, DriverChannelOptions.DEFAULT, NoopNodeMetricUpdater.INSTANCE); + + // Then – future must complete exceptionally without hanging + assertThatStage(channelFuture) + .isFailed( + e -> + assertThat(e) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("resolveAll() must return a non-null, non-empty array")); + } + + @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 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)); + } +} diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/metadata/DefaultEndPointTest.java b/core/src/test/java/com/datastax/oss/driver/internal/core/metadata/DefaultEndPointTest.java index 7da8fb39415..8b7c60ff763 100644 --- a/core/src/test/java/com/datastax/oss/driver/internal/core/metadata/DefaultEndPointTest.java +++ b/core/src/test/java/com/datastax/oss/driver/internal/core/metadata/DefaultEndPointTest.java @@ -21,6 +21,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.net.InetSocketAddress; +import java.net.SocketAddress; import org.junit.Test; public class DefaultEndPointTest { @@ -57,4 +58,40 @@ public void should_reject_null_address() { .isInstanceOf(NullPointerException.class) .hasMessage("address can't be null"); } + + @Test + public void resolve_all_returns_single_element_for_already_resolved_address() { + DefaultEndPoint endPoint = new DefaultEndPoint(new InetSocketAddress("127.0.0.1", 9042)); + SocketAddress[] all = endPoint.resolveAll(); + assertThat(all).hasSize(1); + assertThat(((InetSocketAddress) all[0]).isUnresolved()).isFalse(); + assertThat(((InetSocketAddress) all[0]).getHostString()).isEqualTo("127.0.0.1"); + } + + @Test + public void resolve_all_expands_unresolved_hostname_to_at_least_one_address() { + // localhost reliably resolves to at least 127.0.0.1 + DefaultEndPoint endPoint = + new DefaultEndPoint(InetSocketAddress.createUnresolved("localhost", 9042)); + SocketAddress[] all = endPoint.resolveAll(); + assertThat(all).isNotEmpty(); + for (SocketAddress addr : all) { + InetSocketAddress inet = (InetSocketAddress) addr; + assertThat(inet.isUnresolved()).isFalse(); + assertThat(inet.getPort()).isEqualTo(9042); + } + } + + @Test + public void resolve_all_falls_back_to_single_element_when_hostname_is_unresolvable() { + // Unresolvable hostname: resolveAll() must not throw; it returns the unresolved address. + DefaultEndPoint endPoint = + new DefaultEndPoint( + InetSocketAddress.createUnresolved("this-host-does-not-exist.invalid", 9042)); + SocketAddress[] all = endPoint.resolveAll(); + assertThat(all).hasSize(1); + // The fallback address is the original unresolved one. + assertThat(((InetSocketAddress) all[0]).getHostString()) + .isEqualTo("this-host-does-not-exist.invalid"); + } } diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/metadata/SniEndPointTest.java b/core/src/test/java/com/datastax/oss/driver/internal/core/metadata/SniEndPointTest.java new file mode 100644 index 00000000000..e2aecf06970 --- /dev/null +++ b/core/src/test/java/com/datastax/oss/driver/internal/core/metadata/SniEndPointTest.java @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.datastax.oss.driver.internal.core.metadata; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import org.junit.Test; + +public class SniEndPointTest { + + @Test + public void resolve_all_returns_all_proxy_addresses_for_resolvable_hostname() { + // localhost reliably resolves to at least one address + SniEndPoint endPoint = + new SniEndPoint(new InetSocketAddress("localhost", 9042), "test-server-name"); + SocketAddress[] all = endPoint.resolveAll(); + assertThat(all).isNotEmpty(); + for (SocketAddress addr : all) { + InetSocketAddress inet = (InetSocketAddress) addr; + assertThat(inet.isUnresolved()).isFalse(); + assertThat(inet.getPort()).isEqualTo(9042); + } + } + + @Test + public void resolve_all_throws_for_unresolvable_hostname() { + SniEndPoint endPoint = + new SniEndPoint( + new InetSocketAddress("this-host-does-not-exist.invalid", 9042), "test-server-name"); + assertThatThrownBy(endPoint::resolveAll) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Could not resolve proxy address"); + } + + @Test + public void resolve_returns_single_address_from_round_robin() { + // Sanity check: resolve() still works and returns a single address + SniEndPoint endPoint = + new SniEndPoint(new InetSocketAddress("localhost", 9042), "test-server-name"); + InetSocketAddress addr = endPoint.resolve(); + assertThat(addr.isUnresolved()).isFalse(); + assertThat(addr.getPort()).isEqualTo(9042); + } +}