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..beab2f488a8 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
@@ -27,6 +27,7 @@
import com.datastax.oss.protocol.internal.util.Bytes;
import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.Nullable;
+import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.security.PrivilegedActionException;
@@ -319,7 +320,7 @@ protected GssApiAuthenticator(
SUPPORTED_MECHANISMS,
options.getAuthorizationId(),
protocol,
- ((InetSocketAddress) endPoint.resolve()).getAddress().getCanonicalHostName(),
+ serverName(endPoint),
options.getSaslProperties(),
null);
} catch (LoginException | SaslException e) {
@@ -328,6 +329,23 @@ protected GssApiAuthenticator(
this.endPoint = endPoint;
}
+ /**
+ * The host name to build the Kerberos service principal from.
+ *
+ *
Prefers the canonical name of the resolved address, which is what Kerberos expects. The
+ * driver's own endpoints always hand this a resolved address — the channel carries an endpoint
+ * bound to the address it connected to (see {@code PinnableEndPoint}) — but a custom {@link
+ * EndPoint} implementation may still yield an unresolved one, in which case {@code
+ * getAddress()} is null. Fall back to the host string rather than throwing a {@link
+ * NullPointerException}: the hostname is usually the right service name anyway, and a failed
+ * reverse lookup should not take authentication down.
+ */
+ private static String serverName(EndPoint endPoint) {
+ InetSocketAddress address = (InetSocketAddress) endPoint.resolve();
+ InetAddress inetAddress = address.getAddress();
+ return inetAddress != null ? inetAddress.getCanonicalHostName() : address.getHostString();
+ }
+
@NonNull
@Override
protected ByteBuffer getMechanism() {
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..818f7b9a12b 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
@@ -294,7 +294,32 @@ private Map getConnectedNodes() {
.collect(
Collectors.toMap(
entry -> AddressFormatter.nullSafeToString(entry.getKey().getEndPoint().resolve()),
- this::constructSessionStateForNode));
+ this::constructSessionStateForNode,
+ InsightsClient::mergeNodeStates));
+ }
+
+ /**
+ * Combines the states of two nodes that report under the same address.
+ *
+ * The key is not unique per node: behind an SNI proxy or a cloud client route, every node's
+ * endpoint resolves to the same proxy address, so any session with more than one node open
+ * produces duplicate keys. Without a merge function {@link Collectors#toMap} throws {@link
+ * IllegalStateException}, which propagates out of the status report and aborts it every interval.
+ * Summing matches what the shared key denotes in that deployment: the totals reached through that
+ * address.
+ */
+ private static SessionStateForNode mergeNodeStates(
+ SessionStateForNode first, SessionStateForNode second) {
+ return new SessionStateForNode(
+ sumNullable(first.getConnections(), second.getConnections()),
+ sumNullable(first.getInFlightQueries(), second.getInFlightQueries()));
+ }
+
+ private static Integer sumNullable(Integer first, Integer second) {
+ if (first == null) {
+ return second;
+ }
+ return second == null ? first : first + second;
}
private SessionStateForNode constructSessionStateForNode(Map.Entry entry) {
diff --git a/core/src/main/java/com/datastax/oss/driver/api/core/config/DefaultDriverOption.java b/core/src/main/java/com/datastax/oss/driver/api/core/config/DefaultDriverOption.java
index dd60a2487fb..da0a3c622cb 100644
--- a/core/src/main/java/com/datastax/oss/driver/api/core/config/DefaultDriverOption.java
+++ b/core/src/main/java/com/datastax/oss/driver/api/core/config/DefaultDriverOption.java
@@ -135,6 +135,13 @@ public enum DefaultDriverOption implements DriverOption {
* Value-type: int
*/
CONNECTION_MAX_ORPHAN_REQUESTS("advanced.connection.max-orphan-requests"),
+ /**
+ * The maximum number of addresses a single connection attempt will try, when the endpoint it
+ * connects to is a DNS name that resolves to several addresses.
+ *
+ *
Value-type: int
+ */
+ CONNECTION_MAX_CANDIDATE_ADDRESSES("advanced.connection.max-candidate-addresses"),
/**
* Whether to log non-fatal errors when the driver tries to open a new connection.
*
@@ -701,8 +708,14 @@ public enum DefaultDriverOption implements DriverOption {
CONTROL_CONNECTION_AGREEMENT_WARN("advanced.control-connection.schema-agreement.warn-on-failure"),
/**
- * Whether to forcibly add original contact points held by MetadataManager to the reconnection
- * plan, in case there is no live nodes available according to LBP. Experimental.
+ * Whether to append the original contact points held by MetadataManager to the reconnection plan,
+ * after the live nodes reported by the load balancing policy. Defaults to {@code true}.
+ *
+ *
This is also the driver's DNS re-resolution path. Contact points are appended as-is, still
+ * unresolved hostnames, and each is expanded to its current DNS IPs at connection time through
+ * Netty's configured resolver. Metadata nodes, in contrast, hold an already-resolved endpoint
+ * that is never re-resolved. Keeping this enabled lets control-connection reconnects re-resolve
+ * the original hostnames and pick up new IPs once the live-node plan is exhausted.
*
*
Value-type: boolean
*/
@@ -837,7 +850,14 @@ public enum DefaultDriverOption implements DriverOption {
* Whether to resolve the addresses passed to `basic.contact-points`.
*
*
Value-type: boolean
+ *
+ * @deprecated Setting this option has no effect. Contact points given in the configuration are
+ * now always kept as unresolved hostnames and expanded to all of their DNS-mapped IPs lazily
+ * at connection time. This never applied to programmatic contact points passed to {@code
+ * SessionBuilder.addContactPoints}, which are used exactly as supplied -- an already-resolved
+ * address stays bound to that one IP.
*/
+ @Deprecated
RESOLVE_CONTACT_POINTS("advanced.resolve-contact-points"),
/**
diff --git a/core/src/main/java/com/datastax/oss/driver/api/core/config/OptionsMap.java b/core/src/main/java/com/datastax/oss/driver/api/core/config/OptionsMap.java
index c1a428b3524..bc5bf51c2ba 100644
--- a/core/src/main/java/com/datastax/oss/driver/api/core/config/OptionsMap.java
+++ b/core/src/main/java/com/datastax/oss/driver/api/core/config/OptionsMap.java
@@ -245,6 +245,9 @@ private void readObject(ObjectInputStream stream) throws InvalidObjectException
throw new InvalidObjectException("Proxy required");
}
+ // RESOLVE_CONTACT_POINTS is deprecated and has no effect, but it is still a driver option, so the
+ // defaults map stays complete by carrying its reference.conf value.
+ @SuppressWarnings("deprecation")
protected static void fillWithDriverDefaults(OptionsMap map) {
Duration initQueryTimeout = Duration.ofSeconds(5);
Duration requestTimeout = Duration.ofSeconds(2);
@@ -276,6 +279,7 @@ protected static void fillWithDriverDefaults(OptionsMap map) {
map.put(TypedDriverOption.CONNECTION_POOL_INIT_BATCH_SIZE, 0);
map.put(TypedDriverOption.CONNECTION_MAX_REQUESTS, 1024);
map.put(TypedDriverOption.CONNECTION_MAX_ORPHAN_REQUESTS, 256);
+ map.put(TypedDriverOption.CONNECTION_MAX_CANDIDATE_ADDRESSES, 5);
map.put(TypedDriverOption.CONNECTION_WARN_INIT_ERROR, true);
map.put(TypedDriverOption.CONNECTION_ADVANCED_SHARD_AWARENESS_ENABLED, true);
map.put(TypedDriverOption.ADVANCED_SHARD_AWARENESS_PORT_LOW, 10000);
@@ -369,7 +373,7 @@ protected static void fillWithDriverDefaults(OptionsMap map) {
map.put(TypedDriverOption.CONTROL_CONNECTION_AGREEMENT_INTERVAL, Duration.ofMillis(200));
map.put(TypedDriverOption.CONTROL_CONNECTION_AGREEMENT_TIMEOUT, Duration.ofSeconds(10));
map.put(TypedDriverOption.CONTROL_CONNECTION_AGREEMENT_WARN, true);
- map.put(TypedDriverOption.CONTROL_CONNECTION_RECONNECT_CONTACT_POINTS, false);
+ map.put(TypedDriverOption.CONTROL_CONNECTION_RECONNECT_CONTACT_POINTS, true);
map.put(TypedDriverOption.PREPARE_ON_ALL_NODES, true);
map.put(TypedDriverOption.REPREPARE_ENABLED, true);
map.put(TypedDriverOption.REPREPARE_CHECK_SYSTEM_TABLE, false);
diff --git a/core/src/main/java/com/datastax/oss/driver/api/core/config/TypedDriverOption.java b/core/src/main/java/com/datastax/oss/driver/api/core/config/TypedDriverOption.java
index af93e734ef1..fe7888682bc 100644
--- a/core/src/main/java/com/datastax/oss/driver/api/core/config/TypedDriverOption.java
+++ b/core/src/main/java/com/datastax/oss/driver/api/core/config/TypedDriverOption.java
@@ -172,6 +172,13 @@ public String toString() {
public static final TypedDriverOption CONNECTION_MAX_ORPHAN_REQUESTS =
new TypedDriverOption<>(
DefaultDriverOption.CONNECTION_MAX_ORPHAN_REQUESTS, GenericType.INTEGER);
+ /**
+ * The maximum number of addresses a single connection attempt will try, when the endpoint it
+ * connects to is a DNS name that resolves to several addresses.
+ */
+ public static final TypedDriverOption CONNECTION_MAX_CANDIDATE_ADDRESSES =
+ new TypedDriverOption<>(
+ DefaultDriverOption.CONNECTION_MAX_CANDIDATE_ADDRESSES, GenericType.INTEGER);
/** Whether to log non-fatal errors when the driver tries to open a new connection. */
public static final TypedDriverOption CONNECTION_WARN_INIT_ERROR =
new TypedDriverOption<>(DefaultDriverOption.CONNECTION_WARN_INIT_ERROR, GenericType.BOOLEAN);
@@ -600,7 +607,15 @@ public String toString() {
public static final TypedDriverOption CONTROL_CONNECTION_AGREEMENT_WARN =
new TypedDriverOption<>(
DefaultDriverOption.CONTROL_CONNECTION_AGREEMENT_WARN, GenericType.BOOLEAN);
- /** Whether to forcibly try original contacts if no live nodes are available */
+ /**
+ * Whether to append the original contact points to the control-connection reconnection plan,
+ * after the live nodes reported by the load balancing policy (defaults to {@code true}).
+ *
+ * Contact points are appended as-is (unresolved hostnames); each is expanded to all of its
+ * current DNS IPs at connection time, which is also the driver's DNS re-resolution mechanism. The
+ * append is skipped for topology monitors that re-resolve node addresses themselves (such as the
+ * cloud/proxy monitors).
+ */
public static final TypedDriverOption CONTROL_CONNECTION_RECONNECT_CONTACT_POINTS =
new TypedDriverOption<>(
DefaultDriverOption.CONTROL_CONNECTION_RECONNECT_CONTACT_POINTS, GenericType.BOOLEAN);
@@ -664,7 +679,16 @@ public String toString() {
/** The coalescer reschedule interval. */
public static final TypedDriverOption COALESCER_INTERVAL =
new TypedDriverOption<>(DefaultDriverOption.COALESCER_INTERVAL, GenericType.DURATION);
- /** Whether to resolve the addresses passed to `basic.contact-points`. */
+ /**
+ * Whether to resolve the addresses passed to `basic.contact-points`.
+ *
+ * @deprecated Setting this option has no effect. Contact points given in the configuration are
+ * now always kept as unresolved hostnames and expanded to all of their DNS-mapped IPs lazily
+ * at connection time. This never applied to programmatic contact points passed to {@code
+ * SessionBuilder.addContactPoints}, which are used exactly as supplied -- an already-resolved
+ * address stays bound to that one IP.
+ */
+ @Deprecated
public static final TypedDriverOption RESOLVE_CONTACT_POINTS =
new TypedDriverOption<>(DefaultDriverOption.RESOLVE_CONTACT_POINTS, GenericType.BOOLEAN);
/**
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..f1ed531df01 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,24 +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 the socket address connections should be opened to.
*
*
This will be called each time the driver opens a new connection to the node. The returned
* address cannot be null.
+ *
+ *
Returning a hostname is fine, and is how multi-address support works. The returned
+ * address need not be resolved: an {@linkplain java.net.InetSocketAddress#isUnresolved()
+ * unresolved} {@link java.net.InetSocketAddress} is expanded by the driver to every
+ * address the name maps to, and each one is tried in turn until a connection succeeds. That is
+ * what {@code DefaultEndPoint} does for contact points backed by a hostname, so a single
+ * unreachable IP behind a multi-record name no longer fails the connection.
+ *
+ *
Implementations must not resolve names themselves, and must not block. The driver
+ * calls this from its admin event loop, and it performs the expansion through Netty's configured
+ * {@code AddressResolverGroup} — the same resolver an unresolved address reaches when it is
+ * handed to {@code Bootstrap.connect()}. Looking the name up here instead (for example with
+ * {@link java.net.InetAddress#getAllByName(String)}) would both block that loop and bypass a
+ * custom resolver installed via {@code NettyOptions#afterBootstrapInitialized(Bootstrap)}.
+ *
+ *
Callers must not assume the returned address is resolved. It is for a node discovered
+ * from {@code system.peers} (built from that node's physical broadcast RPC address) and for the
+ * node the control connection is on (bound to the address that connection reached). It is
+ * not for a node reached through the Cloud SNI proxy, or through a cloud private-endpoint
+ * client route: there the address is the configured hostname, and {@link
+ * java.net.InetSocketAddress#getAddress()} returns {@code null}. Read the host with {@link
+ * java.net.InetSocketAddress#getHostString()}, which yields whichever of the two the address
+ * carries and never triggers a reverse lookup.
+ *
+ * @apiNote Timeout note: when a name expands to several addresses they are tried in
+ * sequence, so the worst-case time before the node is declared unreachable is N times a full
+ * attempt — and an attempt is more than a connect. Each address that accepts the TCP
+ * connection then runs the init handshake, whose steps each arm their own {@code
+ * advanced.connection.init-query-timeout}; those add up rather than sharing one deadline. An
+ * address that stalls after accepting the connection can therefore burn {@code
+ * advanced.connection.connect-timeout} plus several times {@code
+ * advanced.connection.init-query-timeout} on its own. In practice DNS round-robin entries
+ * have only a small number of records, so this is rarely a concern, but it is worth bearing
+ * in mind when configuring timeouts — note also that session initialization has no overall
+ * deadline of its own.
*/
@NonNull
SocketAddress resolve();
diff --git a/core/src/main/java/com/datastax/oss/driver/api/core/session/SessionBuilder.java b/core/src/main/java/com/datastax/oss/driver/api/core/session/SessionBuilder.java
index 8375f0ef30b..409ac5a589f 100644
--- a/core/src/main/java/com/datastax/oss/driver/api/core/session/SessionBuilder.java
+++ b/core/src/main/java/com/datastax/oss/driver/api/core/session/SessionBuilder.java
@@ -166,11 +166,15 @@ protected DriverConfigLoader defaultConfigLoader(@Nullable ClassLoader classLoad
*
Contact points can also be provided statically in the configuration. If both are specified,
* they will be merged. If both are absent, the driver will default to 127.0.0.1:9042.
*
- *
Contrary to the configuration, DNS names with multiple A-records will not be handled here.
- * If you need that, extract them manually with {@link java.net.InetAddress#getAllByName(String)}
- * before calling this method. Similarly, if you need connect addresses to stay unresolved, make
- * sure you pass unresolved instances here (see {@code advanced.resolve-contact-points} in the
- * configuration for more explanations).
+ *
The driver automatically expands any contact point backed by an unresolved hostname to all
+ * its DNS-mapped IPs at connection time (through Netty's configured resolver, so a custom {@code
+ * AddressResolverGroup} still applies), so passing a single hostname is sufficient to try all its
+ * IPs on initial connect. This applies equally to hostnames provided here programmatically (build
+ * an unresolved {@link InetSocketAddress} with {@link InetSocketAddress#createUnresolved(String,
+ * int)} to opt in) and to hostnames specified in the configuration. An already-resolved address
+ * passed here (the common case when constructing an {@code InetSocketAddress} directly from a
+ * hostname, which resolves eagerly) is used as provided, with no further expansion. The {@code
+ * advanced.resolve-contact-points} option is deprecated and has no effect.
*/
@NonNull
public SelfT addContactPoints(@NonNull Collection contactPoints) {
@@ -741,6 +745,23 @@ public SelfT withCloudSecureConnectBundle(@NonNull InputStream cloudConfigInputS
*
* For more information, please refer to the DataStax Astra documentation.
*
+ *
A proxy given as a hostname is resolved at connection time, to all of its addresses,
+ * and each is tried in turn. That holds however the {@link InetSocketAddress} was built: the
+ * driver keeps a proxy hostname unresolved internally, so passing one that the ordinary {@code
+ * InetSocketAddress(String, int)} constructor already resolved does not bind the session to that
+ * single address.
+ *
+ *
Prefer {@link InetSocketAddress#createUnresolved(String, int)} all the same, and especially
+ * for a proxy given as an IP address. Whether an address carries a name is read from
+ * {@code getHostString()}, which falls back to the underlying {@link java.net.InetAddress}'s
+ * cached host name -- and that field is filled in, on the very instance passed here, the first
+ * time anything calls {@code getHostName()} on it. The SNI SSL engine does exactly that while
+ * building an engine, unless reverse-lookup SANs are turned off. So an IP that has a {@code PTR}
+ * record can acquire a name mid-session, after which the driver treats that name as the proxy:
+ * the endpoints it builds from then on compare unequal to the earlier ones, report metrics under
+ * a different prefix, and connect to wherever that name resolves. An unresolved address is never
+ * subject to this, and is what the secure connect bundle produces.
+ *
* @param cloudProxyAddress The address of the Cloud proxy to use.
* @see Server Name Indication
*/
@@ -957,11 +978,15 @@ protected final CompletionStage buildDefaultSessionAsync() {
programmaticArguments = programmaticArgumentsBuilder.build();
}
- boolean resolveAddresses =
- defaultConfig.getBoolean(DefaultDriverOption.RESOLVE_CONTACT_POINTS, false);
-
+ // RESOLVE_CONTACT_POINTS is deprecated: contact points are always kept as unresolved
+ // hostnames, and expanded to all their DNS IPs at connection time by ChannelFactory.
+ // The value is still read, only to tell someone who set it that it no longer does anything.
+ // Note this tests the value rather than isDefined(): unlike the deprecated options warned
+ // about in DefaultDriverContext, this one ships uncommented in reference.conf, so it is
+ // always defined.
+ warnIfResolveContactPointsRequested(defaultConfig);
Set contactPoints =
- ContactPoints.merge(programmaticContactPoints, configContactPoints, resolveAddresses);
+ ContactPoints.merge(programmaticContactPoints, configContactPoints, false);
if (keyspace == null && defaultConfig.isDefined(DefaultDriverOption.SESSION_KEYSPACE)) {
keyspace =
@@ -989,6 +1014,24 @@ private boolean anyProfileHasDatacenterDefined(DriverConfig driverConfig) {
return false;
}
+ /**
+ * Tells anyone who turned {@code advanced.resolve-contact-points} on that it no longer does
+ * anything, so the behaviour they configured does not disappear in silence.
+ */
+ @SuppressWarnings("deprecation")
+ private static void warnIfResolveContactPointsRequested(DriverExecutionProfile defaultConfig) {
+ if (defaultConfig.getBoolean(DefaultDriverOption.RESOLVE_CONTACT_POINTS, false)) {
+ LOG.warn(
+ "Option {} is deprecated and no longer has any effect. Contact points given in the"
+ + " configuration are now always kept as unresolved hostnames and expanded to all of"
+ + " their addresses at connection time, so a name that resolves to several nodes is"
+ + " one Node in the driver's metadata rather than one per address. Note that this"
+ + " never applied to contact points passed to addContactPoints(): those are used"
+ + " exactly as supplied, and an already-resolved address stays bound to that one IP.",
+ DefaultDriverOption.RESOLVE_CONTACT_POINTS.getPath());
+ }
+ }
+
/**
* Returns URL based on the configUrl setting. If the configUrl has no protocol provided, the
* method will fallback to file:// protocol and return URL that has file protocol specified.
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/addresstranslation/FixedHostNameAddressTranslator.java b/core/src/main/java/com/datastax/oss/driver/internal/core/addresstranslation/FixedHostNameAddressTranslator.java
index 0ee1e22a7ac..70db25d2a5c 100644
--- a/core/src/main/java/com/datastax/oss/driver/internal/core/addresstranslation/FixedHostNameAddressTranslator.java
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/addresstranslation/FixedHostNameAddressTranslator.java
@@ -53,12 +53,24 @@ public FixedHostNameAddressTranslator(@NonNull DriverContext context) {
context.getConfig().getDefaultProfile().getString(ADDRESS_TRANSLATOR_ADVERTISED_HOSTNAME);
}
+ /**
+ * {@inheritDoc}
+ *
+ * The advertised host name is returned {@linkplain InetSocketAddress#isUnresolved()
+ * unresolved}, so that {@link com.datastax.oss.driver.internal.core.channel.ChannelFactory}
+ * expands it per connect and can try every address it maps to. Resolving it here -- which {@code
+ * new InetSocketAddress(String, int)} does eagerly -- would freeze the whole cluster on whichever
+ * address the JDK happened to return first, and no other would ever be tried: the resolver
+ * reports an already-resolved address as nothing to do, so the expansion is skipped entirely.
+ * That matters precisely for the deployment this translator is for, where one name fronts a proxy
+ * or load balancer that is itself typically several addresses.
+ */
@NonNull
@Override
public InetSocketAddress translate(@NonNull InetSocketAddress address) {
final int port = address.getPort();
LOG.debug("[{}] Resolved {}:{} to {}:{}", logPrefix, address, port, advertisedHostname, port);
- return new InetSocketAddress(advertisedHostname, port);
+ return InetSocketAddress.createUnresolved(advertisedHostname, port);
}
@Override
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/adminrequest/AdminRequestHandler.java b/core/src/main/java/com/datastax/oss/driver/internal/core/adminrequest/AdminRequestHandler.java
index 5078428c21a..02496d68ae1 100644
--- a/core/src/main/java/com/datastax/oss/driver/internal/core/adminrequest/AdminRequestHandler.java
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/adminrequest/AdminRequestHandler.java
@@ -30,8 +30,9 @@
import com.datastax.oss.protocol.internal.Message;
import com.datastax.oss.protocol.internal.ProtocolConstants;
import com.datastax.oss.protocol.internal.request.Query;
+import com.datastax.oss.protocol.internal.request.Register;
import com.datastax.oss.protocol.internal.request.query.QueryOptions;
-import com.datastax.oss.protocol.internal.response.Result;
+import com.datastax.oss.protocol.internal.response.Ready;
import com.datastax.oss.protocol.internal.response.result.Prepared;
import com.datastax.oss.protocol.internal.response.result.Rows;
import io.netty.util.concurrent.Future;
@@ -67,6 +68,24 @@ public static AdminRequestHandler call(
com.datastax.oss.protocol.internal.response.result.Void.class);
}
+ /**
+ * Registers this connection for the given protocol events, as {@link
+ * com.datastax.oss.protocol.internal.request.Register REGISTER} used to be sent as the last step
+ * of protocol initialization.
+ */
+ public static AdminRequestHandler register(
+ DriverChannel channel, List eventTypes, Duration timeout, String logPrefix) {
+ return new AdminRequestHandler<>(
+ channel,
+ true,
+ new Register(eventTypes),
+ Frame.NO_PAYLOAD,
+ timeout,
+ logPrefix,
+ "register for events " + eventTypes,
+ Ready.class);
+ }
+
public static AdminRequestHandler query(
DriverChannel channel,
String query,
@@ -98,7 +117,7 @@ public static AdminRequestHandler query(
private final Duration timeout;
private final String logPrefix;
private final String debugString;
- private final Class extends Result> expectedResponseType;
+ private final Class extends Message> expectedResponseType;
protected final CompletableFuture result = new CompletableFuture<>();
// This is only ever accessed on the channel's event loop, so it doesn't need to be volatile
@@ -112,7 +131,7 @@ protected AdminRequestHandler(
Duration timeout,
String logPrefix,
String debugString,
- Class extends Result> expectedResponseType) {
+ Class extends Message> expectedResponseType) {
this.channel = channel;
this.shouldPreAcquireId = shouldPreAcquireId;
this.message = message;
@@ -190,8 +209,9 @@ public void onResponse(Frame responseFrame) {
@SuppressWarnings("unchecked")
ResultT result = (ResultT) ByteBuffer.wrap(prepared.preparedQueryId);
setFinalResult(result);
- } else if (expectedResponseType
- == com.datastax.oss.protocol.internal.response.result.Void.class) {
+ } else if (expectedResponseType == com.datastax.oss.protocol.internal.response.result.Void.class
+ || expectedResponseType == Ready.class) {
+ // Neither carries a payload: a schema change or a REGISTER acknowledgement.
setFinalResult(null);
} else {
setFinalError(new AssertionError("Unhandled response type" + expectedResponseType));
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 35190afa3f4..6cbf702b519 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
@@ -24,29 +24,41 @@
package com.datastax.oss.driver.internal.core.channel;
import com.datastax.oss.driver.api.core.ConsistencyLevel;
+import com.datastax.oss.driver.api.core.InvalidKeyspaceException;
import com.datastax.oss.driver.api.core.ProtocolVersion;
import com.datastax.oss.driver.api.core.UnsupportedProtocolVersionException;
+import com.datastax.oss.driver.api.core.auth.AuthenticationException;
import com.datastax.oss.driver.api.core.config.DefaultDriverOption;
import com.datastax.oss.driver.api.core.config.DriverConfig;
import com.datastax.oss.driver.api.core.config.DriverExecutionProfile;
+import com.datastax.oss.driver.api.core.connection.ConnectionInitException;
import com.datastax.oss.driver.api.core.context.DriverContext;
import com.datastax.oss.driver.api.core.metadata.EndPoint;
import com.datastax.oss.driver.api.core.metadata.Node;
import com.datastax.oss.driver.api.core.metadata.NodeShardingInfo;
import com.datastax.oss.driver.api.core.metrics.DefaultNodeMetric;
import com.datastax.oss.driver.api.core.metrics.DefaultSessionMetric;
+import com.datastax.oss.driver.internal.core.adminrequest.AdminRequestHandler;
+import com.datastax.oss.driver.internal.core.adminrequest.UnexpectedResponseException;
import com.datastax.oss.driver.internal.core.config.typesafe.TypesafeDriverConfig;
import com.datastax.oss.driver.internal.core.context.InternalDriverContext;
import com.datastax.oss.driver.internal.core.context.NettyOptions;
import com.datastax.oss.driver.internal.core.metadata.DefaultNode;
+import com.datastax.oss.driver.internal.core.metadata.PinnableEndPoint;
import com.datastax.oss.driver.internal.core.metrics.NodeMetricUpdater;
import com.datastax.oss.driver.internal.core.metrics.NoopNodeMetricUpdater;
import com.datastax.oss.driver.internal.core.metrics.SessionMetricUpdater;
import com.datastax.oss.driver.internal.core.protocol.FrameDecoder;
import com.datastax.oss.driver.internal.core.protocol.FrameEncoder;
+import com.datastax.oss.driver.internal.core.util.AddressUtils;
+import com.datastax.oss.driver.internal.core.util.ProtocolUtils;
+import com.datastax.oss.driver.internal.core.util.concurrent.CompletableFutures;
import com.datastax.oss.driver.shaded.guava.common.annotations.VisibleForTesting;
import com.datastax.oss.driver.shaded.guava.common.base.Preconditions;
import com.datastax.oss.driver.shaded.guava.common.collect.ImmutableMap;
+import com.datastax.oss.driver.shaded.guava.common.net.InetAddresses;
+import com.datastax.oss.protocol.internal.Message;
+import com.datastax.oss.protocol.internal.ProtocolConstants;
import com.datastax.oss.protocol.internal.ProtocolFeatures;
import io.netty.bootstrap.Bootstrap;
import io.netty.channel.Channel;
@@ -54,16 +66,32 @@
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.ChannelPipeline;
+import io.netty.channel.EventLoop;
+import io.netty.resolver.AddressResolver;
+import io.netty.resolver.AddressResolverGroup;
+import io.netty.util.concurrent.Future;
+import io.netty.util.concurrent.ScheduledFuture;
import java.io.IOException;
+import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.SocketAddress;
+import java.net.UnknownHostException;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.Random;
+import java.util.Set;
import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import net.jcip.annotations.ThreadSafe;
@@ -127,6 +155,21 @@ public static int effectiveMaxOrphanRequests(
private final String logPrefix;
protected final InternalDriverContext context;
+ /**
+ * Guards the one-time warning in {@link #newBootstrap()}. Per factory rather than per JVM: what
+ * it reports is a property of this session's {@link NettyOptions}, and the message names the
+ * session, so a JVM-wide latch would report the first offender and silence every one after it.
+ */
+ private final AtomicBoolean loggedHandlerWarning = new AtomicBoolean();
+
+ private final AtomicBoolean loggedGroupWarning = new AtomicBoolean();
+
+ /**
+ * Randomizes the order in which a name's expanded addresses are tried (see {@link
+ * #shuffleAndLimit}). Injectable so tests can seed it and observe a deterministic order.
+ */
+ @VisibleForTesting Random random = new Random();
+
/** either set from the configuration, or null and will be negotiated */
@VisibleForTesting volatile ProtocolVersion protocolVersion;
@@ -145,6 +188,7 @@ public ChannelFactory(InternalDriverContext context) {
this.context = context;
DriverExecutionProfile defaultConfig = context.getConfig().getDefaultProfile();
+
if (defaultConfig.isDefined(DefaultDriverOption.PROTOCOL_VERSION)) {
String versionName = defaultConfig.getString(DefaultDriverOption.PROTOCOL_VERSION);
this.protocolVersion = context.getProtocolVersionRegistry().fromName(versionName);
@@ -181,7 +225,7 @@ public CompletionStage connect(Node node, DriverChannelOptions op
} else {
nodeMetricUpdater = NoopNodeMetricUpdater.INSTANCE;
}
- return connect(node.getEndPoint(), null, null, options, nodeMetricUpdater);
+ return connect(node.getEndPoint(), null, null, options, nodeMetricUpdater, isIdentified(node));
}
public CompletionStage connect(
@@ -192,7 +236,31 @@ public CompletionStage connect(
} else {
nodeMetricUpdater = NoopNodeMetricUpdater.INSTANCE;
}
- return connect(node.getEndPoint(), node.getShardingInfo(), shardId, options, nodeMetricUpdater);
+ return connect(
+ node.getEndPoint(),
+ node.getShardingInfo(),
+ shardId,
+ options,
+ nodeMetricUpdater,
+ isIdentified(node));
+ }
+
+ /**
+ * Whether we know which node we are connecting to, as opposed to merely which address to
+ * try. Both {@link #spreadAcrossAddresses} and {@link #sameServerAtEveryAddress} turn on it,
+ * because an unidentified contact-point name may expand to addresses of different nodes,
+ * while every address of an identified node is that same node.
+ *
+ * {@link Node#getHostId()} is null exactly for a contact point, and stays null for the life of
+ * that instance: {@code MetadataManager.registerNode} mints a fresh {@code DefaultNode} from each
+ * {@code system.local}/{@code system.peers} row rather than back-filling the contact point it was
+ * reached through, and those ephemeral contact-point nodes are never added to metadata. So this
+ * is not a state a contact point grows out of once the driver has read host ids -- the driver
+ * simply stops using that instance for anything except the reconnection fallback, which keeps
+ * handing it back.
+ */
+ private static boolean isIdentified(Node node) {
+ return node.getHostId() != null;
}
@VisibleForTesting
@@ -202,11 +270,23 @@ CompletionStage connect(
Integer shardId,
DriverChannelOptions options,
NodeMetricUpdater nodeMetricUpdater) {
+ // A bare endpoint carries no host id, so this matches the contact-point case (see
+ // isIdentified()).
+ return connect(endPoint, shardingInfo, shardId, options, nodeMetricUpdater, false);
+ }
+
+ @VisibleForTesting
+ CompletionStage connect(
+ EndPoint endPoint,
+ NodeShardingInfo shardingInfo,
+ Integer shardId,
+ DriverChannelOptions options,
+ NodeMetricUpdater nodeMetricUpdater,
+ boolean nodeIsIdentified) {
CompletableFuture resultFuture = new CompletableFuture<>();
ProtocolVersion currentVersion;
boolean isNegotiating;
- List attemptedVersions = new CopyOnWriteArrayList<>();
if (this.protocolVersion != null) {
currentVersion = protocolVersion;
isNegotiating = false;
@@ -223,7 +303,7 @@ CompletionStage connect(
nodeMetricUpdater,
currentVersion,
isNegotiating,
- attemptedVersions,
+ nodeIsIdentified,
resultFuture);
return resultFuture;
}
@@ -236,121 +316,1653 @@ private void connect(
NodeMetricUpdater nodeMetricUpdater,
ProtocolVersion currentVersion,
boolean isNegotiating,
- List attemptedVersions,
+ boolean nodeIsIdentified,
CompletableFuture resultFuture) {
- SocketAddress resolvedAddress;
+ // Built once per connect() rather than once per candidate: it is the only handle on the Netty
+ // AddressResolverGroup (see resolveCandidates()), and it means the user's
+ // afterBootstrapInitialized() hook runs once per logical connection instead of once per address
+ // attempt. Each attempt gets its own clone() with its own handler.
+ //
+ // The event loop is likewise picked once per connect() and shared by name resolution and the
+ // channel itself (the per-attempt clones are bound to it, see connectToAddress()). Advancing
+ // the group's round-robin chooser exactly once per connect keeps channels evenly distributed:
+ // taking one loop for resolution and letting Bootstrap.connect() take another would advance
+ // the chooser twice per connect, parking all channels on half the loops with the default
+ // power-of-two chooser. It also mirrors what Netty itself does with an unresolved address:
+ // Bootstrap resolves on the connecting channel's own event loop.
+ Bootstrap baseBootstrap;
+ EventLoop eventLoop;
try {
- resolvedAddress = endPoint.resolve();
- } catch (Exception e) {
+ baseBootstrap = newBootstrap();
+ eventLoop = context.getNettyOptions().ioEventLoopGroup().next();
+ } catch (Throwable e) {
resultFuture.completeExceptionally(e);
return;
}
- NettyOptions nettyOptions = context.getNettyOptions();
+ // EndPoint.resolve() is contractually non-blocking and performs no name resolution, so it is
+ // safe to call here even though connect() runs on the admin event loop for control-connection
+ // reconnects. Everything a name needs to become connectable happens in resolveCandidates().
+ SocketAddress address;
+ try {
+ address = endPoint.resolve();
+ } catch (Throwable e) {
+ resultFuture.completeExceptionally(e);
+ return;
+ }
+ if (address == null) {
+ // EndPoint.resolve() is contractually non-null; fail fast instead of NPE-ing inside an
+ // event-loop task later, which would leave resultFuture hanging (see resolveCandidates()).
+ resultFuture.completeExceptionally(
+ new IllegalArgumentException("EndPoint.resolve() returned null: " + endPoint));
+ return;
+ }
+
+ // Guarded for the same reason resolve() is: addressesAreInterchangeable() calls through to
+ // PinnableEndPoint.addressesAreInterchangeable(), another method the endpoint implementation
+ // supplies and can therefore throw from. As a bare argument to resolveCandidates() it would
+ // escape connect() synchronously, and nothing upstream would complete resultFuture:
+ // ControlConnection.reconnect() does not wrap its connect() call, and the recursive ones run
+ // inside a whenCompleteAsync callback with no catch, so the throwable would be swallowed and
+ // the attempt left hanging with Reconnection stuck in ATTEMPT_IN_PROGRESS.
+ //
+ // Throwable, not Exception, and likewise for the two guards above: an endpoint supplied by
+ // someone else can fail with an Error just as easily as with an exception -- a
+ // NoClassDefFoundError or ExceptionInInitializerError out of lazy class initialization in a
+ // shaded or OSGi deployment, an AssertionError under -ea -- and a hang is the outcome either
+ // way. Every other guard in this class that owns a future's completion already catches
+ // Throwable.
+ boolean interchangeable;
+ try {
+ interchangeable = addressesAreInterchangeable(endPoint, address);
+ } catch (Throwable e) {
+ resultFuture.completeExceptionally(e);
+ return;
+ }
+
+ // Two questions over the same two facts. Not each other's negation: a connect can be both
+ // (an identified node behind an SNI proxy) or neither (an identified node on a plain name).
+ boolean spreadAcrossAddresses = spreadAcrossAddresses(nodeIsIdentified, interchangeable);
+ boolean sameServerAtEveryAddress = sameServerAtEveryAddress(nodeIsIdentified, interchangeable);
+ resolveCandidates(baseBootstrap, address, eventLoop, spreadAcrossAddresses)
+ .whenComplete(
+ (candidates, error) -> {
+ if (error != null) {
+ Throwable cause =
+ (error instanceof CompletionException && error.getCause() != null)
+ ? error.getCause()
+ : error;
+ resultFuture.completeExceptionally(cause);
+ return;
+ }
+ tryNextCandidate(
+ baseBootstrap,
+ eventLoop,
+ endPoint,
+ shardingInfo,
+ shardId,
+ options,
+ nodeMetricUpdater,
+ currentVersion,
+ isNegotiating,
+ sameServerAtEveryAddress,
+ resultFuture,
+ candidates,
+ 0,
+ new ArrayList<>());
+ });
+ }
+
+ /**
+ * Builds the {@link Bootstrap} shared by every connection attempt of a single {@code connect()}
+ * call, including the user's {@link NettyOptions#afterBootstrapInitialized(Bootstrap)} hook. Per
+ * attempt, {@link #connectToAddress} takes a {@link
+ * Bootstrap#clone(io.netty.channel.EventLoopGroup)} of it bound to the event loop the connect
+ * picked, and installs its own handler; the copy carries the resolver configuration over. The
+ * base bootstrap itself keeps the full I/O group, so the hook observes the same group as always.
+ */
+ private Bootstrap newBootstrap() {
+ NettyOptions nettyOptions = context.getNettyOptions();
Bootstrap bootstrap =
new Bootstrap()
.group(nettyOptions.ioEventLoopGroup())
.channel(nettyOptions.channelClass())
- .option(ChannelOption.ALLOCATOR, nettyOptions.allocator())
- .handler(
- initializer(endPoint, currentVersion, options, nodeMetricUpdater, resultFuture));
-
+ .option(ChannelOption.ALLOCATOR, nettyOptions.allocator());
nettyOptions.afterBootstrapInitialized(bootstrap);
+ if (bootstrap.config().handler() != null && loggedHandlerWarning.compareAndSet(false, true)) {
+ LOG.warn(
+ "[{}] NettyOptions.afterBootstrapInitialized() installed a channel handler on the"
+ + " bootstrap; it will be replaced by the driver's own handler. Use"
+ + " NettyOptions.afterChannelInitialized() to customize the pipeline instead.",
+ logPrefix);
+ }
+ // Same shape as the handler above, and for the same reason: connectToAddress() clones this
+ // bootstrap with clone(eventLoop), which assigns the group unconditionally, so a group the
+ // hook set here is dropped and the driver's own ioEventLoopGroup is used instead. Silently
+ // moving a deployment's I/O back onto the driver's threads is exactly the kind of thing that
+ // is noticed months later, so say it once.
+ if (bootstrap.config().group() != nettyOptions.ioEventLoopGroup()
+ && loggedGroupWarning.compareAndSet(false, true)) {
+ LOG.warn(
+ "[{}] NettyOptions.afterBootstrapInitialized() replaced the bootstrap's event loop"
+ + " group; it will be ignored, because each connection attempt is bound to an event"
+ + " loop picked from NettyOptions.ioEventLoopGroup(). Override ioEventLoopGroup() to"
+ + " run driver I/O on your own threads.",
+ logPrefix);
+ }
+ return bootstrap;
+ }
- ChannelFuture connectFuture;
- if (shardId == null || shardingInfo == null) {
- if (shardId != null) {
- LOG.debug(
- "Requested connection to shard {} but shardingInfo is currently missing for Node at endpoint {}. Falling back to arbitrary local port.",
- shardId,
- endPoint);
- }
- connectFuture = bootstrap.connect(resolvedAddress);
- } else {
- int localPort =
- PortAllocator.getNextAvailablePort(shardingInfo.getShardsCount(), shardId, context);
- if (localPort == -1) {
- LOG.warn(
- "Could not find free port for shard {} at {}. Falling back to arbitrary local port.",
- shardId,
- endPoint);
- connectFuture = bootstrap.connect(resolvedAddress);
- } else {
- connectFuture = bootstrap.connect(resolvedAddress, new InetSocketAddress(localPort));
+ /**
+ * Turns the address an {@link EndPoint} denotes into the concrete, connectable addresses to try,
+ * expanding it to all the addresses it maps to when it is a name.
+ *
+ * Expansion goes through the bootstrap's Netty {@link AddressResolverGroup} rather than a
+ * direct {@code InetAddress.getAllByName()} call, so a custom resolver installed via {@link
+ * NettyOptions#afterBootstrapInitialized(Bootstrap)} is honoured — that is the resolver an
+ * unresolved address would have reached had it been handed straight to {@code
+ * Bootstrap.connect()}, as it was before multi-address support. This is also why endpoints are
+ * forbidden from resolving names themselves (see {@link EndPoint#resolve()}): doing it here is
+ * the only way to keep that configuration point working, and the only way to keep {@code
+ * resolve()} non-blocking.
+ *
+ *
Whether an address needs resolving at all is the resolver's decision, not ours: exactly as
+ * in {@code Bootstrap#doResolveAndConnect0}, the address is passed through untouched only when
+ * the resolver says it does not {@linkplain AddressResolver#isSupported support} it (e.g. {@link
+ * io.netty.channel.local.LocalAddress}) or that it {@linkplain AddressResolver#isResolved is
+ * already resolved}. Both are overridable, and a custom resolver may well report an
+ * already-resolved address as unresolved in order to redirect it — Netty consulted it either way,
+ * so a pre-check here on {@code InetSocketAddress#isUnresolved()} would silently take that
+ * configuration point away for every connect to an already-resolved node, which is to say for
+ * almost every connect. A null group means the user called {@link Bootstrap#disableResolver()},
+ * which is likewise respected.
+ *
+ *
On the two branches that pass the address through without resolving it -- no resolver
+ * at all, or one that declines the address -- an unresolved IP literal is materialized locally
+ * instead of being failed; see {@link #materializeLiteral}. A host name still fails there, with a
+ * message naming which of the two put it in that position.
+ *
+ *
Note that with Netty's default resolver the lookup blocks the event loop it runs on,
+ * because {@code DefaultNameResolver} performs {@code InetAddress.getAllByName()} inline. That is
+ * the pre-existing behaviour of handing an unresolved address to {@code Bootstrap.connect()}, and
+ * it is an I/O loop, never the admin loop that {@code connect()} is called from. Deployments that
+ * need non-blocking resolution can now install {@code DnsAddressResolverGroup} and have it take
+ * effect.
+ */
+ private CompletionStage> resolveCandidates(
+ Bootstrap bootstrap,
+ SocketAddress address,
+ EventLoop eventLoop,
+ boolean spreadAcrossAddresses) {
+
+ AddressResolverGroup> resolverGroup = bootstrap.config().resolver();
+ if (resolverGroup == null) {
+ // Bootstrap.disableResolver(): the user wants the address passed through as-is, which only
+ // works if it is usable as-is -- or can be made so without resolving anything.
+ SocketAddress literal = materializeLiteral(address);
+ if (literal != null) {
+ return CompletableFuture.completedFuture(Collections.singletonList(literal));
}
+ IllegalStateException unusable =
+ unusableWithoutResolution(
+ address,
+ "the bootstrap has name resolution disabled",
+ "Either remove Bootstrap.disableResolver() from"
+ + " NettyOptions.afterBootstrapInitialized(), or supply an already-resolved"
+ + " address.");
+ return (unusable != null)
+ ? CompletableFutures.failedFuture(unusable)
+ : CompletableFuture.completedFuture(Collections.singletonList(address));
}
- connectFuture.addListener(
- cf -> {
- if (connectFuture.isSuccess()) {
- Channel channel = connectFuture.channel();
- DriverChannel driverChannel =
- new DriverChannel(endPoint, channel, context.getWriteCoalescer(), currentVersion);
- // If this is the first successful connection, remember the protocol version and
- // cluster name for future connections.
- if (isNegotiating) {
- ChannelFactory.this.protocolVersion = currentVersion;
- }
- if (ChannelFactory.this.clusterName == null) {
- ChannelFactory.this.clusterName = driverChannel.getClusterName();
- }
- Map> supportedOptions = driverChannel.getOptions();
- if (ChannelFactory.this.productType == null && supportedOptions != null) {
- List productTypes = supportedOptions.get("PRODUCT_TYPE");
- String productType =
- productTypes != null && !productTypes.isEmpty()
- ? productTypes.get(0)
- : UNKNOWN_PRODUCT_TYPE;
- ChannelFactory.this.productType = productType;
- DriverConfig driverConfig = context.getConfig();
- if (driverConfig instanceof TypesafeDriverConfig
- && productType.equals(DATASTAX_CLOUD_PRODUCT_TYPE)) {
- ((TypesafeDriverConfig) driverConfig)
- .overrideDefaults(
- ImmutableMap.of(
- DefaultDriverOption.REQUEST_CONSISTENCY,
- ConsistencyLevel.LOCAL_QUORUM.name()));
+ // The supplied event loop is the same one the channel will be registered on (see connect()),
+ // which is what Netty itself does with an unresolved address: Bootstrap resolves on the
+ // connecting channel's own event loop. Its transport also matches the channel class, which
+ // matters because DnsAddressResolverGroup registers a datagram channel on the executor it
+ // resolves for.
+ CompletableFuture> result = new CompletableFuture<>();
+ // Every path below must complete `result`: nothing at this stage has a timeout, so a task or
+ // listener that dies with the future still pending (Netty swallows their throwables, it only
+ // logs them) would hang the connect attempt -- and with it control-connection init or a pool
+ // reconnect -- forever. Hence the blanket catches around the task body, the listener body, and
+ // the execute() call itself (which throws RejectedExecutionException while shutting down).
+ try {
+ eventLoop.execute(
+ () -> {
+ try {
+ AddressResolver extends SocketAddress> resolver =
+ resolverGroup.getResolver(eventLoop);
+ boolean unsupported = !resolver.isSupported(address);
+ if (unsupported || resolver.isResolved(address)) {
+ // Nothing for the resolver to do; same short-circuit as
+ // Bootstrap#doResolveAndConnect0. An address the resolver declines is in the same
+ // position as one with no resolver at all, so it gets the same check; an
+ // already-resolved one is usable by definition and passes straight through.
+ //
+ // The two halves report their own cause: they are diagnosed differently, and naming
+ // the wrong one sends the operator to look for a disableResolver() nobody called.
+ //
+ // An IP literal is rescued on the unsupported half only, for the same reason as
+ // under disableResolver(): nothing downstream will resolve it, and nothing has to.
+ // Not on the other half, where the resolver has claimed the address is already
+ // resolved while it plainly is not -- silently papering over that would leave the
+ // contradiction to surface somewhere less diagnosable.
+ SocketAddress literal = unsupported ? materializeLiteral(address) : null;
+ if (literal != null) {
+ result.complete(Collections.singletonList(literal));
+ return;
+ }
+ IllegalStateException unusable =
+ unsupported
+ ? unusableWithoutResolution(
+ address,
+ "the configured resolver does not support this address",
+ "Either install a resolver that supports it in"
+ + " NettyOptions.afterBootstrapInitialized(), or supply an"
+ + " already-resolved address.")
+ : unusableWithoutResolution(
+ address,
+ "the configured resolver reports it as already resolved",
+ "That is a contradiction: fix the resolver's isResolved()"
+ + " implementation, or supply an already-resolved address.");
+ if (unusable != null) {
+ result.completeExceptionally(unusable);
+ } else {
+ result.complete(Collections.singletonList(address));
+ }
+ return;
}
+ resolver
+ .resolveAll(address)
+ .addListener(
+ (Future super List extends SocketAddress>> future) -> {
+ try {
+ if (!future.isSuccess()) {
+ result.completeExceptionally(future.cause());
+ return;
+ }
+ @SuppressWarnings("unchecked")
+ List extends SocketAddress> addresses =
+ (List extends SocketAddress>) future.getNow();
+ if (addresses == null || addresses.isEmpty()) {
+ result.completeExceptionally(
+ new IllegalStateException(
+ "Resolver returned no address for " + address));
+ return;
+ }
+ List connectable =
+ dropUnresolved(address, reattachHostnames(address, addresses));
+ if (connectable.isEmpty()) {
+ result.completeExceptionally(
+ new IllegalStateException(
+ String.format(
+ "Cannot connect to %s: the configured resolver (%s) "
+ + "expanded it to %d address(es) and every one of them "
+ + "is still unresolved, so nothing will resolve them.",
+ address, resolver.getClass().getName(), addresses.size())));
+ return;
+ }
+ result.complete(shuffleAndLimit(connectable, spreadAcrossAddresses));
+ } catch (Throwable t) {
+ result.completeExceptionally(t);
+ }
+ });
+ } catch (Throwable t) {
+ result.completeExceptionally(t);
}
- resultFuture.complete(driverChannel);
- } else {
- Throwable error = connectFuture.cause();
- if (error instanceof UnsupportedProtocolVersionException && isNegotiating) {
- attemptedVersions.add(currentVersion);
- Optional downgraded =
- context.getProtocolVersionRegistry().downgrade(currentVersion);
- if (downgraded.isPresent()) {
+ });
+ } catch (Throwable t) {
+ result.completeExceptionally(t);
+ }
+ return result;
+ }
+
+ /**
+ * The failure to report when {@link #resolveCandidates} is about to pass an address through
+ * without resolving it, or {@code null} if passing it through is fine.
+ *
+ * {@link #connectToAddress} hands the candidate to a bootstrap clone with {@link
+ * Bootstrap#disableResolver()}, so an address that is still unresolved by the time it gets there
+ * cannot connect: Netty raises {@code UnresolvedAddressException} from inside {@code doConnect},
+ * naming neither the address nor the reason nothing resolved it. That is a hard failure of every
+ * connection attempt for the whole session, and it is worth a message that says which endpoint
+ * and which configuration produced it -- the endpoints most likely to hit it (SNI, client routes)
+ * hand out unresolved addresses by design, and contact-point hostnames are now always kept
+ * unresolved.
+ *
+ *
Deliberately not a general {@code isUnresolved()} pre-check on every path: see {@link
+ * #resolveCandidates}'s javadoc for why an address the resolver merely declines to touch must
+ * still go through. This fires only where nothing downstream will resolve it either -- as does
+ * {@link #dropUnresolved}, which applies the same reasoning to what {@code resolveAll} returns.
+ *
+ *
Its callers try {@link #materializeLiteral} first, so this is reached only by an address
+ * that genuinely needs a name service. "Supply an already-resolved address" is therefore always
+ * advice about a host name.
+ *
+ * @param why what put the address in this position, in a clause that reads after "it is an
+ * unresolved address and".
+ * @param fix what the operator should do about it. Each caller supplies its own: the three
+ * situations that reach here are diagnosed differently, and naming the wrong one sends the
+ * operator looking for configuration nobody wrote.
+ */
+ private static IllegalStateException unusableWithoutResolution(
+ SocketAddress address, String why, String fix) {
+ if (!(address instanceof InetSocketAddress) || !((InetSocketAddress) address).isUnresolved()) {
+ return null;
+ }
+ return new IllegalStateException(
+ String.format(
+ "Cannot connect to %s: it is an unresolved address and %s, so nothing will resolve it. %s",
+ address, why, fix));
+ }
+
+ /**
+ * The address as something connectable when nothing is going to resolve it, or {@code null} if it
+ * is not an unresolved IP literal.
+ *
+ *
A literal needs no name service, so an endpoint that holds one has no business failing on a
+ * path where resolution is unavailable -- and endpoints now hold one routinely, contact points
+ * being kept unresolved whatever they were written as (see {@code
+ * SessionBuilder#addContactPoint}). Before that, {@code 127.0.0.1:9042} arrived here already
+ * resolved and {@link Bootstrap#disableResolver()} worked with it; this keeps that true.
+ *
+ *
Deliberately not a general pre-check. It is applied only where {@link #resolveCandidates} is
+ * already committed to passing the address through unresolved, never before an enabled resolver
+ * has been consulted: a custom resolver is entitled to redirect a literal, exactly as it is
+ * entitled to redirect a name, and testing for one earlier would take that away.
+ *
+ *
Performs no lookup, which is what makes it safe on both call sites -- one runs on a Netty
+ * I/O loop, the other on whatever thread called {@code connect()}, the admin loop for the control
+ * connection. {@link InetAddress#getByName} consults the name service only for a name, and {@link
+ * AddressUtils#carriesName} has just established there is none; it accepts every spelling that
+ * method reports as a literal, the bracketed and zoned forms included.
+ *
+ *
The literal is re-attached as the address's host-name label rather than left off, so that
+ * {@code getHostName()} stays a field read answering what the operator configured. A nameless
+ * address sends {@code DefaultSslEngineFactory} to a reverse lookup on an event loop and has it
+ * validate the certificate against a PTR record -- the same hazard {@link #reattachHostname}'s
+ * literal branch exists to prevent, and the reason the label goes on here rather than being left
+ * to that method, whose byte-matching re-derives through {@code InetAddresses#forString} what is
+ * known here by construction (and which rejects a zoned literal outright). The label is the
+ * spelling that was configured, less the brackets of the URI form -- {@link
+ * InetAddress#getByAddress(String, byte[])} strips those from any host name it is handed. A
+ * non-canonically written literal then makes {@link AddressUtils#carriesName} report {@code true}
+ * for the result: the same imprecision that method already documents, and nothing re-labels a
+ * candidate twice.
+ */
+ @VisibleForTesting
+ static SocketAddress materializeLiteral(SocketAddress address) {
+ if (!(address instanceof InetSocketAddress)) {
+ return null;
+ }
+ InetSocketAddress inet = (InetSocketAddress) address;
+ if (!inet.isUnresolved() || AddressUtils.carriesName(inet)) {
+ return null;
+ }
+ String literal = inet.getHostString();
+ try {
+ return new InetSocketAddress(
+ AddressUtils.withHostName(literal, InetAddress.getByName(literal)), inet.getPort());
+ } catch (UnknownHostException notAfterAll) {
+ // carriesName() and getByName() disagreeing about what a literal is: fall through to the
+ // diagnostic the caller was about to raise, which says more than a bare parse failure.
+ return null;
+ }
+ }
+
+ /** Applies {@link #reattachHostname} to every expanded candidate. */
+ private static List reattachHostnames(
+ SocketAddress original, List extends SocketAddress> candidates) {
+ List result = new ArrayList<>(candidates.size());
+ for (SocketAddress candidate : candidates) {
+ result.add(reattachHostname(original, candidate));
+ }
+ return result;
+ }
+
+ /**
+ * Drops the candidates a resolver returned still unresolved, keeping the order of the rest.
+ *
+ * {@code resolveAll} is contracted to return resolved addresses, but a custom resolver that
+ * rewrites what it is given -- which {@link #resolveCandidates} deliberately supports -- may hand
+ * back one that is not. Such a candidate cannot connect: {@link #connectToAddress} uses a
+ * bootstrap clone with {@link Bootstrap#disableResolver()}, so nothing downstream will resolve it
+ * either, and Netty raises {@code UnresolvedAddressException} from inside {@code doConnect}. This
+ * is the same reasoning as {@link #unusableWithoutResolution}, applied where the addresses come
+ * from the resolver itself; dropping them here rather than after the cap means the cap counts
+ * only addresses that can actually be tried. The caller reports the case where nothing is left,
+ * which is the one that fails every connection attempt for the whole session.
+ */
+ private List dropUnresolved(
+ SocketAddress original, List candidates) {
+ List result = new ArrayList<>(candidates.size());
+ for (SocketAddress candidate : candidates) {
+ if (candidate instanceof InetSocketAddress
+ && ((InetSocketAddress) candidate).isUnresolved()) {
+ LOG.debug(
+ "[{}] Resolver returned {} for {} but it is still unresolved, skipping it",
+ logPrefix,
+ candidate,
+ original);
+ } else {
+ result.add(candidate);
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Re-attaches the {@code original} address's host name to one of the resolved candidates it
+ * expanded to, whatever name that candidate carries.
+ *
+ * The JDK and Netty-DNS resolvers already attach the queried name to the {@link InetAddress}es
+ * they return, so this is a no-op for them. A custom resolver, however, may build its results
+ * from raw address bytes, or label them with a canonical/CNAME name of its own. The channel's
+ * pinned endpoint is built from the candidate (see {@link PinnableEndPoint}), and it is what
+ * {@code DefaultSslEngineFactory} and {@code SniSslEngineFactory} derive the SSL peer host from,
+ * inside the channel initializer. So whatever name the candidate carries is the name TLS hostname
+ * verification checks the server certificate against, and the only name that may be is the one
+ * the user configured: with a nameless address, {@code InetSocketAddress#getHostName()}
+ * additionally triggers a blocking reverse-DNS lookup on the event loop and validation falls back
+ * to the IP or the PTR record, and with a resolver-supplied label it validates a name the
+ * operator never chose. Hence the queried name always wins here; before multi-address support the
+ * initializer kept the original endpoint and Netty resolved only the TCP destination, which had
+ * the same effect.
+ *
+ *
Re-attaching changes nothing else: {@link AddressUtils#withHostName} performs no lookup, the
+ * TCP connect target is the same IP, and a resolved {@link InetSocketAddress}'s equality ignores
+ * host names, so pinning and the pin-equality shortcuts are unaffected. A scoped IPv6 candidate
+ * keeps its zone.
+ *
+ *
An IP literal gets its own literal re-attached, and only when the resolver handed
+ * back that very address. Leaving the candidate nameless there would not be neutral: a nameless
+ * address is exactly what {@code InetSocketAddress#getHostName()} answers with a blocking reverse
+ * lookup, so {@code DefaultSslEngineFactory} would validate the certificate against a PTR record
+ * instead of the literal the operator configured, on a Netty I/O loop — where before, contact
+ * points were kept unresolved and the literal came back with no lookup at all. Labelling with the
+ * literal keeps {@code getHostName()} a field read that answers the literal, which is what it
+ * answered before. (A non-canonically written IPv6 literal then makes {@link
+ * AddressUtils#carriesName} report {@code true} for the labelled candidate: the same imprecision
+ * that method already documents, and nothing re-labels a candidate twice.)
+ *
+ *
A candidate the resolver redirected to a different IP is left alone: labelling it
+ * with the literal form of the one we asked for would invent a name that resolves to something
+ * else.
+ *
+ *
A resolved original is treated exactly like an unresolved one, and reaches this at
+ * all only because a resolver may report an already-resolved address as unresolved in order to
+ * redirect it (see {@link #resolveCandidates}). Its host string is not as trustworthy: it renders
+ * a mutable field on the shared {@link InetAddress} (see {@link AddressUtils#carriesName}), which
+ * holds the name the operator configured when the address was built from one -- {@code new
+ * InetSocketAddress("db.example.com", 9042)} resolves eagerly and keeps the name -- but holds
+ * whatever reverse-DNS name an earlier TLS handshake cached when it was not. The two are
+ * indistinguishable from the object.
+ *
+ *
Re-attaching regardless is still the better of the two, because of what the alternative
+ * costs. Leaving a redirected candidate unlabelled does not leave it neutral: {@code
+ * DefaultSslEngineFactory} derives the TLS peer host from {@code resolve()}, which for the pinned
+ * copy is this candidate, and for a nameless address that is a blocking reverse lookup on an
+ * event loop. So in the configured-name case the choice is between validating the certificate
+ * against the configured DNS SAN and validating it against a PTR record -- which is what the
+ * pre-multi-address path did, when {@code resolve()} still handed back the endpoint's own
+ * address. And in the cached-PTR case it is between one address's PTR name and another's, neither
+ * of which the operator ever wrote. One case is fixed and the other is a wash.
+ */
+ @VisibleForTesting
+ static SocketAddress reattachHostname(SocketAddress original, SocketAddress candidate) {
+ if (!(original instanceof InetSocketAddress) || !(candidate instanceof InetSocketAddress)) {
+ return candidate;
+ }
+ InetSocketAddress originalInet = (InetSocketAddress) original;
+ InetSocketAddress candidateInet = (InetSocketAddress) candidate;
+ InetAddress candidateIp = candidateInet.getAddress();
+ if (candidateIp == null) {
+ return candidate;
+ }
+ String hostString = originalInet.getHostString();
+ if (AddressUtils.carriesName(originalInet)) {
+ // The queried name always wins -- unless the candidate already carries it, which is the
+ // common case (the JDK and Netty-DNS resolvers attach it themselves). getHostString() never
+ // looks anything up, and for a nameless candidate it falls back to the IP literal, which
+ // cannot equal a name -- so this test does not mistake one for the other.
+ return hostString.equals(candidateInet.getHostString())
+ ? candidate
+ : relabel(candidateInet, candidateIp, hostString);
+ }
+ // An IP literal: re-attach it only to the address it denotes, so a redirect stays unlabelled.
+ // forString parses and never resolves a *name* -- but it rejects the bracketed URI form
+ // outright, and it does resolve an IPv6 zone against the local interfaces, throwing when the
+ // zone does not name one. carriesName() reports both spellings as literals, so both reach
+ // here, and handing either to forString is unreliable enough to be treated as a failure mode
+ // rather than an edge case: Guava rejects even "fe80::1%lo" on a host that has an lo interface.
+ // Failing here would return the candidate unlabelled, which is the one outcome this branch
+ // exists to prevent -- getHostName() would then answer with a reverse lookup and the SSL
+ // engine would validate against a PTR record. So strip the brackets and split the zone off,
+ // match on the address part alone, and keep the whole original string as the label.
+ //
+ // Brackets come off first: extract() splits a contact point on its last colon and keeps them,
+ // so "[fe80::1%eth0]:9042" arrives here as "[fe80::1%eth0]" -- splitting on '%' before
+ // unwrapping would leave the closing bracket inside the zone and the opening one inside the
+ // literal, and neither part would parse.
+ String bare = hostString;
+ if (bare.length() > 2 && bare.charAt(0) == '[' && bare.charAt(bare.length() - 1) == ']') {
+ bare = bare.substring(1, bare.length() - 1);
+ }
+ int zoneSeparator = bare.indexOf('%');
+ String literalPart = zoneSeparator < 0 ? bare : bare.substring(0, zoneSeparator);
+ InetAddress literal;
+ try {
+ literal = InetAddresses.forString(literalPart);
+ } catch (IllegalArgumentException notALiteral) {
+ return candidate;
+ }
+ // A byte-exact comparison, with IPv4 and IPv6 told apart by array length. This is equivalent
+ // to InetAddress.equals(), which ignores the scope id as well -- verified on JDK 11.0.30, where
+ // two Inet6Addresses built from the same bytes with scope ids 3 and 5 compare equal -- and is
+ // written out so that the scope-blindness is visible rather than inherited: the zone was split
+ // off just above and goes into the label, never into this test.
+ //
+ // The consequence is accepted, not overlooked. A candidate carrying a different scope than the
+ // configured zone still matches here and is relabelled with that zone, so getHostString() names
+ // one interface while the connect goes out on the candidate's own. Reaching that needs a
+ // resolver that answers a zoned literal with a different scope than it was asked about; the
+ // alternative -- resolving the zone name through NetworkInterface to compare it -- buys a
+ // NetworkInterface lookup on the connect path for that one case, so it is deferred rather than
+ // taken here.
+ if (!Arrays.equals(literal.getAddress(), candidateIp.getAddress())) {
+ return candidate;
+ }
+ // Deliberately no getHostString() short-circuit on this branch: a *nameless* candidate's host
+ // string is its own IP literal, so it compares equal to the label about to be attached and the
+ // relabel would be skipped -- leaving getHostName() to answer with a reverse lookup, which is
+ // the one thing this branch exists to prevent. Relabelling is idempotent, so paying for it
+ // unconditionally is cheaper than telling the two apart.
+ return relabel(candidateInet, candidateIp, hostString);
+ }
+
+ /**
+ * {@code candidate} rebuilt with {@code hostName} as its label, or itself if that is not
+ * possible.
+ */
+ private static SocketAddress relabel(
+ InetSocketAddress candidate, InetAddress candidateIp, String hostName) {
+ try {
+ return new InetSocketAddress(
+ AddressUtils.withHostName(hostName, candidateIp), candidate.getPort());
+ } catch (UnknownHostException impossible) {
+ // getByAddress only rejects illegal byte lengths, and these bytes come from a real
+ // InetAddress; keep the raw candidate rather than failing the connect over a cosmetic step.
+ return candidate;
+ }
+ }
+
+ /**
+ * Whether every address this endpoint expands to is another way in to the same server, which only
+ * the endpoint knows — see {@link PinnableEndPoint#addressesAreInterchangeable(SocketAddress)}
+ * for the two cases and why they differ. An endpoint that does not implement {@link
+ * PinnableEndPoint} is treated as not interchangeable, which is also the conservative reading for
+ * a third-party implementation.
+ *
+ *
Asked once per connect, in {@link #connect}, with both of the booleans that depend on
+ * it derived from the one answer: an endpoint backed by mutable state asked twice could answer
+ * about a different address than the one being dialled, and the two would then disagree. {@code
+ * resolvedAddress} is passed in rather than re-derived for the same reason — it is what {@link
+ * EndPoint#resolve()} already returned for this connect.
+ */
+ @VisibleForTesting
+ static boolean addressesAreInterchangeable(EndPoint endPoint, SocketAddress resolvedAddress) {
+ return endPoint instanceof PinnableEndPoint
+ && ((PinnableEndPoint) endPoint).addressesAreInterchangeable(resolvedAddress);
+ }
+
+ /**
+ * Whether {@link #shuffleAndLimit} may spread this connect across the addresses the endpoint
+ * expands to.
+ *
+ *
A contact point always may: its addresses may well be different nodes, so there is no
+ * node identity to preserve, and spreading both balances load and varies which address an attempt
+ * starts from. An {@linkplain #isIdentified(Node) identified} node may only when its addresses
+ * are interchangeable -- which is what {@code SniEndPoint#resolve()} used to do for itself,
+ * rotating through the sorted records, before resolution moved to this layer.
+ */
+ @VisibleForTesting
+ static boolean spreadAcrossAddresses(boolean nodeIsIdentified, boolean interchangeable) {
+ return !nodeIsIdentified || interchangeable;
+ }
+
+ /**
+ * Whether a rejection observed at one address is a verdict on the server, and so on every
+ * remaining address, rather than on the record that reached it. See {@link #isNodeWideFailure},
+ * the only thing that asks.
+ *
+ *
An identified node always qualifies: every address of it is that same node. An unidentified
+ * contact point qualifies only when its addresses are interchangeable, i.e. when the endpoint
+ * says they all lead to one server. Otherwise they may be distinct servers running distinct
+ * software, which is not an edge case but what a rolling upgrade looks like from the client.
+ */
+ @VisibleForTesting
+ static boolean sameServerAtEveryAddress(boolean nodeIsIdentified, boolean interchangeable) {
+ return nodeIsIdentified || interchangeable;
+ }
+
+ /**
+ * Truncates the expanded address list to {@code advanced.connection.max-candidate-addresses},
+ * shuffling it first when the addresses may be spread across (see {@link
+ * #spreadAcrossAddresses}).
+ *
+ *
The shuffle spreads load: without it, every connection would try the resolver's first
+ * address first and healthy connections would pile onto one IP, while the whole point of a
+ * multi-record name is usually to spread them. A fresh random order per connect also means
+ * successive attempts start at different addresses, with no per-name counter state to maintain
+ * and nothing depending on the order the resolver, or a sort, happened to choose.
+ *
+ *
Where the order is kept instead, it is because the addresses are not known to be
+ * interchangeable: an identified node whose endpoint is an unresolved name that may map to
+ * several hosts, which is what a configured {@code AddressTranslator} returns by default ({@code
+ * SubnetAddressTranslator} under {@code resolve-addresses = false}). Each pool connection is its
+ * own {@code connect()}, so shuffling there would land one {@code Node}'s channels on different
+ * hosts while routing, shard awareness and per-node metrics attribute them all to that node.
+ * Keeping the resolver's order means such a pool converges on one address, as it did before
+ * multi-address support -- {@code Bootstrap.connect()} resolved through {@code resolve()},
+ * singular, i.e. the first record -- while the remaining addresses still serve as fallback.
+ *
+ *
The cap bounds what a single connect attempt can cost: every address tried is a full TCP
+ * connect plus init handshake -- and, with wrong credentials, a rejected login (see {@link
+ * #tryNextCandidate} on why an authentication failure does not stop the loop). For a shuffled
+ * list, a capped attempt tries a different sample of the addresses each time, so a name with more
+ * records than the cap still reaches all of them across successive attempts; one attempt just no
+ * longer walks them all. Where the order is kept, the cap is a hard limit -- a capped attempt
+ * keeps dialing the same prefix of the list, so records beyond it are never reached. That is
+ * accepted rather than worked around: it is still strictly more than the single address such an
+ * endpoint got before multi-address support, and rotating the window instead would give up the
+ * convergence the stable order exists for.
+ */
+ @VisibleForTesting
+ List shuffleAndLimit(
+ List extends SocketAddress> addresses, boolean spreadAcrossAddresses) {
+ List shuffled = new ArrayList<>(addresses);
+ if (shuffled.size() > 1 && spreadAcrossAddresses) {
+ Collections.shuffle(shuffled, random);
+ }
+ int cap =
+ Math.max(
+ 1,
+ context
+ .getConfig()
+ .getDefaultProfile()
+ .getInt(DefaultDriverOption.CONNECTION_MAX_CANDIDATE_ADDRESSES));
+ if (shuffled.size() > cap) {
+ LOG.debug(
+ "[{}] Resolved {} addresses, will try at most {}"
+ + " (advanced.connection.max-candidate-addresses)",
+ logPrefix,
+ shuffled.size(),
+ cap);
+ return shuffled.subList(0, cap);
+ }
+ return shuffled;
+ }
+
+ /**
+ * Iterates through the candidate addresses produced by {@link #resolveCandidates}. Tries each one
+ * in sequence; when an address fails, the next candidate is tried, and only when all candidates
+ * are exhausted is the overall {@code resultFuture} failed.
+ *
+ * Two failures are node-wide -- they doom every remaining address rather than only the
+ * one that was tried: an {@link UnsupportedProtocolVersionException} and an {@link
+ * UnsupportedEventTypeException}. Both are properties of the server rather than of the record
+ * that reached it, so both are gated on the same thing, {@code sameServerAtEveryAddress} (see
+ * {@link #sameServerAtEveryAddress}, and {@link #isNodeWideFailure} for why that is the right
+ * question for each).
+ *
+ *
What that covers is narrower than it looks, because {@code isNegotiating} is true only while
+ * {@link #protocolVersion} is still unset -- i.e. on the session's first connection, which is
+ * always to a contact point, and a contact point is never {@linkplain #isIdentified(Node)
+ * identified}. So a version rejection reached by negotiation stops the loop only when the
+ * contact point's endpoint reports its addresses interchangeable, as an SNI or client-routes
+ * proxy does. A plain multi-record name still walks the downgrade ladder from the top on every
+ * candidate -- each gets a fresh {@code attemptedVersions} list, so N records cost N ladders,
+ * with N bounded by {@link #shuffleAndLimit} -- and that is the intent, because those records may
+ * be different servers.
+ *
+ *
Every other failure -- authentication included -- advances to the next candidate: the
+ * addresses a name expands to may well belong to different nodes, so a rejection by the first of
+ * them says nothing about the rest. That also preserves the behaviour this PR would otherwise
+ * have removed: with {@code advanced.resolve-contact-points = true} each resolved address used to
+ * be a separate {@code Node}, and {@code ControlConnection} advances to the next node in its
+ * query plan on any error, including these.
+ *
+ *
Authentication in particular has to advance, for a reason only visible in the order of the
+ * handshake: {@link ProtocolInitHandler} runs {@code STARTUP -> AUTH_RESPONSE ->
+ * GET_CLUSTER_NAME}, so authentication completes before the cluster-name check. A stale
+ * DNS record pointing at a foreign cluster that wants different credentials therefore fails at
+ * AUTH, and treating that as terminal would write off the whole hostname -- making the
+ * cluster-name mismatch that would have advanced to the next address unreachable, in exactly the
+ * multi-record case this loop exists for. What bounds the cost of genuinely wrong credentials is
+ * the candidate cap ({@link #shuffleAndLimit}): one attempt pays at most {@code
+ * advanced.connection.max-candidate-addresses} rejected logins, with the earlier failures
+ * attached as suppressed exceptions.
+ *
+ *
Timeout note: addresses are tried serially, so the worst-case time before failure is
+ * N times a full attempt, and an attempt is a connect plus the init handshake. Each of the
+ * handshake's steps arms its own {@code advanced.connection.init-query-timeout} when it is sent,
+ * so they accumulate instead of sharing one deadline: a single address that accepts the
+ * connection and then stalls costs {@code connect-timeout} plus several times {@code
+ * init-query-timeout} before the loop moves on. This is an intentional tradeoff: failing
+ * immediately on the first unreachable IP would prevent fallback to healthy ones. The candidate
+ * cap ({@link #shuffleAndLimit}) is what bounds N.
+ *
+ *
When every candidate fails, one of their errors is propagated -- see {@link
+ * #surfacedFailure} for which, and why it is not simply the last -- with every other candidate's
+ * failure attached to it as a {@linkplain Throwable#addSuppressed(Throwable) suppressed}
+ * exception, so no cause is lost.
+ *
+ *
What that does not give is which address produced which failure. Every one of these
+ * exceptions is built from the endpoint, and a pinned copy is required to render identically to
+ * the unpinned original ({@link PinnableEndPoint}), so a three-record name yields three messages
+ * that all name the same hostname. The DEBUG line above is where the pairing lives; making the
+ * exceptions themselves carry it would mean either relaxing that contract or wrapping causes in a
+ * driver-owned type, which would in turn break the {@code instanceof} tests the callers do on
+ * them (see {@link #surfacedFailure}).
+ */
+ private void tryNextCandidate(
+ Bootstrap baseBootstrap,
+ EventLoop eventLoop,
+ EndPoint endPoint,
+ NodeShardingInfo shardingInfo,
+ Integer shardId,
+ DriverChannelOptions options,
+ NodeMetricUpdater nodeMetricUpdater,
+ ProtocolVersion currentVersion,
+ boolean isNegotiating,
+ boolean sameServerAtEveryAddress,
+ CompletableFuture resultFuture,
+ List candidates,
+ int index,
+ List priorErrors) {
+
+ // Invariant: this method always (eventually) completes resultFuture. It is invoked from
+ // CompletionStage and Netty callbacks that swallow throwables, so a synchronous throw -- a
+ // custom PinnableEndPoint.pinTo() for instance -- would otherwise leave the connect attempt
+ // hanging forever. Double completion is harmless: completeExceptionally() on an already
+ // completed future is a no-op.
+ try {
+ SocketAddress candidate = candidates.get(index);
+ // Everything downstream of here -- the channel, its pipeline (SSL engine, authenticator) and
+ // the DriverChannel handed to the caller -- sees an endpoint bound to this one address
+ // instead of the multi-address original. See PinnableEndPoint for why that matters.
+ EndPoint pinnedEndPoint = pin(endPoint, candidate);
+ CandidateFuture perAddressFuture = new CandidateFuture();
+ // Fresh per candidate address: connectToAddress()'s downgrade retries stay on this one
+ // address, so the final UnsupportedProtocolVersionException (if negotiation is what dooms
+ // this candidate) only reports versions actually tried against it, not earlier candidates'.
+ List attemptedVersions = new CopyOnWriteArrayList<>();
+ connectToAddress(
+ baseBootstrap,
+ eventLoop,
+ pinnedEndPoint,
+ shardingInfo,
+ shardId,
+ options,
+ nodeMetricUpdater,
+ currentVersion,
+ isNegotiating,
+ attemptedVersions,
+ perAddressFuture,
+ candidate);
+
+ perAddressFuture.whenComplete(
+ (channel, error) -> {
+ try {
+ boolean nodeWide =
+ error != null && isNodeWideFailure(error, sameServerAtEveryAddress);
+ if (error == null) {
+ if (!resultFuture.complete(channel)) {
+ // Same guard as completeCandidate and abandonCandidate: resultFuture is handed to
+ // callers as a CompletionStage and every path that can complete it early does so
+ // exceptionally (the blanket catches in resolveCandidates and below), so losing
+ // this race is possible -- and would otherwise leak a live socket and its
+ // pipeline for the life of the JVM, since nobody else holds this channel.
+ channel.forceClose();
+ }
+ } else if (!nodeWide && index + 1 < candidates.size()) {
LOG.debug(
- "[{}] Failed to connect with protocol {}, retrying with {}",
+ "[{}] Failed to connect to {} ({}), trying next address",
logPrefix,
- currentVersion,
- downgraded.get());
- connect(
+ candidate,
+ error.getMessage());
+ priorErrors.add(error);
+ tryNextCandidate(
+ baseBootstrap,
+ eventLoop,
+ // Deliberately the original, not the pinned copy: the next candidate must be
+ // pinned from the unpinned endpoint.
endPoint,
shardingInfo,
shardId,
options,
nodeMetricUpdater,
- downgraded.get(),
- true,
- attemptedVersions,
- resultFuture);
+ currentVersion,
+ isNegotiating,
+ sameServerAtEveryAddress,
+ resultFuture,
+ candidates,
+ index + 1,
+ priorErrors);
+ } else {
+ if (index + 1 < candidates.size()) {
+ // Only reachable for a node-wide failure (see the javadoc).
+ LOG.debug(
+ "[{}] Not trying the remaining addresses of {}: this failure is a property of"
+ + " the node, not of the address ({})",
+ logPrefix,
+ endPoint,
+ error.getMessage());
+ }
+ // Surface one failure, carrying the others as suppressed exceptions so they are
+ // not lost (they were only logged at DEBUG above). Deduplicated by identity:
+ // nothing stops two candidates from failing with the same Throwable instance, and
+ // this mutates an object we do not own -- attaching it twice would show the same
+ // cause twice, and would keep growing a shared instance's suppressed list on every
+ // connect.
+ List allErrors = new ArrayList<>(priorErrors);
+ allErrors.add(error);
+ Throwable surfaced = surfacedFailure(allErrors, nodeWide);
+ Set attached = Collections.newSetFromMap(new IdentityHashMap<>());
+ attached.add(surfaced);
+ for (Throwable candidateError : allErrors) {
+ if (attached.add(candidateError)) {
+ surfaced.addSuppressed(candidateError);
+ }
+ }
+ // Note: might be completed already if the failure happened in initializer()
+ resultFuture.completeExceptionally(surfaced);
+ }
+ } catch (Throwable t) {
+ resultFuture.completeExceptionally(t);
+ }
+ });
+ } catch (Throwable t) {
+ resultFuture.completeExceptionally(t);
+ }
+ }
+
+ /**
+ * Whether {@code error} dooms every remaining address of the endpoint, making it pointless for
+ * {@link #tryNextCandidate} to try them. See its javadoc for the reasoning behind each case.
+ *
+ * A {@link ClusterNameMismatchException} is deliberately absent, for an identified node as
+ * much as for a contact point. It says that the address just tried fronts a different cluster,
+ * which is a property of that record rather than of the node -- a stale DNS entry is exactly what
+ * it looks like -- so advancing to the next address is the whole point. {@link #surfacedFailure}
+ * treats it with the same caution on the way out.
+ *
+ *
An {@link AuthenticationException} is deliberately absent too, even for an identified node:
+ * see {@link #tryNextCandidate} on why authentication must advance, and {@link #shuffleAndLimit}
+ * for the cap that bounds what wrong credentials can cost.
+ *
+ *
Both cases here are properties of the server rather than of the record that reached
+ * it: which protocol versions it speaks, and which event types it knows. That is why they are
+ * node-wide at all -- replaying either against the remaining addresses can only fail the same
+ * way, and each replay costs a full TCP connect plus the STARTUP/AUTH/cluster-name handshake and
+ * the connect hook's round trip. Stopping at the first restores what a rejection cost before an
+ * endpoint expanded to several addresses, which was one failed connect per contact point.
+ *
+ *
And it is why both are gated on the same question -- whether the same server really does
+ * answer at every address (see {@link #connect}, which derives it). Asking only whether the
+ * node is identified would be wrong in both directions. Too narrow: an unidentified
+ * contact point behind an SNI or client-routes proxy has every address routed to one node, so a
+ * rejection settles all of them and replaying the downgrade ladder against each proxy IP is
+ * waste. Too wide: an unidentified contact point that is a plain multi-record name may front
+ * distinct servers, and during a rolling upgrade they genuinely differ -- writing the name off
+ * because the first address answered was the one not yet upgraded would skip the addresses that
+ * would have worked.
+ *
+ *
What that leaves unrescued is the mirror of the second case, and is accepted: a
+ * heterogeneous identified node, whose own addresses disagree about protocol versions or
+ * event types. Every address of an identified node is that node, so the driver has nowhere better
+ * to look.
+ */
+ private static boolean isNodeWideFailure(Throwable error, boolean sameServerAtEveryAddress) {
+ return sameServerAtEveryAddress
+ && (error instanceof UnsupportedProtocolVersionException
+ || error instanceof UnsupportedEventTypeException);
+ }
+
+ /**
+ * Which of the candidates' failures to propagate once they are all exhausted, the rest being
+ * attached to it as suppressed exceptions.
+ *
+ *
Not simply the last one. Callers branch on the type of what they receive -- {@link
+ * com.datastax.oss.driver.internal.core.pool.ChannelPool#handleError} treats a cluster-name
+ * mismatch and a protocol-version rejection as fatal, an invalid keyspace as a keyspace error and
+ * an authentication failure as warn-and-retry; {@code ControlConnection} logs authentication
+ * failures differently from transport ones -- and with a multi-record name the address that
+ * happens to be tried last is arbitrary. Letting it win would report a firewalled IP's connect
+ * timeout for what is really a rejected password, and take the reconnect path where the caller
+ * asked for the fatal one.
+ *
+ *
So a failure the callers classify is preferred over one they do not, in the order they test
+ * for it, and the last non-fatal failure is only used when no candidate produced a
+ * classified one. Every failure is still attached, so nothing is lost either way.
+ *
+ *
The two fatal types are the exception to that: they are only preferred when every
+ * candidate failed that way, or when the last one is the node-wide failure that stopped the loop.
+ * See the comments in the body.
+ */
+ private static Throwable surfacedFailure(List errors, boolean lastIsNodeWide) {
+ Throwable lastError = errors.get(errors.size() - 1);
+ // A node-wide failure is what ended the loop, and it is a verdict about the node rather than
+ // about the one address it was observed on (see tryNextCandidate). It therefore outranks
+ // everything below, including the unanimity rule -- which would otherwise demote it to whatever
+ // transport failure an earlier address happened to produce, turning the forced-down node that a
+ // single-address connect has always produced into a reconnect.
+ if (lastIsNodeWide) {
+ return lastError;
+ }
+ // An irreversible verdict needs evidence from every address. handleError turns these two into
+ // TopologyEvent.forceDown, and nothing in the driver ever reverses one -- no component fires
+ // FORCE_UP, and a SUGGEST_UP is explicitly refused for a FORCED_DOWN node -- so the node is out
+ // for the rest of the session. Meanwhile tryNextCandidate() classifies a cluster-name mismatch
+ // as a property of the address and advances past it, which is the point: it means this record
+ // is stale, not that this node belongs to another cluster. Promoting one such record over the
+ // other candidates' transport failures would write a healthy node off on the strength of the
+ // one address that was never going to work. Requiring unanimity leaves a single-address
+ // endpoint exactly as it was before this loop existed -- one candidate is unanimous by
+ // definition -- and a mixed pass simply reconnects, forcing down on the first pass that is.
+ boolean everyCandidateFatal = true;
+ for (Throwable error : errors) {
+ if (!isFatalToCallers(error)) {
+ everyCandidateFatal = false;
+ break;
+ }
+ }
+ if (everyCandidateFatal) {
+ return errors.get(0);
+ }
+ // An invalid keyspace, unlike those two, is a property of the cluster's schema rather than of
+ // the address, so one address answering settles it and no unanimity is required. It outranks an
+ // authentication failure because it is the rung a caller acts on -- handleError routes it to
+ // onKeyspaceError, which is how PoolManager fails session init fast instead of reconnecting for
+ // a keyspace that will never appear -- and because reaching the keyspace step at all proves the
+ // credentials were accepted on that address.
+ for (Throwable error : errors) {
+ if (error instanceof InvalidKeyspaceException) {
+ return error;
+ }
+ }
+ for (Throwable error : errors) {
+ if (error instanceof AuthenticationException) {
+ return error;
+ }
+ }
+ // The last failure -- but not a fatal one. The address tried last is arbitrary, so promoting a
+ // fatal failure here would force the node down on the strength of the single address that
+ // produced it, which is exactly what the unanimity rule above refuses to do.
+ for (int i = errors.size() - 1; i >= 0; i--) {
+ Throwable error = errors.get(i);
+ if (!isFatalToCallers(error)) {
+ return error;
+ }
+ }
+ // Unreachable: a list of nothing but fatal failures returned at the unanimity check above.
+ return lastError;
+ }
+
+ /**
+ * Whether the callers treat {@code error} as fatal, i.e. as grounds to write the node off: {@code
+ * ChannelPool#handleError} turns these two, and only these two, into {@code
+ * TopologyEvent.forceDown}.
+ */
+ private static boolean isFatalToCallers(Throwable error) {
+ return error instanceof ClusterNameMismatchException
+ || error instanceof UnsupportedProtocolVersionException;
+ }
+
+ /**
+ * Whether {@code error} and every failure attached to it are authentication failures, i.e.
+ * whether "authentication" is the whole story for the endpoint that produced it.
+ *
+ * The test callers must use in place of a bare {@code instanceof AuthenticationException}, and
+ * it lives here because this class is what makes the bare test wrong: one failure no longer means
+ * one address. A connect expands an endpoint to every address it resolves to and reports a single
+ * failure for the endpoint, with the others attached as {@linkplain Throwable#getSuppressed()
+ * suppressed} exceptions -- and {@link #surfacedFailure} deliberately promotes an authentication
+ * failure over transport ones, so an endpoint whose records failed {@code [refused, refused,
+ * auth]} surfaces the auth error. Counting that as {@code errors.connection.auth} alone, and
+ * telling the operator their credentials are wrong, hides that two thirds of the deployment is
+ * unreachable.
+ *
+ * @see com.datastax.oss.driver.internal.core.pool.ChannelPool
+ * @see com.datastax.oss.driver.internal.core.control.ControlConnection
+ */
+ public static boolean isAuthOnly(Throwable error) {
+ if (!(error instanceof AuthenticationException)) {
+ return false;
+ }
+ for (Throwable suppressed : error.getSuppressed()) {
+ if (!(suppressed instanceof AuthenticationException)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * 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(
+ Bootstrap baseBootstrap,
+ EventLoop eventLoop,
+ EndPoint endPoint,
+ NodeShardingInfo shardingInfo,
+ Integer shardId,
+ DriverChannelOptions options,
+ NodeMetricUpdater nodeMetricUpdater,
+ ProtocolVersion currentVersion,
+ boolean isNegotiating,
+ List attemptedVersions,
+ CandidateFuture perAddressFuture,
+ SocketAddress resolvedAddress) {
+
+ if (shardId == null || shardingInfo == null) {
+ if (shardId != null) {
+ LOG.debug(
+ "Requested connection to shard {} but shardingInfo is currently missing for Node at endpoint {}. Falling back to arbitrary local port.",
+ shardId,
+ endPoint);
+ }
+ bootstrapAndConnect(
+ baseBootstrap,
+ eventLoop,
+ endPoint,
+ shardingInfo,
+ shardId,
+ options,
+ nodeMetricUpdater,
+ currentVersion,
+ isNegotiating,
+ attemptedVersions,
+ perAddressFuture,
+ resolvedAddress,
+ null);
+ return;
+ }
+
+ // Picking a shard-aware local port means probing ports with ServerSocket.bind(): a blocking
+ // syscall per probe, and when the range is contended a scan across the whole of
+ // [port-low, port-high] -- twice, if the first pass wraps (see PortAllocator).
+ //
+ // That must not run on `eventLoop`. It is one of the I/O loops, shared with every established
+ // channel registered on it, and advanced shard awareness is enabled by default, so this is the
+ // ordinary path against Scylla rather than an edge case. The loop is reached by two separate
+ // routes -- resolveCandidates() completes its future there, and the downgrade retry below
+ // re-enters this method from inside a Netty listener -- so the guard belongs here, at the
+ // blocking call, rather than at either caller.
+ //
+ // The admin group, because that is where this scan already ran: before resolution moved into
+ // this class, connect() did it inline on its calling thread, which is the adminExecutor of the
+ // ChannelPool or ControlConnection driving the connect. It carries no request traffic.
+ //
+ // Two things do differ from that, both accepted rather than unnoticed. next() takes a thread
+ // from the group (advanced.netty.admin-group.size, 2 by default) instead of the caller's own,
+ // so a pool's scan can now land on the thread the control connection runs on, where before it
+ // could only stall the caller's own queue. And the candidate loop reaches this once per address
+ // tried rather than once per connect(), so an endpoint whose earlier addresses fail pays it
+ // more than once. Both stay bounded -- the loop is sequential within a connect, and
+ // max-candidate-addresses caps the addresses at 5 -- and neither puts blocking work on this
+ // group that was not already there. Moving the scan off the control plane altogether is the
+ // real fix and is tracked in the deferred ledger.
+ try {
+ context
+ .getNettyOptions()
+ .adminEventExecutorGroup()
+ .next()
+ .execute(
+ () -> {
+ // The same invariant as below, restated because it is a fresh entry point: this
+ // body runs as an executor task, and Netty only logs a task's throwables.
+ try {
+ int localPort =
+ PortAllocator.getNextAvailablePort(
+ shardingInfo.getShardsCount(), shardId, context);
+ if (localPort == -1) {
+ LOG.warn(
+ "Could not find free port for shard {} at {}. Falling back to arbitrary local port.",
+ shardId,
+ endPoint);
+ }
+ bootstrapAndConnect(
+ baseBootstrap,
+ eventLoop,
+ endPoint,
+ shardingInfo,
+ shardId,
+ options,
+ nodeMetricUpdater,
+ currentVersion,
+ isNegotiating,
+ attemptedVersions,
+ perAddressFuture,
+ resolvedAddress,
+ localPort == -1 ? null : localPort);
+ } catch (Throwable t) {
+ perAddressFuture.completeExceptionally(t);
+ }
+ });
+ } catch (Throwable t) {
+ // RejectedExecutionException, if the group is shutting down. Completing the future here is
+ // what keeps a connect from hanging on it.
+ perAddressFuture.completeExceptionally(t);
+ }
+ }
+
+ /**
+ * The rest of {@link #connectToAddress}, once the local port (if any) has been settled.
+ *
+ * @param localPort the local port to bind to, or {@code null} to let the OS pick one.
+ */
+ private void bootstrapAndConnect(
+ Bootstrap baseBootstrap,
+ EventLoop eventLoop,
+ EndPoint endPoint,
+ NodeShardingInfo shardingInfo,
+ Integer shardId,
+ DriverChannelOptions options,
+ NodeMetricUpdater nodeMetricUpdater,
+ ProtocolVersion currentVersion,
+ boolean isNegotiating,
+ List attemptedVersions,
+ CandidateFuture perAddressFuture,
+ SocketAddress resolvedAddress,
+ Integer localPort) {
+
+ // Invariant, as in tryNextCandidate(): every path completes perAddressFuture. The synchronous
+ // section can throw from Bootstrap validation; the connect listener runs inside a Netty
+ // callback that swallows throwables and contains the downgrade recursion, the version-registry
+ // lookup and the config overrides, any of which throwing would otherwise hang the attempt.
+ try {
+ // clone(eventLoop) so each attempt gets its own handler while sharing the options (including
+ // anything afterBootstrapInitialized() set), and is registered on the event loop the
+ // connect() picked -- the same one resolution ran on, so the group's chooser advances exactly
+ // once per logical connect (see connect()).
+ //
+ // disableResolver() because resolveCandidates() has already done the one resolution pass this
+ // connect gets, and `resolvedAddress` is one of its results. Bootstrap.clone() otherwise
+ // carries the resolver over and Netty resolves again -- through resolve(), *singular*. That
+ // is inert for the default resolver, which short-circuits on isResolved(), but a resolver
+ // that reports resolved addresses as unresolved in order to redirect them -- which
+ // resolveCandidates() deliberately supports -- would remap every candidate onto its first
+ // answer: the remaining candidates would never actually be tried, and the endpoint pinned
+ // onto the channel would name an address the channel is not connected to (which is what the
+ // SSL engine's peer host and DefaultTopologyMonitor#savePort are derived from). Every other
+ // exit from resolveCandidates() yields an address Netty would itself have passed through
+ // untouched -- no group, !isSupported, or isResolved -- so nothing else changes.
+ Bootstrap bootstrap =
+ baseBootstrap
+ .clone(eventLoop)
+ .disableResolver()
+ .handler(
+ initializer(
+ endPoint, currentVersion, options, nodeMetricUpdater, perAddressFuture));
+
+ ChannelFuture connectFuture =
+ (localPort == null)
+ ? bootstrap.connect(resolvedAddress)
+ : bootstrap.connect(resolvedAddress, new InetSocketAddress(localPort));
+
+ connectFuture.addListener(
+ cf -> {
+ try {
+ if (connectFuture.isSuccess()) {
+ Channel channel = connectFuture.channel();
+ DriverChannel driverChannel =
+ new DriverChannel(
+ endPoint, channel, context.getWriteCoalescer(), currentVersion);
+ finishCandidate(
+ driverChannel,
+ options,
+ perAddressFuture,
+ () -> latchNegotiatedState(driverChannel, currentVersion, isNegotiating));
} else {
- resultFuture.completeExceptionally(
- UnsupportedProtocolVersionException.forNegotiation(
- endPoint, attemptedVersions));
+ Throwable error = connectFuture.cause();
+ if (error instanceof UnsupportedProtocolVersionException && isNegotiating) {
+ attemptedVersions.add(currentVersion);
+ Optional downgraded =
+ context.getProtocolVersionRegistry().downgrade(currentVersion);
+ if (downgraded.isPresent()) {
+ LOG.debug(
+ "[{}] Failed to connect with protocol {}, retrying with {}",
+ logPrefix,
+ currentVersion,
+ downgraded.get());
+ // Stay on the same address for protocol-version downgrade retries.
+ connectToAddress(
+ baseBootstrap,
+ eventLoop,
+ endPoint,
+ shardingInfo,
+ shardId,
+ options,
+ nodeMetricUpdater,
+ downgraded.get(),
+ true,
+ attemptedVersions,
+ perAddressFuture,
+ resolvedAddress);
+ } else {
+ perAddressFuture.completeExceptionally(
+ UnsupportedProtocolVersionException.forNegotiation(
+ endPoint, attemptedVersions));
+ }
+ } else {
+ // Note: might be completed already if the failure happened in initializer(), this
+ // is fine
+ perAddressFuture.completeExceptionally(error);
+ }
}
+ } catch (Throwable t) {
+ // Close the channel we opened before giving up on it. Nothing else holds it once this
+ // listener returns -- the DriverChannel wrapper is out of scope, and no candidate was
+ // completed with it -- so the socket and its pipeline would stay open for the life of
+ // the JVM. Only when the future is ours to fail, though: a candidate that completed
+ // successfully is the caller's channel, not ours to close.
+ if ((perAddressFuture.completeExceptionally(t)
+ || perAddressFuture.isCompletedExceptionally())
+ && connectFuture.isSuccess()) {
+ connectFuture.channel().close();
+ }
+ }
+ });
+ } catch (Throwable t) {
+ perAddressFuture.completeExceptionally(t);
+ }
+ }
+
+ /**
+ * Remembers what the first accepted connection negotiated, so later connections skip the
+ * negotiation: the protocol version, the cluster name to check others against, and the server's
+ * product type (which for Cloud also lowers the default consistency level).
+ *
+ * Run only once a candidate has been accepted, not as soon as its transport connect and
+ * init handshake succeed. Init is no longer the last word on a candidate: the connect hook can
+ * reject it (the control connection's identity read does, for a node with no {@code host_id}),
+ * and REGISTER, which used to be the final init step, now runs after that hook. A candidate the
+ * driver is about to throw away must not leave its cluster name latched here -- a stale DNS
+ * record pointing at a foreign cluster would otherwise make every subsequent connection fail its
+ * cluster-name check, which {@code ChannelPool} turns into an irreversible forced-down node.
+ *
+ *
What enforces that is {@link CandidateFuture#settle()}: only the candidate that wins it
+ * reaches {@link #completeCandidate}, and only {@link #completeCandidate} runs this. Note that
+ * "accepted" is per connect attempt, not per factory -- two concurrent negotiating connects each
+ * accept a candidate and each latch, so {@code protocolVersion} can legitimately be written more
+ * than once (it has no first-write-wins guard, unlike the two below). Both writers negotiated
+ * against the same cluster, so they agree except while it is being upgraded.
+ */
+ private void latchNegotiatedState(
+ DriverChannel driverChannel, ProtocolVersion currentVersion, boolean isNegotiating) {
+ if (isNegotiating) {
+ this.protocolVersion = currentVersion;
+ }
+ if (this.clusterName == null) {
+ this.clusterName = driverChannel.getClusterName();
+ }
+ Map> supportedOptions = driverChannel.getOptions();
+ if (this.productType == null && supportedOptions != null) {
+ List productTypes = supportedOptions.get("PRODUCT_TYPE");
+ String productType =
+ productTypes != null && !productTypes.isEmpty()
+ ? productTypes.get(0)
+ : UNKNOWN_PRODUCT_TYPE;
+ this.productType = productType;
+ DriverConfig driverConfig = context.getConfig();
+ if (driverConfig instanceof TypesafeDriverConfig
+ && productType.equals(DATASTAX_CLOUD_PRODUCT_TYPE)) {
+ ((TypesafeDriverConfig) driverConfig)
+ .overrideDefaults(
+ ImmutableMap.of(
+ DefaultDriverOption.REQUEST_CONSISTENCY, ConsistencyLevel.LOCAL_QUORUM.name()));
+ }
+ }
+ }
+
+ /**
+ * The tail of a candidate attempt, once transport connect and protocol initialization have both
+ * succeeded: runs the caller's {@link ConnectHook} (if any), then registers for protocol events
+ * (if requested), and only then completes the candidate's future.
+ *
+ * Both steps happen while this attempt still holds the endpoint's remaining addresses, so a
+ * failure in either is a per-candidate failure: the channel is force-closed on the spot and
+ * {@link #tryNextCandidate} advances to the next address.
+ *
+ *
REGISTER used to be the last protocol-init step; it moved behind the hook so that a channel
+ * the hook is about to reject never registers for events. The window in which a live channel is
+ * not yet registered grows by the hook's round trip -- the same order of cost as the init step it
+ * follows.
+ */
+ private void finishCandidate(
+ DriverChannel driverChannel,
+ DriverChannelOptions options,
+ CandidateFuture perAddressFuture,
+ Runnable onAccepted) {
+ if (options.connectHook == null) {
+ registerForEvents(driverChannel, options, perAddressFuture, onAccepted);
+ return;
+ }
+ CompletionStage vetted;
+ try {
+ vetted = options.connectHook.onConnect(driverChannel);
+ } catch (Throwable t) {
+ // A synchronous throw is a rejection, like an exceptional stage. Blanket-caught: this runs
+ // inside a Netty listener that swallows throwables, so a caller-supplied callback leaking
+ // one would otherwise leave the attempt hanging forever.
+ abandonCandidate(
+ driverChannel,
+ perAddressFuture,
+ new ConnectionInitException("Connect hook rejected the channel", t));
+ return;
+ }
+ if (vetted == null) {
+ abandonCandidate(
+ driverChannel,
+ perAddressFuture,
+ new ConnectionInitException("Connect hook returned a null stage", null));
+ return;
+ }
+ // The hook's contract says its stage eventually completes, but only the driver can make that
+ // true: a wedged hook (a topology monitor whose own timeout is broken, say) would otherwise
+ // hang the whole connect attempt, and with it control-connection init or a reconnect.
+ //
+ // Unless the timeout is zero or negative, which every other consumer of a driver timeout option
+ // reads as "no timeout" (see AdminRequestHandler#onWriteComplete). Scheduling it anyway would
+ // fire on the next event-loop turn, before any round trip can complete, and abandon every
+ // candidate of every contact point -- so an operator who disabled the control-connection
+ // timeout
+ // would find that the session cannot initialize at all.
+ ScheduledFuture> hookTimeout;
+ try {
+ hookTimeout =
+ (options.connectHookTimeout == null || options.connectHookTimeout.toNanos() <= 0)
+ ? null
+ : driverChannel
+ .eventLoop()
+ .schedule(
+ () ->
+ abandonCandidate(
+ driverChannel,
+ perAddressFuture,
+ new ConnectionInitException(
+ "Connect hook timed out after " + options.connectHookTimeout,
+ null)),
+ options.connectHookTimeout.toNanos(),
+ TimeUnit.NANOSECONDS);
+ } catch (Throwable t) {
+ // An event loop shutting down rejects the task. Fail the candidate rather than run the hook
+ // with nothing bounding it: this method is called from a Netty listener that swallows
+ // throwables, so the attempt would otherwise hang (see connectToAddress's invariant).
+ abandonCandidate(
+ driverChannel,
+ perAddressFuture,
+ new ConnectionInitException("Could not schedule the connect hook timeout", t));
+ return;
+ }
+ vetted.whenComplete(
+ (aVoid, error) -> {
+ try {
+ if (hookTimeout != null) {
+ hookTimeout.cancel(false);
+ }
+ if (error != null) {
+ abandonCandidate(
+ driverChannel,
+ perAddressFuture,
+ new ConnectionInitException("Connect hook rejected the channel", error));
} else {
- // Note: might be completed already if the failure happened in initializer(), this is
- // fine
- resultFuture.completeExceptionally(error);
+ registerForEvents(driverChannel, options, perAddressFuture, onAccepted);
}
+ } catch (Throwable t) {
+ // Blanket-caught, as everywhere else in this class: nobody consumes the stage this
+ // callback returns, and the timeout that would have failed the candidate has just been
+ // cancelled, so anything escaping here -- registerForEvents' config read, for instance
+ // -- would leave perAddressFuture uncompleted forever.
+ abandonCandidate(
+ driverChannel,
+ perAddressFuture,
+ new ConnectionInitException(
+ "Unexpected error after the connect hook accepted the channel", t));
}
});
}
+ /**
+ * Sends the REGISTER request when the options ask for protocol events, then completes the
+ * candidate. A registration failure is a per-candidate failure, exactly as it was when REGISTER
+ * was a protocol-init step.
+ */
+ private void registerForEvents(
+ DriverChannel driverChannel,
+ DriverChannelOptions options,
+ CandidateFuture perAddressFuture,
+ Runnable onAccepted) {
+ if (options.eventTypes.isEmpty()) {
+ completeCandidate(driverChannel, perAddressFuture, onAccepted);
+ return;
+ }
+ Duration timeout =
+ context
+ .getConfig()
+ .getDefaultProfile()
+ .getDuration(DefaultDriverOption.CONNECTION_INIT_QUERY_TIMEOUT);
+ AdminRequestHandler.register(driverChannel, options.eventTypes, timeout, logPrefix)
+ .start()
+ .whenComplete(
+ (aVoid, error) -> {
+ try {
+ if (error != null) {
+ abandonCandidate(
+ driverChannel, perAddressFuture, translateRegisterFailure(error));
+ } else {
+ completeCandidate(driverChannel, perAddressFuture, onAccepted);
+ }
+ } catch (Throwable t) {
+ // Blanket-caught, as everywhere else in this class: nobody consumes the stage this
+ // callback returns, and by this point no timeout is left to fail the candidate, so
+ // anything escaping -- translateRegisterFailure's casts, a forceClose() on an event
+ // loop that is shutting down -- would leave perAddressFuture uncompleted forever
+ // and hang the connect attempt (see connectToAddress's invariant).
+ abandonCandidate(
+ driverChannel,
+ perAddressFuture,
+ new ConnectionInitException("Unexpected error after REGISTER", t));
+ }
+ });
+ }
+
+ /**
+ * Gives the one REGISTER rejection with a known cause a message that names it: the server not
+ * knowing the {@code CLIENT_ROUTES_CHANGE} event type. This translation lived in the init handler
+ * when REGISTER was an init step, and exists so that the caller
+ * (ClientRoutesTopologyMonitor.init()) reports a clear error instead of silently degrading.
+ */
+ private static Throwable translateRegisterFailure(Throwable error) {
+ if (error instanceof UnexpectedResponseException) {
+ Message response = ((UnexpectedResponseException) error).message;
+ if (response instanceof com.datastax.oss.protocol.internal.response.Error) {
+ com.datastax.oss.protocol.internal.response.Error protocolError =
+ (com.datastax.oss.protocol.internal.response.Error) response;
+ if (protocolError.code == ProtocolConstants.ErrorCode.PROTOCOL_ERROR
+ && protocolError.message.contains(ProtocolConstants.EventType.CLIENT_ROUTES_CHANGE)) {
+ return new UnsupportedEventTypeException(
+ "Server does not support CLIENT_ROUTES_CHANGE event "
+ + "(requires ScyllaDB Enterprise >= 2026.1). "
+ + "Either upgrade the server or remove the client routes configuration.",
+ error);
+ }
+ // Any other server error naming REGISTER. Reported the way ProtocolInitHandler reported it
+ // while REGISTER was an init step -- error code name included, which
+ // UnexpectedResponseException does not carry (its message renders the Error as
+ // "ERROR()", dropping the code). The type is deliberately not what the init handler
+ // produced: that path ended in failOnUnexpected(), whose IllegalArgumentException is
+ // neither a DriverException nor especially informative about what failed.
+ return new ConnectionInitException(
+ String.format(
+ "REGISTER: server replied with unexpected error code [%s]: %s",
+ ProtocolUtils.errorCodeString(protocolError.code), protocolError.message),
+ error);
+ }
+ }
+ return error;
+ }
+
+ /**
+ * A REGISTER rejection that is a property of the server -- it does not know an event type
+ * the driver asked for -- rather than of the address that was dialled.
+ *
+ * A {@link ConnectionInitException}, so that callers which branch on the type (see {@link
+ * #surfacedFailure}, and {@code ClientRoutesTopologyMonitor#init}, which reports the message)
+ * treat it exactly as they treated the same rejection when REGISTER was an init step. The subtype
+ * exists only so {@link #isNodeWideFailure} can recognise it.
+ */
+ @VisibleForTesting
+ static class UnsupportedEventTypeException extends ConnectionInitException {
+ UnsupportedEventTypeException(String message, Throwable cause) {
+ super(message, cause);
+ }
+ }
+
+ /**
+ * One candidate address's future, together with the one-shot latch that says which of {@link
+ * #completeCandidate} and {@link #abandonCandidate} owns the outcome.
+ *
+ *
A separate latch rather than the future's own completion state, because the two decisions
+ * have to be made in opposite orders. A candidate must be known accepted before it
+ * publishes, so that {@link #latchNegotiatedState} has already run by the time any caller can
+ * hold the channel -- while {@code complete()} only reports whether it won after
+ * publishing. Settling first separates the two: the winner latches and then publishes, the loser
+ * touches neither.
+ */
+ private static class CandidateFuture extends CompletableFuture {
+
+ // newIncompleteFuture() is deliberately not overridden: a derived stage carrying its own copy
+ // of the latch would imply a guarantee it does not have, and nothing needs one here -- the only
+ // stage derived from this future is the discarded return of tryNextCandidate's whenComplete.
+
+ private final AtomicBoolean settled = new AtomicBoolean();
+
+ /** Whether the caller is the one that gets to decide this candidate's outcome. */
+ boolean settle() {
+ return settled.compareAndSet(false, true);
+ }
+ }
+
+ /**
+ * Records what the candidate negotiated and publishes its channel, in that order.
+ *
+ * {@code onAccepted} -- see {@link #latchNegotiatedState} -- runs before the channel is
+ * published. {@code complete()} drives the downstream continuations synchronously, so the moment
+ * it returns a caller on another thread may already hold the channel; latching afterwards leaves
+ * a window in which it does while {@link #getProtocolVersion()} still sees {@code null} and
+ * throws its "not known yet" precondition, and in which a concurrently-built channel reads a null
+ * {@code clusterName} and skips the cluster-name check. The fields are {@code volatile}, so that
+ * is ordering rather than visibility -- but the window is real, and it is what made {@code
+ * ChannelFactoryProtocolNegotiationTest} await the value instead of reading it.
+ *
+ *
Latching first is only safe because {@link CandidateFuture#settle()} has already decided the
+ * outcome. Latching unconditionally would not be: a candidate the hook timeout has abandoned
+ * would still leave its cluster name behind, which is exactly what {@link #latchNegotiatedState}
+ * must not allow.
+ *
+ *
Losing the latch means the channel is nobody's -- the winner failed the future and will not
+ * be handed this channel -- so it is closed here rather than leaked.
+ *
+ *
Winning it carries the opposite duty: this call must then complete the future on every
+ * path, including a throwing {@code onAccepted}. Every blanket catch downstream of {@link
+ * #finishCandidate} discharges the "always completes {@code perAddressFuture}" invariant by
+ * calling {@link #abandonCandidate}, and that is a no-op once the candidate is settled -- so a
+ * throw escaping here would leave the future settled but never completed, hanging the connect
+ * with {@code Reconnection} stuck in ATTEMPT_IN_PROGRESS and leaking the channel, with no timeout
+ * left to rescue it (REGISTER has completed and the hook timeout is already cancelled). {@link
+ * #latchNegotiatedState} is not throw-free: on the Cloud path it reaches {@code
+ * TypesafeDriverConfig#overrideDefaults}, which re-parses the whole configuration.
+ */
+ private static void completeCandidate(
+ DriverChannel driverChannel, CandidateFuture perAddressFuture, Runnable onAccepted) {
+ if (!perAddressFuture.settle()) {
+ driverChannel.forceClose();
+ return;
+ }
+ try {
+ onAccepted.run();
+ } catch (Throwable t) {
+ // Settling made this the only call that can still complete the future -- see the javadoc.
+ perAddressFuture.completeExceptionally(t);
+ driverChannel.forceClose();
+ return;
+ }
+ if (!perAddressFuture.complete(driverChannel)) {
+ // Defensive. Several paths complete the future without settling it: the blanket catches in
+ // connectToAddress and bootstrapAndConnect, and ChannelFactoryInitializer#initChannel. One
+ // of them -- bootstrapAndConnect's connect-listener catch -- wraps the whole listener body
+ // and so can fire with this very channel already built. None can reach here today, all being
+ // upstream of the hook and REGISTER, but an unpublished channel nobody holds is a leak.
+ driverChannel.forceClose();
+ }
+ }
+
+ /**
+ * Closes a candidate channel that will not be used and fails its future -- unless that channel
+ * has meanwhile been handed to the caller, in which case it is theirs and must be left alone.
+ */
+ private static void abandonCandidate(
+ DriverChannel driverChannel, CandidateFuture perAddressFuture, Throwable error) {
+ // The hook timeout and the hook's own completion race, and the hook's stage may complete off
+ // the channel's event loop -- the contract allows it, and a custom TopologyMonitor behind the
+ // control connection's hook is free to -- so cancel(false) can lose to a timeout task that has
+ // already started running. Losing the settle means completeCandidate got there first, so the
+ // channel is the caller's: closing it would leave them owning a dead channel with no error to
+ // explain it, and this error is moot anyway.
+ //
+ // Winning it makes the channel ours even if the future was already failed elsewhere (a blanket
+ // catch in the connect listener), in which case completeExceptionally is a no-op and the close
+ // is the point. forceClose is idempotent.
+ if (!perAddressFuture.settle()) {
+ return;
+ }
+ perAddressFuture.completeExceptionally(error);
+ driverChannel.forceClose();
+ }
+
+ /**
+ * Binds {@code endPoint} to the address a connection is being opened to, when the implementation
+ * supports it.
+ *
+ *
Third-party {@link EndPoint}s that do not implement {@link PinnableEndPoint} are returned
+ * unchanged, so they keep behaving exactly as they did before multi-address support: the channel
+ * carries the endpoint it was given.
+ *
+ *
So is an endpoint whose candidate came back unresolved. {@link
+ * PinnableEndPoint#pinTo(SocketAddress)} is documented to take an address that is already
+ * resolved, and {@link #resolveCandidates} has three paths that hand the original address
+ * straight through -- the user disabled the resolver, the resolver does not support the address,
+ * or it reports it as already resolved. For an endpoint that hands out a hostname, pinning there
+ * would freeze it on a name that still re-expands on every connect: no address stability gained,
+ * and whatever the endpoint does instead of consulting its own source once pinned is lost.
+ */
+ private static EndPoint pin(EndPoint endPoint, SocketAddress resolvedAddress) {
+ if (resolvedAddress instanceof InetSocketAddress
+ && ((InetSocketAddress) resolvedAddress).isUnresolved()) {
+ return endPoint;
+ }
+ return endPoint instanceof PinnableEndPoint
+ ? ((PinnableEndPoint) endPoint).pinTo(resolvedAddress)
+ : endPoint;
+ }
+
@VisibleForTesting
ChannelInitializer initializer(
EndPoint endPoint,
@@ -463,7 +2075,11 @@ protected void initChannel(Channel channel) {
context.getNettyOptions().afterChannelInitialized(channel);
} catch (Throwable t) {
// If the init handler throws an exception, Netty swallows it and closes the channel. We
- // want to propagate it instead, so fail the outer future (the result of connect()).
+ // want to propagate it instead, so fail this candidate's future. Note that is the
+ // per-address one, not the result of connect(): a pipeline failure that is not specific to
+ // the address (a bad truststore, say) therefore advances to the next candidate and is
+ // retried against each of them, which tryNextCandidate() documents as the deliberate
+ // trade-off for not being able to tell the two apart.
resultFuture.completeExceptionally(t);
throw t;
}
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/channel/ConnectHook.java b/core/src/main/java/com/datastax/oss/driver/internal/core/channel/ConnectHook.java
new file mode 100644
index 00000000000..7a7a541909d
--- /dev/null
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/channel/ConnectHook.java
@@ -0,0 +1,69 @@
+/*
+ * 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 java.util.concurrent.CompletionStage;
+
+/**
+ * A caller-supplied step that runs against every candidate channel a {@link ChannelFactory#connect}
+ * attempt opens, after protocol initialization succeeds and before the attempt is considered
+ * successful.
+ *
+ * Its position is what makes it useful: a connect attempt may try several addresses when the
+ * endpoint's name resolves to more than one, and the hook runs while the factory still holds the
+ * remaining ones. Completing the returned stage exceptionally (or throwing synchronously) rejects
+ * the candidate -- the factory closes the channel and moves on to the endpoint's next address -- so
+ * a caller can impose its own acceptance criteria on a channel, per address, without losing the
+ * fallback. The control connection uses this to read {@code system.local} and refuse a channel
+ * whose node cannot identify itself, channeling what it read straight into its own state (see
+ * {@code ControlConnection}).
+ *
+ *
Contract:
+ *
+ *
+ * - invoked at most once per candidate channel, and candidates are tried serially -- but a hook
+ * that has not completed by {@link DriverChannelOptions#connectHookTimeout} is abandoned,
+ * not cancelled. The factory rejects that candidate and calls the hook for the next
+ * address while the stranded stage is still outstanding, so two invocations from one connect
+ * attempt can be live at once, and a late one can complete after its own candidate has been
+ * closed. An implementation that carries state between the hook and the rest of the attempt
+ * must therefore publish it per channel and atomically, rather than assume the previous
+ * invocation has finished -- which is what {@code ControlConnection.NodeInfoHolder} does, and
+ * why it can;
+ *
- invoked on the channel's event loop: implementations must not block, and anything heavier
+ * than an asynchronous request on the channel itself should hop to another thread;
+ *
- the returned stage must eventually complete; the factory bounds it with {@link
+ * DriverChannelOptions#connectHookTimeout} and rejects the candidate when it expires;
+ *
- only channel-scoped resources may be touched: the channel is not published to the caller
+ * yet, and a rejected or timed-out candidate is closed by the factory.
+ *
+ *
+ * When the options also request protocol events ({@link DriverChannelOptions#eventTypes}), the
+ * {@code REGISTER} request is sent after the hook completes successfully, so a channel that is
+ * about to be rejected never registers for events.
+ */
+public interface ConnectHook {
+
+ /**
+ * Vets a candidate channel that completed protocol initialization.
+ *
+ * @return a stage that completes normally to accept the channel, or exceptionally to reject it
+ * and make the connect attempt move on to the endpoint's next address.
+ */
+ CompletionStage onConnect(DriverChannel channel);
+}
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/channel/DriverChannelOptions.java b/core/src/main/java/com/datastax/oss/driver/internal/core/channel/DriverChannelOptions.java
index 378fd2dc0b8..7f8d768153d 100644
--- a/core/src/main/java/com/datastax/oss/driver/internal/core/channel/DriverChannelOptions.java
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/channel/DriverChannelOptions.java
@@ -19,6 +19,7 @@
import com.datastax.oss.driver.api.core.CqlIdentifier;
import com.datastax.oss.driver.shaded.guava.common.base.Preconditions;
+import java.time.Duration;
import java.util.Collections;
import java.util.List;
import net.jcip.annotations.Immutable;
@@ -54,17 +55,41 @@ public static Builder builder() {
*/
public final boolean reportConfig;
+ /**
+ * A step the caller runs against every candidate channel after protocol initialization, with the
+ * power to reject it while {@link ChannelFactory} still holds the endpoint's other addresses, or
+ * {@code null} if the caller has no vetting to do. Precedent for a behavioral member here: {@link
+ * #eventCallback}.
+ *
+ * The control connection supplies one when connecting to a node whose {@code host_id} is not
+ * yet known -- a contact point, the one case with something to learn -- to read {@code
+ * system.local} and refuse a channel whose node cannot identify itself.
+ *
+ * @see ConnectHook
+ */
+ public final ConnectHook connectHook;
+
+ /**
+ * How long the factory waits for {@link #connectHook}'s stage before treating the candidate as
+ * rejected. Never null when {@link #connectHook} is set.
+ */
+ public final Duration connectHookTimeout;
+
private DriverChannelOptions(
CqlIdentifier keyspace,
List eventTypes,
EventCallback eventCallback,
String ownerLogPrefix,
- boolean reportConfig) {
+ boolean reportConfig,
+ ConnectHook connectHook,
+ Duration connectHookTimeout) {
this.keyspace = keyspace;
this.eventTypes = eventTypes;
this.eventCallback = eventCallback;
this.ownerLogPrefix = ownerLogPrefix;
this.reportConfig = reportConfig;
+ this.connectHook = connectHook;
+ this.connectHookTimeout = connectHookTimeout;
}
public static class Builder {
@@ -73,6 +98,8 @@ public static class Builder {
private EventCallback eventCallback = null;
private String ownerLogPrefix = null;
private boolean reportConfig = false;
+ private ConnectHook connectHook = null;
+ private Duration connectHookTimeout = null;
public Builder withKeyspace(CqlIdentifier keyspace) {
this.keyspace = keyspace;
@@ -100,9 +127,27 @@ public Builder reportConfig(boolean reportConfig) {
return this;
}
+ /**
+ * Arms a step that vets every candidate channel after protocol initialization, bounded by the
+ * given timeout. See {@link ConnectHook} for the contract.
+ */
+ public Builder withConnectHook(ConnectHook connectHook, Duration connectHookTimeout) {
+ Preconditions.checkNotNull(connectHook);
+ Preconditions.checkNotNull(connectHookTimeout);
+ this.connectHook = connectHook;
+ this.connectHookTimeout = connectHookTimeout;
+ return this;
+ }
+
public DriverChannelOptions build() {
return new DriverChannelOptions(
- keyspace, eventTypes, eventCallback, ownerLogPrefix, reportConfig);
+ keyspace,
+ eventTypes,
+ eventCallback,
+ ownerLogPrefix,
+ reportConfig,
+ connectHook,
+ connectHookTimeout);
}
}
}
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/channel/ProtocolInitHandler.java b/core/src/main/java/com/datastax/oss/driver/internal/core/channel/ProtocolInitHandler.java
index dd7630a6530..042006ea9a6 100644
--- a/core/src/main/java/com/datastax/oss/driver/internal/core/channel/ProtocolInitHandler.java
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/channel/ProtocolInitHandler.java
@@ -52,7 +52,6 @@
import com.datastax.oss.protocol.internal.request.AuthResponse;
import com.datastax.oss.protocol.internal.request.Options;
import com.datastax.oss.protocol.internal.request.Query;
-import com.datastax.oss.protocol.internal.request.Register;
import com.datastax.oss.protocol.internal.request.Startup;
import com.datastax.oss.protocol.internal.response.AuthChallenge;
import com.datastax.oss.protocol.internal.response.AuthSuccess;
@@ -159,7 +158,6 @@ private enum Step {
GET_CLUSTER_NAME,
SET_KEYSPACE,
AUTH_RESPONSE,
- REGISTER,
}
private class InitRequest extends ChannelHandlerRequest {
@@ -170,12 +168,10 @@ private class InitRequest extends ChannelHandlerRequest {
private Message request;
private Authenticator authenticator;
private ByteBuffer authResponseToken;
- private final List registerEventTypes;
InitRequest(ChannelHandlerContext ctx) {
super(ctx, timeoutMillis);
this.step = querySupportedOptions ? Step.OPTIONS : Step.STARTUP;
- this.registerEventTypes = options.eventTypes;
}
@Override
@@ -206,8 +202,6 @@ Message getRequest() {
return request = new Query("USE " + options.keyspace.asCql(false));
case AUTH_RESPONSE:
return request = new AuthResponse(authResponseToken);
- case REGISTER:
- return request = new Register(registerEventTypes);
default:
throw new AssertionError("unhandled step: " + step);
}
@@ -330,21 +324,11 @@ void onResponse(Message response) {
if (options.keyspace != null) {
step = Step.SET_KEYSPACE;
send();
- } else if (!registerEventTypes.isEmpty()) {
- step = Step.REGISTER;
- send();
} else {
setConnectSuccess();
}
}
} else if (step == Step.SET_KEYSPACE && response instanceof SetKeyspace) {
- if (!registerEventTypes.isEmpty()) {
- step = Step.REGISTER;
- send();
- } else {
- setConnectSuccess();
- }
- } else if (step == Step.REGISTER && response instanceof Ready) {
setConnectSuccess();
} else if (response instanceof Error) {
Error error = (Error) response;
@@ -366,17 +350,6 @@ void onResponse(Message response) {
} else if (step == Step.SET_KEYSPACE
&& error.code == ProtocolConstants.ErrorCode.INVALID) {
fail(new InvalidKeyspaceException(error.message));
- } else if (step == Step.REGISTER
- && error.code == ErrorCode.PROTOCOL_ERROR
- && error.message.contains(ProtocolConstants.EventType.CLIENT_ROUTES_CHANGE)) {
- // The server rejected CLIENT_ROUTES_CHANGE as an unknown event type.
- // Fail the connection so that the caller (ClientRoutesTopologyMonitor.init())
- // gets a clear error instead of silently degrading.
- fail(
- "Server does not support CLIENT_ROUTES_CHANGE event "
- + "(requires ScyllaDB Enterprise >= 2026.1). "
- + "Either upgrade the server or remove the client routes configuration.",
- null);
} else {
failOnUnexpected(error);
}
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/context/NettyOptions.java b/core/src/main/java/com/datastax/oss/driver/internal/core/context/NettyOptions.java
index 5b4ff4dcec8..43a3ac412a4 100644
--- a/core/src/main/java/com/datastax/oss/driver/internal/core/context/NettyOptions.java
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/context/NettyOptions.java
@@ -66,7 +66,33 @@ public interface NettyOptions {
/**
* A hook invoked each time the driver creates a client bootstrap in order to open a channel. This
- * is a good place to configure any custom option on the bootstrap.
+ * is a good place to configure any custom option, attribute, or {@link
+ * Bootstrap#resolver(io.netty.resolver.AddressResolverGroup)} on the bootstrap.
+ *
+ * The hook runs once per logical connection to a node. When a hostname expands to several IP
+ * addresses, the same bootstrap is shared by every per-address attempt (each attempt uses a
+ * {@link Bootstrap#clone(io.netty.channel.EventLoopGroup)} of it); likewise, protocol-version
+ * downgrade retries reuse it. Before multi-address support the hook ran once per attempt,
+ * including once per downgrade retry.
+ *
+ *
Anything the hook allocates must outlive the call. Because it runs per connection, a
+ * resolver group constructed inside it — {@code bootstrap.resolver(new
+ * DnsAddressResolverGroup(...))} — is a fresh group every time: a new {@code DnsNameResolver}
+ * with its own datagram channel and its own cold cache, plus a listener registered on the I/O
+ * loop's termination future that only {@code AddressResolverGroup.close()} removes. Build the
+ * group once, hold it in a field, and hand the same instance to every bootstrap.
+ *
+ *
The bootstrap does not carry the driver's channel handler yet, and a handler
+ * installed by this hook is not honoured: the driver sets its own handler on each
+ * per-attempt copy afterwards (and logs a one-time warning if it overwrites one). To customize
+ * the pipeline, use {@link #afterChannelInitialized(Channel)} instead. (Before multi-address
+ * support the hook ran after the driver's handler was installed, so replacing it was technically
+ * possible; that was never a supported extension point.)
+ *
+ *
An {@link io.netty.channel.EventLoopGroup} set by this hook is likewise not honoured:
+ * {@code Bootstrap#clone(EventLoopGroup)} assigns the group unconditionally, so each per-attempt
+ * copy is bound to the loop the driver picked from {@link #ioEventLoopGroup()}. Configure the
+ * group there instead.
*/
void afterBootstrapInitialized(Bootstrap bootstrap);
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/control/ControlConnection.java b/core/src/main/java/com/datastax/oss/driver/internal/core/control/ControlConnection.java
index 9da3a2a8aa2..4637d96e8e1 100644
--- a/core/src/main/java/com/datastax/oss/driver/internal/core/control/ControlConnection.java
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/control/ControlConnection.java
@@ -28,23 +28,28 @@
import com.datastax.oss.driver.api.core.metadata.Node;
import com.datastax.oss.driver.api.core.metadata.NodeState;
import com.datastax.oss.driver.internal.core.channel.ChannelEvent;
+import com.datastax.oss.driver.internal.core.channel.ChannelFactory;
import com.datastax.oss.driver.internal.core.channel.DriverChannel;
import com.datastax.oss.driver.internal.core.channel.DriverChannelOptions;
import com.datastax.oss.driver.internal.core.channel.EventCallback;
import com.datastax.oss.driver.internal.core.context.InternalDriverContext;
import com.datastax.oss.driver.internal.core.metadata.ClientRoutesTopologyMonitor;
import com.datastax.oss.driver.internal.core.metadata.ClientRoutesUpdateEvent;
+import com.datastax.oss.driver.internal.core.metadata.DefaultEndPoint;
import com.datastax.oss.driver.internal.core.metadata.DefaultNode;
import com.datastax.oss.driver.internal.core.metadata.DefaultTopologyMonitor;
import com.datastax.oss.driver.internal.core.metadata.DistanceEvent;
import com.datastax.oss.driver.internal.core.metadata.MetadataManager;
+import com.datastax.oss.driver.internal.core.metadata.NodeInfo;
import com.datastax.oss.driver.internal.core.metadata.NodeStateEvent;
+import com.datastax.oss.driver.internal.core.metadata.PinnableEndPoint;
import com.datastax.oss.driver.internal.core.metadata.TopologyEvent;
import com.datastax.oss.driver.internal.core.util.Loggers;
import com.datastax.oss.driver.internal.core.util.concurrent.CompletableFutures;
import com.datastax.oss.driver.internal.core.util.concurrent.Reconnection;
import com.datastax.oss.driver.internal.core.util.concurrent.RunOrSchedule;
import com.datastax.oss.driver.internal.core.util.concurrent.UncaughtExceptions;
+import com.datastax.oss.driver.shaded.guava.common.annotations.VisibleForTesting;
import com.datastax.oss.driver.shaded.guava.common.collect.ImmutableList;
import com.datastax.oss.protocol.internal.Message;
import com.datastax.oss.protocol.internal.ProtocolConstants;
@@ -54,17 +59,26 @@
import com.datastax.oss.protocol.internal.response.event.StatusChangeEvent;
import com.datastax.oss.protocol.internal.response.event.TopologyChangeEvent;
import edu.umd.cs.findbugs.annotations.NonNull;
+import edu.umd.cs.findbugs.annotations.Nullable;
import io.netty.util.concurrent.EventExecutor;
+import java.time.Duration;
import java.util.AbstractMap.SimpleEntry;
import java.util.ArrayList;
import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Queue;
+import java.util.Set;
+import java.util.UUID;
import java.util.WeakHashMap;
import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
import java.util.concurrent.CompletionStage;
import java.util.function.Consumer;
import net.jcip.annotations.ThreadSafe;
@@ -90,6 +104,24 @@
public class ControlConnection implements EventCallback, AsyncAutoCloseable {
private static final Logger LOG = LoggerFactory.getLogger(ControlConnection.class);
+ /**
+ * How many removed host ids {@code SingleThreaded#removedHostIds} keeps before evicting the
+ * oldest. Large enough that the set still covers the churn of a rolling restart many times over,
+ * small enough that it cannot grow into a leak over a session's lifetime.
+ */
+ private static final int MAX_REMOVED_HOST_IDS = 256;
+
+ /**
+ * How many consecutive reconnection rounds may be refused entirely by exclusions before {@code
+ * SingleThreaded#removedHostIds} is cleared anyway.
+ *
+ *
Small, because every one of those rounds is a round that reached a live server and then
+ * threw the channel away: there is nothing to learn from repeating it, and the only thing still
+ * refusing is a judgement the driver cannot re-check while the control connection is down. Not
+ * one, because the refusal has to actually take effect -- see {@code #reconnect}.
+ */
+ private static final int MAX_ALL_EXCLUDED_ROUNDS = 3;
+
private final InternalDriverContext context;
private final String logPrefix;
private final EventExecutor adminExecutor;
@@ -267,6 +299,77 @@ private void processClientRoutesChange(Event event) {
.fire(new ClientRoutesUpdateEvent(crce.changeType, crce.connectionIds, crce.hostIds));
}
+ /**
+ * A node the control connection may not use, as opposed to one it could not reach. Recorded in a
+ * round's errors so that exhausting a plan reports why, and so that {@code anyNodeUnreachable}
+ * can tell the two apart -- what a round did to the nodes it was offered is what decides whether
+ * {@code removedHostIds} may be cleared.
+ */
+ /**
+ * Whether {@code error} records a node this connection was not allowed to use, rather than one it
+ * tried and failed to reach.
+ *
+ *
Walks the cause chain rather than testing the top-level throwable, because a refusal is
+ * wrapped once for every layer it travels through and the number of layers depends on where it
+ * was raised. From the query plan it arrives bare. From the connect hook -- where a contact
+ * point's host id is now settled, one candidate at a time -- it comes back through a failed stage
+ * as a {@link CompletionException}, and {@code ChannelFactory#finishCandidate} then wraps that in
+ * a {@code ConnectionInitException} before the candidate loop ever sees it. Matching on one fixed
+ * shape would silently classify the deeper one as a connectivity failure, which is the opposite
+ * of what it is.
+ *
+ *
Shared by every reader of a round's error list, which have to agree on what an exclusion
+ * means -- {@code anyNodeUnreachable} must not count one as having reached something, {@code
+ * anyNodeExcluded} decides whether the round spends any of the refusal budget, and {@code
+ * isAuthFailure} must not let one veto the verdict.
+ */
+ private static boolean isExcluded(Throwable error) {
+ // Bounded rather than unbounded: getCause() is overridable, so a cyclic chain is possible in
+ // principle, and no legitimate one is anywhere near this deep.
+ Throwable cause = error;
+ for (int depth = 0; cause != null && depth < 16; depth++) {
+ if (cause instanceof ExcludedNodeException) {
+ return true;
+ }
+ Throwable next = cause.getCause();
+ cause = (next == cause) ? null : next;
+ }
+ return false;
+ }
+
+ /**
+ * Whether {@code error} and every failure attached to it are exclusions, i.e. whether "we were
+ * not allowed to use it" is the whole story for the node that produced it.
+ *
+ *
The test to use wherever an exclusion must not be confused with a connection failure, for
+ * the same reason {@link ChannelFactory#isAuthOnly} exists: one error no longer means one
+ * address. A contact point expands to every address its name resolves to and reports a single
+ * failure with the others attached as {@linkplain Throwable#getSuppressed() suppressed}, so a
+ * name whose addresses went {@code [excluded, refused]} can surface either half depending on
+ * which one {@code ChannelFactory#surfacedFailure} promotes. Only when no address was reached at
+ * all is the node's failure genuinely an exclusion.
+ */
+ private static boolean isExclusionOnly(Throwable error) {
+ if (!isExcluded(error)) {
+ return false;
+ }
+ for (Throwable suppressed : error.getSuppressed()) {
+ if (!isExcluded(suppressed)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @VisibleForTesting
+ static class ExcludedNodeException extends IllegalStateException {
+ private static final long serialVersionUID = 1;
+
+ ExcludedNodeException(String reason) {
+ super(reason);
+ }
+ }
+
private class SingleThreaded {
private final InternalDriverContext context;
private final DriverConfig config;
@@ -276,12 +379,65 @@ private class SingleThreaded {
private boolean closeWasCalled;
private final ReconnectionPolicy reconnectionPolicy;
private final Reconnection reconnection;
- private DriverChannelOptions channelOptions;
+ // Computed once in init() and kept; the options themselves are built fresh for every connect
+ // attempt (see buildChannelOptions), so this is the only part of them that lives here.
+ private ImmutableList eventTypes;
private volatile ControlNodeState controlNodeState = ControlNodeState.NONE;
// The last events received for each node
private final Map lastNodeDistance = new WeakHashMap<>();
private final Map lastNodeState = new WeakHashMap<>();
+ /**
+ * Host ids the topology monitor has removed, for as long as they stay removed.
+ *
+ * The two maps above are keyed on the {@link Node} instance, which cannot answer for a node
+ * the driver is about to re-create: {@code MetadataManager#registerNode} mints a fresh {@link
+ * DefaultNode} for a host id absent from metadata, and {@code DefaultNode} overrides neither
+ * {@code equals} nor {@code hashCode}, so the new instance is in neither map. Removal is
+ * therefore also recorded by host id, which survives the re-creation -- see {@link
+ * #exclusionReasonForHostId}.
+ *
+ *
An entry is dropped as soon as the host id reports any state, so a node that legitimately
+ * comes back is not blocked -- but that path runs on node state events, which arrive from a
+ * metadata refresh and therefore need a working control connection. It cannot un-refuse a node
+ * while the control connection is down, which is the one moment this set can do harm, so a
+ * reconnection round that fails against its whole plan clears it outright (see {@link
+ * #reconnect}).
+ *
+ *
Neither of those bounds it on its own, which is why it is also capped at 256 entries,
+ * evicting the oldest. Unlike {@code lastNodeDistance} and {@code lastNodeState} next to it --
+ * {@link java.util.WeakHashMap}s, so a dead {@link Node} takes its entry with it -- this is
+ * keyed on a {@link UUID} the driver holds strongly, and the state-event path only ever drops
+ * the id of a node that came back. A host id that never returns, which is every
+ * decommissioned or replaced node, would otherwise stay for the life of the session: under
+ * rolling instance replacement each round mints new ids and none of the old ones are ever
+ * removed. Evicting the oldest is the right way to lose them, since the risk this set guards
+ * against -- a contact point whose DNS still lists a node the monitor removed -- fades as the
+ * removal recedes; the cost of an eviction is at worst one connection attempt to a node that is
+ * gone, which is the behaviour that predates the set entirely.
+ */
+ private final Set removedHostIds =
+ Collections.newSetFromMap(
+ new LinkedHashMap() {
+ @Override
+ protected boolean removeEldestEntry(Map.Entry eldest) {
+ return size() > MAX_REMOVED_HOST_IDS;
+ }
+ });
+
+ /**
+ * How many reconnection rounds in a row drained without reaching anything, every node in them
+ * having been refused instead.
+ *
+ * Counts only consecutive rounds: a round that reached something resets it, and so does a
+ * successful reconnection. At {@link #MAX_ALL_EXCLUDED_ROUNDS} it clears {@link
+ * #removedHostIds} and resets, which is the only exit from that set that does not require the
+ * control connection this class is trying to restore. See {@code #reconnect}.
+ *
+ *
Admin-thread confined, like everything else in this class.
+ */
+ private int consecutiveAllExcludedRounds;
+
private SingleThreaded(InternalDriverContext context) {
this.context = context;
this.config = context.getConfig();
@@ -320,15 +476,8 @@ private void init(
try {
boolean listenClientRoutesEvents =
context.getTopologyMonitor() instanceof ClientRoutesTopologyMonitor;
- ImmutableList eventTypes =
- buildEventTypes(listenToClusterEvents, listenClientRoutesEvents);
+ this.eventTypes = buildEventTypes(listenToClusterEvents, listenClientRoutesEvents);
LOG.debug("[{}] Initializing with event types {}", logPrefix, eventTypes);
- channelOptions =
- DriverChannelOptions.builder()
- .withEvents(eventTypes, ControlConnection.this)
- .withOwnerLogPrefix(logPrefix + "|control")
- .reportConfig(true)
- .build();
Queue nodes =
context.getLoadBalancingPolicyWrapper().newControlReconnectionQueryPlan();
@@ -377,11 +526,118 @@ private CompletionStage reconnect() {
onSuccessfulReconnect();
},
error -> {
+ // A round that reached nothing at all leaves this judgement unusable, so drop it.
+ //
+ // removedHostIds is only ever cleared by a NodeStateEvent, and those arrive from a
+ // metadata refresh, which needs the very control connection this is trying to restore.
+ // A host id recorded as removed on stale information -- a node transiently missing from
+ // a peers table during a restart, say -- would therefore refuse the only reachable
+ // address for the rest of the session, and the contact-point fallback that exists to
+ // re-resolve names could never recover from it. That is a permanent deadlock; a
+ // resurrection is not, since the ids are re-learned from the first successful refresh.
+ //
+ // Clearing here does not weaken the protection where it earns its keep. The case it
+ // guards -- a contact point whose DNS still lists a node the monitor removed -- happens
+ // while other nodes are reachable, so those rounds succeed and never reach this branch.
+ // Only a round that failed against every node in its plan does, and at that point the
+ // driver's view of who was removed is exactly as stale as its view of everything else.
+ //
+ // Only when something was genuinely unreachable, though. This branch is also reached by
+ // a plan that drained without a single connectivity failure, every node in it having
+ // been refused instead: excluded by distance or state, or turned away by host id --
+ // which is this very set doing its job. Nothing is stale about the driver's view then,
+ // and clearing on it would undo, on the round that just enforced it, the refusal that
+ // the next round's contact-point fallback would immediately need again.
+ //
+ // But not forever. Enforcing a refusal is one thing; enforcing it for the life of the
+ // session on evidence that can never be rechecked is another, and that is what an
+ // unbounded version of this would do. Every other way out of the set needs something
+ // this situation does not have: a NodeStateEvent arrives from a metadata refresh, which
+ // needs the control connection being restored here, and the LRU cap only evicts after
+ // MAX_REMOVED_HOST_IDS *further* removals, which likewise cannot be learned. So a round
+ // that reached a live server and refused it, over and over, is a round that will keep
+ // producing the identical outcome -- and if the refused host id is the only address the
+ // plan has, the session never recovers. Give the refusal MAX_ALL_EXCLUDED_ROUNDS rounds
+ // to matter, then clear and let the next round find out for itself. Being wrong that
+ // way costs one connect to a node that is gone; being wrong the other way costs the
+ // session.
+ //
+ // Only a round that actually refused something counts against that budget. A plan that
+ // was empty to begin with drains through here too, and it is neither of the two cases
+ // above: it reached nothing and it turned nothing away, so it learned nothing either
+ // way -- which is precisely why #anyNodeUnreachable declines to clear on it. Letting it
+ // drive the budget would discard the set on the one kind of evidence both branches
+ // agree confers no standing, and an empty plan is reachable: turn the contact-point
+ // fallback off and let the load balancing policy's view go empty.
+ if (anyNodeUnreachable(error)) {
+ consecutiveAllExcludedRounds = 0;
+ removedHostIds.clear();
+ } else if (anyNodeExcluded(error)
+ && ++consecutiveAllExcludedRounds >= MAX_ALL_EXCLUDED_ROUNDS) {
+ LOG.debug(
+ "[{}] {} consecutive reconnection rounds were refused outright; "
+ + "discarding the set of removed host ids so the next round can retry them",
+ logPrefix,
+ consecutiveAllExcludedRounds);
+ consecutiveAllExcludedRounds = 0;
+ removedHostIds.clear();
+ }
result.complete(false);
});
return result;
}
+ /**
+ * Whether a failed reconnection round actually failed to reach something, as opposed to
+ * having had every node in its plan refused.
+ *
+ * Drawn from the errors the round collected, since both outcomes arrive here as the same
+ * {@link AllNodesFailedException}. A round with no errors at all -- a plan that was empty to
+ * begin with -- counts as not unreachable: it never tried anything, so it learned
+ * nothing about who is reachable and has no standing to discard the removal set.
+ *
+ *
{@link #isExclusionOnly}, not {@link #isExcluded}: a contact point reports one failure for
+ * the whole name, and one whose addresses went {@code [excluded, refused]} did reach something.
+ */
+ private boolean anyNodeUnreachable(Throwable roundFailure) {
+ if (!(roundFailure instanceof AllNodesFailedException)) {
+ return false;
+ }
+ for (List nodeErrors :
+ ((AllNodesFailedException) roundFailure).getAllErrors().values()) {
+ for (Throwable nodeError : nodeErrors) {
+ if (!isExclusionOnly(nodeError)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Whether a failed reconnection round refused at least one node, as opposed to having had
+ * nothing to try in the first place.
+ *
+ * The complement of {@link #anyNodeUnreachable} on the branch that matters, not its
+ * negation: a round can be neither, and an empty plan is exactly that. Both predicates read the
+ * same error list and agree that such a round learned nothing -- so it neither clears {@code
+ * removedHostIds} nor spends any of the budget that eventually will.
+ */
+ private boolean anyNodeExcluded(Throwable roundFailure) {
+ if (!(roundFailure instanceof AllNodesFailedException)) {
+ return false;
+ }
+ for (List nodeErrors :
+ ((AllNodesFailedException) roundFailure).getAllErrors().values()) {
+ for (Throwable nodeError : nodeErrors) {
+ if (isExcluded(nodeError)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
private void connect(
Queue nodes,
List> errors,
@@ -393,21 +649,62 @@ private void connect(
onFailure.accept(AllNodesFailedException.fromErrors(errors));
} else {
LOG.debug("[{}] Trying to establish a connection to {}", logPrefix, node);
+ NodeInfoHolder capturedNodeInfo = new NodeInfoHolder();
context
.getChannelFactory()
- .connect(node, channelOptions)
+ .connect(node, buildChannelOptions(node, capturedNodeInfo))
.whenCompleteAsync(
(channel, error) -> {
try {
- NodeDistance lastDistance = lastNodeDistance.get(node);
- NodeState lastState = lastNodeState.get(node);
+ String exclusion = exclusionReason(node);
if (error != null) {
if (closeWasCalled || initFuture.isCancelled()) {
onSuccess.run(); // abort, we don't really care about the result
+ } else if (isExclusionOnly(error)) {
+ // Every address of this contact point turned out to be a node this
+ // connection may not use -- the connect hook refused each one on its host
+ // id (see #readChannelNodeInfo). Reported exactly as the two exclusions
+ // that are decided on the admin thread are: at DEBUG, recorded so that
+ // exhausting the plan this way says why rather than surfacing a bare
+ // NoNodeAvailableException, and with no controlConnectionFailed event,
+ // because nothing failed to connect. Routing it through the branch below
+ // would warn the operator about a deployment that is in fact reachable,
+ // and would count a refusal as a connection failure in the metrics.
+ LOG.debug(
+ "[{}] Every address of {} belongs to a node this connection may not"
+ + " use, trying next node",
+ logPrefix,
+ node,
+ error);
+ List> exclusionErrors =
+ (errors == null) ? new ArrayList<>() : errors;
+ exclusionErrors.add(new SimpleEntry<>(node, error));
+ connect(nodes, exclusionErrors, onSuccess, onFailure);
} else {
- if (error instanceof AuthenticationException) {
+ // isAuthOnly, not a bare instanceof: ChannelFactory reports one failure
+ // per contact point with the other addresses' failures attached as
+ // suppressed, and it deliberately surfaces an authentication failure over
+ // transport ones. A name whose records failed [refused, refused, auth] is
+ // not an authentication problem, and logging it as one would hide that two
+ // thirds of the deployment is unreachable.
+ if (ChannelFactory.isAuthOnly(error)) {
Loggers.warnWithException(
LOG, "[{}] Authentication error", logPrefix, error);
+ } else if (error instanceof AuthenticationException) {
+ // Mixed [refused, refused, auth]. Not an authentication problem alone --
+ // hence the wording -- but still warned unconditionally, as every
+ // AuthenticationException was before multi-address support.
+ // advanced.connection.warn-on-init-error mutes unreachable-node noise;
+ // it is not a switch for "your credentials are wrong". Folding this case
+ // into the gated branch below would log the only actionable half of the
+ // failure at DEBUG.
+ Loggers.warnWithException(
+ LOG,
+ "[{}] Error connecting to {} (authentication failed on some of its"
+ + " addresses, others failed for other reasons), trying next node",
+ logPrefix,
+ node,
+ error);
} else {
if (config
.getDefaultProfile()
@@ -439,24 +736,24 @@ private void connect(
channel);
channel.forceClose();
onSuccess.run();
- } else if (lastDistance == NodeDistance.IGNORED) {
+ } else if (exclusion != null) {
LOG.debug(
- "[{}] New channel opened ({}) but node became ignored, "
- + "closing and trying next node",
+ "[{}] New channel opened ({}) but {}, closing and trying next node",
logPrefix,
- channel);
- channel.forceClose();
- connect(nodes, errors, onSuccess, onFailure);
- } else if (lastNodeState.containsKey(node)
- && (lastState == null /*(removed)*/
- || lastState == NodeState.FORCED_DOWN)) {
- LOG.debug(
- "[{}] New channel opened ({}) but node was removed or forced down, "
- + "closing and trying next node",
- logPrefix,
- channel);
+ channel,
+ exclusion);
channel.forceClose();
- connect(nodes, errors, onSuccess, onFailure);
+ // Recorded for the same reason as the post-handshake exclusion below, and
+ // marked for the same reason: a plan drained entirely by exclusions has to
+ // report why rather than surface a bare NoNodeAvailableException, and the
+ // reconnection's failure callback has to be able to tell "everything was
+ // refused" from "nothing could be reached". No controlConnectionFailed event
+ // though -- nothing failed to connect.
+ List> exclusionErrors =
+ (errors == null) ? new ArrayList<>() : errors;
+ exclusionErrors.add(
+ new SimpleEntry<>(node, new ExcludedNodeException(exclusion)));
+ connect(nodes, exclusionErrors, onSuccess, onFailure);
} else {
LOG.debug("[{}] New channel opened {}", logPrefix, channel);
DriverChannel previousChannel = ControlConnection.this.channel;
@@ -469,7 +766,7 @@ private void connect(
previousChannel);
previousChannel.forceClose();
}
- resolveChannelNodeIfNeeded(channel, (DefaultNode) node)
+ resolveChannelNodeIfNeeded(channel, (DefaultNode) node, capturedNodeInfo)
.whenCompleteAsync(
(resolvedNode, fetchError) -> {
if (fetchError != null) {
@@ -500,19 +797,78 @@ private void connect(
new Exception("Channel closed during endpoint resolve")));
connect(nodes, newErrors, onSuccess, onFailure);
} else {
- controlNodeState = new ControlNodeState(resolvedNode, null);
- context
- .getEventBus()
- .fire(ChannelEvent.channelOpened(resolvedNode));
- channel
- .closeFuture()
- .addListener(
- f ->
- adminExecutor
- .submit(
- () -> onChannelClosed(channel, resolvedNode))
- .addListener(UncaughtExceptions::log));
- onSuccess.run();
+ // The guards above ran against the node the query plan offered.
+ // For a contact point appended by the reconnection fallback that
+ // is an ephemeral instance which is never the subject of a
+ // distance or state event, so it is never a key in either map and
+ // those guards cannot have seen anything. Only now, once the
+ // handshake has said which node actually answered, is there
+ // something to ask about -- and asking matters, because nothing
+ // downstream will: an unchanged distance fires no event, so a
+ // control connection parked on an excluded node stays there.
+ String resolvedExclusion = exclusionReason(resolvedNode);
+ if (resolvedExclusion != null) {
+ LOG.debug(
+ "[{}] Channel {} turned out to be {}, which {}; "
+ + "closing and trying next node",
+ logPrefix,
+ channel,
+ resolvedNode,
+ resolvedExclusion);
+ controlNodeState = ControlNodeState.NONE;
+ // Null out before forceClose(), as above, so that
+ // onChannelClosed() does not start a redundant reconnection on
+ // top of the connect() retry below.
+ ControlConnection.this.channel = null;
+ channel.forceClose();
+ // Recorded, so that exhausting the plan this way reports why
+ // rather than a bare NoNodeAvailableException. No
+ // controlConnectionFailed event though: nothing failed to
+ // connect, the node is simply not one we may use -- same as the
+ // pre-handshake exclusion branch above.
+ List> newErrors =
+ (errors == null) ? new ArrayList<>() : errors;
+ newErrors.add(
+ new SimpleEntry<>(
+ resolvedNode,
+ new ExcludedNodeException(resolvedExclusion)));
+ connect(nodes, newErrors, onSuccess, onFailure);
+ } else {
+ controlNodeState = new ControlNodeState(resolvedNode, null);
+ // Contained, because this callback is the only thing that
+ // completes the round and it is not wrapped by the outer
+ // catch (Exception) above -- that one guards the *outer*
+ // whenCompleteAsync, a different stack. EventBus.fire() has no
+ // try/catch of its own and RunOrSchedule.on(adminExecutor, ..)
+ // runs listeners inline when already on the admin loop, so a
+ // user NodeStateListener.onUp that throws would escape here,
+ // skip onSuccess.run(), and leave the Reconnection parked in
+ // ATTEMPT_IN_PROGRESS for good. The channel is open either
+ // way; a listener's failure is not the connection's.
+ try {
+ context
+ .getEventBus()
+ .fire(ChannelEvent.channelOpened(resolvedNode));
+ } catch (Throwable t) {
+ Loggers.warnWithException(
+ LOG,
+ "[{}] Listener threw while handling channelOpened for {};"
+ + " the control connection is up regardless",
+ logPrefix,
+ resolvedNode,
+ t);
+ }
+ channel
+ .closeFuture()
+ .addListener(
+ f ->
+ adminExecutor
+ .submit(
+ () ->
+ onChannelClosed(channel, resolvedNode))
+ .addListener(UncaughtExceptions::log));
+ onSuccess.run();
+ }
}
},
adminExecutor);
@@ -530,32 +886,248 @@ private void connect(
}
/**
- * Resolves the identity of the node at the other end of the channel. For contact point nodes
- * (no hostId), queries system.local and registers a new metadata node. For nodes that already
- * have a hostId, returns the node as-is.
+ * Why {@code candidate} must not be used for the control connection -- the load balancing
+ * policy has excluded it, or a topology event has -- or {@code null} if nothing rules it out.
+ *
+ * Both maps are keyed on the {@link Node} instance and filled only from events, so a node
+ * that has never been the subject of one is simply absent, and absent means "nothing known
+ * against it" rather than "fine". That distinction is why this is asked twice per connect: once
+ * about the node the query plan offered, and again about the node the handshake proved is at
+ * the other end, which for a contact point is a different instance.
+ *
+ *
Keying on the instance means this can only answer for a node the driver still holds. For a
+ * node it does not -- one the monitor removed, which a contact point can lead back to -- see
+ * {@link #exclusionReasonForHostId}.
*/
- private CompletionStage resolveChannelNodeIfNeeded(
- DriverChannel channel, DefaultNode node) {
- if (node.getHostId() != null) {
- return CompletableFuture.completedFuture(node);
+ private String exclusionReason(Node candidate) {
+ if (lastNodeDistance.get(candidate) == NodeDistance.IGNORED) {
+ return "node became ignored";
+ }
+ if (lastNodeState.containsKey(candidate)) {
+ NodeState state = lastNodeState.get(candidate);
+ if (state == null /*(removed)*/ || state == NodeState.FORCED_DOWN) {
+ return "node was removed or forced down";
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Why the node answering under {@code hostId} must not be used for the control connection, or
+ * {@code null} if nothing rules it out.
+ *
+ * The host-id-keyed counterpart of {@link #exclusionReason}, for the one moment that has to
+ * be settled before {@code MetadataManager#registerNode}: a contact point whose DNS
+ * record still lists a node the topology monitor has removed. Registering first would publish
+ * that node back into {@code Metadata#getNodes()} and fire {@code NodeStateListener#onAdd} for
+ * it, and the instance-keyed check could not then undo it -- registerNode returns a brand-new
+ * {@link DefaultNode}, which is in neither event map. That is exactly the resurrection {@code
+ * TopologyMonitor#reresolvesNodeAddresses()} describes, reachable through the contact-point
+ * reconnection fallback whatever that flag says.
+ *
+ *
A host id the driver still has in metadata is deferred to {@link #exclusionReason}, so
+ * IGNORED and FORCED_DOWN keep being reported with their own wording. A host id that is in
+ * neither metadata nor {@link #removedHostIds} is simply new -- a node that joined while the
+ * driver was disconnected, which the fallback exists to reach -- and is allowed through.
+ */
+ @Nullable
+ private String exclusionReasonForHostId(@Nullable UUID hostId) {
+ if (hostId == null) {
+ // registerNode's own precondition reports this better than a generic exclusion would.
+ return null;
+ }
+ Node known = context.getMetadataManager().getMetadata().getNodes().get(hostId);
+ if (known != null) {
+ return exclusionReason(known);
+ }
+ return removedHostIds.contains(hostId) ? "node was removed" : null;
+ }
+
+ /**
+ * {@link #exclusionReasonForHostId} as a plain map, so that a connect hook can ask it from a
+ * channel's event loop.
+ *
+ *
The hook is where a contact point's identity is settled while its remaining addresses are
+ * still on hand, and refusing there costs one address instead of the whole plan entry -- but
+ * the state the answer comes from cannot be read there. {@code lastNodeDistance} and {@code
+ * lastNodeState} are {@link WeakHashMap}s, whose {@code get} expunges cleared entries and so
+ * writes; {@code removedHostIds} is a plain {@link LinkedHashMap}-backed set the admin thread
+ * mutates. All three are confined to {@code adminExecutor}, which is where this runs.
+ *
+ *
Enumerated by calling {@link #exclusionReasonForHostId} rather than by restating what it
+ * does, so the two cannot drift: every host id it can answer non-null for is a key of metadata
+ * or of {@code removedHostIds}, and both are asked. It also keeps no {@link Node} out of the
+ * weak maps -- the values are strings -- so a snapshot outliving a connect cannot pin a node
+ * that metadata has dropped.
+ *
+ *
Usually empty, and never bigger than the number of nodes currently excluded.
+ */
+ private Map excludedHostIds() {
+ assert adminExecutor.inEventLoop();
+ Set candidates =
+ new HashSet<>(context.getMetadataManager().getMetadata().getNodes().keySet());
+ candidates.addAll(removedHostIds);
+ Map excluded = new HashMap<>();
+ for (UUID hostId : candidates) {
+ String reason = exclusionReasonForHostId(hostId);
+ if (reason != null) {
+ excluded.put(hostId, reason);
+ }
+ }
+ return excluded.isEmpty() ? Collections.emptyMap() : Collections.unmodifiableMap(excluded);
+ }
+
+ /**
+ * Options for one connect attempt against one query-plan node. Built fresh per attempt rather
+ * than cached: an attempt against an unidentified node carries a stateful holder that the
+ * connect hook fills, and overlapping connect chains are reachable -- the initial {@code
+ * connect()} runs outside {@code Reconnection} (which serializes only its own attempts), and
+ * {@code reconnectNow()} checks only {@code initWasCalled} -- so nothing stateful may be shared
+ * between attempts.
+ */
+ private DriverChannelOptions buildChannelOptions(Node node, NodeInfoHolder capturedNodeInfo) {
+ DriverChannelOptions.Builder builder =
+ DriverChannelOptions.builder()
+ .withEvents(eventTypes, ControlConnection.this)
+ .withOwnerLogPrefix(logPrefix + "|control")
+ .reportConfig(true);
+ if (node.getHostId() == null) {
+ // A contact point: the driver does not yet know which node answers at each of its
+ // addresses, so the identity read happens through the connect hook, inside the factory's
+ // candidate loop, where the hostname's other addresses are still on hand and a rejection
+ // costs one of them. Read after the connect instead, the candidates are already gone and a
+ // failure writes off the whole plan entry for that round.
+ //
+ // Both criteria are applied there: that the node identifies itself at all, and that the
+ // host id it gives is one this connection may use. The second needs state the hook's thread
+ // cannot read, so it is snapshotted here, on the admin loop -- see #excludedHostIds. Taken
+ // per attempt, which is also when the options are built, so it is as current as the attempt
+ // is; an event landing mid-connect is caught by the second, live check in
+ // #resolveChannelNodeIfNeeded.
+ Duration hookTimeout =
+ config.getDefaultProfile().getDuration(DefaultDriverOption.CONTROL_CONNECTION_TIMEOUT);
+ Map excludedHostIds = excludedHostIds();
+ builder.withConnectHook(
+ channel -> readChannelNodeInfo(channel, capturedNodeInfo, excludedHostIds),
+ hookTimeout);
}
+ return builder.build();
+ }
+
+ /**
+ * The connect hook of a contact-point attempt: reads which node answered and channels it
+ * straight into the attempt's holder, rejecting the candidate when the node cannot identify
+ * itself, or identifies itself as one this connection may not use.
+ *
+ * Runs on the channel's event loop and touches no {@code SingleThreaded} state: the holder
+ * is the only thing written, and it is read back on the admin thread only after the connect
+ * completes. {@code excludedHostIds} was snapshotted on the admin thread when this attempt's
+ * options were built, precisely so that nothing here has to read the collections it came from.
+ *
+ *
Rejecting here rather than after the connect is what confines the cost of an exclusion to
+ * the one address that hit it. {@code ChannelFactory} does not treat an {@code
+ * ExcludedNodeException} as node-wide, so the candidate loop moves on to the hostname's next
+ * address -- which, for a contact point whose DNS still lists a node the monitor removed, is
+ * quite likely a node it may use.
+ */
+ private CompletionStage readChannelNodeInfo(
+ DriverChannel channel, NodeInfoHolder capturedNodeInfo, Map excludedHostIds) {
return context
.getTopologyMonitor()
.getChannelNodeInfo(channel)
- .thenComposeAsync(
+ .thenAccept(
nodeInfo -> {
- EndPoint resolvedEp = nodeInfo.getEndPoint();
- if (resolvedEp != null && !resolvedEp.equals(channel.getEndPoint())) {
- channel.setEndPoint(resolvedEp);
- LOG.debug("[{}] Control channel endpoint upgraded to {}", logPrefix, resolvedEp);
+ // Mirrors DefaultTopologyMonitor's own precondition, so that a custom monitor
+ // cannot smuggle a null past registerNode: rejecting here costs one address, while
+ // failing in registerNode later would cost the whole plan entry.
+ Objects.requireNonNull(
+ nodeInfo.getHostId(),
+ "Node info is missing its host id; the node may still be bootstrapping");
+ String exclusion = excludedHostIds.get(nodeInfo.getHostId());
+ if (exclusion != null) {
+ throw new ExcludedNodeException(exclusion);
}
- return context.getMetadataManager().registerNode(nodeInfo);
- },
- adminExecutor);
+ capturedNodeInfo.set(channel, nodeInfo);
+ });
+ }
+
+ /**
+ * Resolves the identity of the node at the other end of the channel. For nodes that already
+ * have a hostId, returns the node as-is. For a contact point, the connect hook has already read
+ * {@code system.local} and captured the result (see {@link #readChannelNodeInfo}); this
+ * registers a new metadata node from it.
+ */
+ private CompletionStage resolveChannelNodeIfNeeded(
+ DriverChannel channel, DefaultNode node, NodeInfoHolder capturedNodeInfo) {
+ if (node.getHostId() != null) {
+ return CompletableFuture.completedFuture(node);
+ }
+ NodeInfo captured = capturedNodeInfo.getFor(channel);
+ // The pairing with the channel is asserted rather than assumed, and a miss falls back to a
+ // direct read: a ChannelFactory subclass that does not run the connect hook still gets a
+ // functioning control connection (and the mocked factories in the unit tests exercise this
+ // same path). The fallback costs one extra round trip, on that path only.
+ CompletionStage nodeInfoFuture =
+ (captured != null)
+ ? CompletableFuture.completedFuture(captured)
+ : context.getTopologyMonitor().getChannelNodeInfo(channel);
+ return nodeInfoFuture.thenComposeAsync(
+ nodeInfo -> {
+ // Asked before registerNode, not after: registration is what publishes a node into
+ // Metadata#getNodes() and fires NodeStateListener#onAdd, and for a host id the driver
+ // does not know it *creates* the node. Deciding afterwards would mean resurrecting a
+ // node the topology monitor has removed and only then refusing it -- and refusing it
+ // would not even work, since the instance registerNode just minted is not a key in
+ // either event map (see #exclusionReason).
+ //
+ // Asked again, rather than only in the connect hook: the hook goes on a snapshot taken
+ // before the connect, so a removal or a distance change that landed while it was in
+ // flight is not in it. This read is live. The hook is what keeps an exclusion from
+ // costing the whole plan entry; this is what keeps the answer current.
+ String exclusion = exclusionReasonForHostId(nodeInfo.getHostId());
+ if (exclusion != null) {
+ return CompletableFutures.failedFuture(new ExcludedNodeException(exclusion));
+ }
+ EndPoint resolvedEp = nodeInfo.getEndPoint();
+ EndPoint channelEp = channel.getEndPoint();
+ // The channel adopts the node's endpoint so that everything reading it afterwards --
+ // DefaultTopologyMonitor's localEndPoint on the next refresh, refreshNode's
+ // control-node
+ // check, OptionalLocalDcHelper's endpoint fallback -- sees the same instance the node
+ // holds, rather than the contact point this connection happened to come up through.
+ //
+ // Pinned to the address this channel actually reached, though, because the node's own
+ // endpoint need not name one: SniEndPoint and ClientRoutesEndPoint hand out a *name* by
+ // design and re-expand it per connect. Adopting such an endpoint unpinned would make
+ // channel.getEndPoint().resolve() the shared proxy name, which every SniEndPoint in the
+ // cluster equals -- so #isControlNode's resolve() comparison would answer true for any
+ // node, and JAVA-2303's self-peer guard (broadcastRpcAddress.equals(localEndPoint
+ // .resolve())) would stop matching, an unresolved address never equalling a resolved
+ // one. pinTo() is a no-op when there is nothing to pin to.
+ //
+ // The same test as DefaultNode#setEndPoint, and deliberately not equals(): this is
+ // exactly the mixed unresolved-vs-resolved case (see PinnableEndPoint#sameIdentity).
+ if (resolvedEp != null
+ && resolvedEp != channelEp
+ && !PinnableEndPoint.sameIdentity(resolvedEp, channelEp)) {
+ EndPoint adopted =
+ (resolvedEp instanceof PinnableEndPoint)
+ ? ((PinnableEndPoint) resolvedEp).pinTo(channelEp.resolve())
+ : resolvedEp;
+ channel.setEndPoint(adopted);
+ LOG.debug("[{}] Control channel endpoint upgraded to {}", logPrefix, adopted);
+ }
+ return context.getMetadataManager().registerNode(nodeInfo);
+ },
+ adminExecutor);
}
private void onSuccessfulReconnect() {
assert adminExecutor.inEventLoop();
+ // A round got through, so the count of rounds that did not is no longer consecutive. Reset it
+ // here rather than only on the failure path, so a session that alternates between a refused
+ // round and a good one never accumulates its way to a spurious clear.
+ consecutiveAllExcludedRounds = 0;
// If reconnectOnFailure was true and we've never connected before, complete the future now to
// signal that the initialization is complete. Schema refresh and LBP initialization for the
// first connection are handled by the session initialization path (DefaultSession.init), not
@@ -687,10 +1259,44 @@ private boolean isControlNode(Node eventNode) {
&& eventNode.getHostId().equals(state.current.getHostId())) {
return true;
}
- if (state.current == null
- && state.pending != null
- && Objects.equals(eventNode.getEndPoint(), state.pending.getEndPoint())) {
- return true;
+ if (state.current == null && state.pending != null) {
+ // Resolution is still in flight, so there is no host id to compare yet and the endpoint is
+ // all there is to go on. The channel's own endpoint is what to compare against: unlike the
+ // pending node's, ChannelFactory has bound it to the one address the connection actually
+ // went to.
+ DriverChannel pendingChannel = ControlConnection.this.channel;
+ if (pendingChannel == null) {
+ return false;
+ }
+ EndPoint eventEndPoint = eventNode.getEndPoint();
+ EndPoint channelEndPoint = pendingChannel.getEndPoint();
+ // Two lookup-free comparisons, because neither shape is covered by the other.
+ //
+ // resolve() settles it when both sides hold a concrete address: the event carries a
+ // metadata
+ // node whose endpoint is a resolved IP, and the channel's is pinned to the IP it reached.
+ // This is the case the plain hostname contact point hits, and comparing resolve() results
+ // rather than the endpoints keeps DefaultEndPoint#equals -- which resolves the unresolved
+ // side of a mixed comparison, i.e. a blocking DNS lookup on the admin thread, on an
+ // arbitrary single address (issue #1006) -- off a path that runs for every distance or
+ // state
+ // event arriving during a control connect.
+ if (Objects.equals(eventEndPoint.resolve(), channelEndPoint.resolve())) {
+ return true;
+ }
+ // But resolve() cannot settle it when the endpoint's current address is a *name*, which is
+ // the permanent state of an SNI proxy address, of a client route, and of anything a custom
+ // AddressTranslator hands over unresolved: the event node resolves to that unresolved name
+ // while the channel resolves to the IP it was pinned to, and an InetSocketAddress carrying
+ // an InetAddress never equals one that does not. For those endpoints identity does not key
+ // on the address at all -- SniEndPoint on proxy + serverName, ClientRoutesEndPoint on the
+ // host id, both of which a Cloud contact point and its metadata node share -- so equals()
+ // answers exactly the right question, and does so without resolving anything. Every
+ // implementation except DefaultEndPoint qualifies; that one is excluded precisely because
+ // its equals() is the one that would resolve.
+ return eventEndPoint.getClass() == channelEndPoint.getClass()
+ && !(eventEndPoint instanceof DefaultEndPoint)
+ && eventEndPoint.equals(channelEndPoint);
}
return false;
}
@@ -713,6 +1319,14 @@ && isControlNode(event.node)) {
private void onStateEvent(NodeStateEvent event) {
assert adminExecutor.inEventLoop();
this.lastNodeState.put(event.node, event.newState);
+ UUID hostId = event.node.getHostId();
+ if (hostId != null) {
+ if (event.newState == null /*(removed)*/) {
+ removedHostIds.add(hostId);
+ } else {
+ removedHostIds.remove(hostId);
+ }
+ }
if ((event.newState == null /*(removed)*/ || event.newState == NodeState.FORCED_DOWN)
&& channel != null
&& !channel.closeFuture().isDone()
@@ -752,22 +1366,94 @@ private void forceClose() {
}
}
- private boolean isAuthFailure(Throwable error) {
- if (error instanceof AllNodesFailedException) {
- Collection> errors =
- ((AllNodesFailedException) error).getAllErrors().values();
- if (errors.isEmpty()) {
- return false;
- }
- for (List nodeErrors : errors) {
- for (Throwable nodeError : nodeErrors) {
- if (!(nodeError instanceof AuthenticationException)) {
- return false;
- }
+ /**
+ * Whether every contact point failed for the one reason worth telling the operator to go and fix
+ * their configuration over: bad credentials, everywhere.
+ *
+ * Each entry is tested with {@link ChannelFactory#isAuthOnly} rather than a bare {@code
+ * instanceof}, because one entry no longer means one address. {@code ChannelFactory} expands a
+ * contact-point hostname to every address it resolves to and reports a single failure for the
+ * name, with the other addresses' failures attached as suppressed exceptions. Looking only at the
+ * top-level throwable would call a name whose records failed {@code [refused, refused, auth]} an
+ * authentication failure, and claim in the log that authentication is what is wrong with the
+ * deployment when two thirds of it is unreachable.
+ */
+ @VisibleForTesting
+ static boolean isAuthFailure(Throwable error) {
+ if (!(error instanceof AllNodesFailedException)) {
+ // Anything else carries no per-node breakdown to inspect, so there is nothing here that says
+ // every contact point rejected the credentials.
+ return false;
+ }
+ Collection> errors = ((AllNodesFailedException) error).getAllErrors().values();
+ if (errors.isEmpty()) {
+ return false;
+ }
+ // An excluded node is skipped rather than allowed to veto. It was never asked for credentials,
+ // so it is no evidence either way -- and letting it answer would hide a genuine credential
+ // problem behind one node that happened to be IGNORED or forced down. If skipping leaves
+ // nothing, the round tried nobody and there is no verdict to report.
+ //
+ // Only when the exclusion is the whole story for that node, though: a contact point whose
+ // addresses went [excluded, auth] was asked for credentials, on the address that reached a
+ // server, and skipping it would drop the only evidence there is.
+ boolean anyTried = false;
+ for (List nodeErrors : errors) {
+ for (Throwable nodeError : nodeErrors) {
+ if (isExclusionOnly(nodeError)) {
+ continue;
+ }
+ if (!ChannelFactory.isAuthOnly(nodeError)) {
+ return false;
}
+ anyTried = true;
+ }
+ }
+ return anyTried;
+ }
+
+ /**
+ * What one contact-point connect attempt learned about the node that answered: filled by the
+ * connect hook on the channel's event loop, read back on the admin thread once the connect
+ * completes. One instance per attempt -- overlapping attempts must not share it, which is why
+ * {@code DriverChannelOptions} are built per attempt.
+ */
+ @VisibleForTesting
+ static final class NodeInfoHolder {
+
+ /**
+ * The two values are published as one reference so that a reader can never see one candidate's
+ * node info paired with another's channel. Two separate volatile fields would not do: the
+ * factory's candidate loop can leave a stranded hook behind -- an attempt abandoned on the hook
+ * timeout, whose {@code system.local} response then arrives anyway -- and its late write would
+ * land in between the accepted candidate's two writes. The admin thread reading in that window
+ * would take the rejected candidate's node info as the accepted channel's, and the control
+ * connection would then register the wrong host id and endpoint for the node it is talking to.
+ *
+ * With the pair atomic, that late write merely makes {@link #getFor} miss, which falls back
+ * to reading {@code system.local} again on the channel that is actually open.
+ */
+ private volatile Capture capture;
+
+ void set(DriverChannel channel, NodeInfo nodeInfo) {
+ this.capture = new Capture(channel, nodeInfo);
+ }
+
+ /** The captured info if it came from {@code channel}: the pairing is asserted, not assumed. */
+ NodeInfo getFor(DriverChannel channel) {
+ Capture current = this.capture;
+ return (current != null && current.channel == channel) ? current.nodeInfo : null;
+ }
+
+ private static final class Capture {
+ final DriverChannel channel;
+ final NodeInfo nodeInfo;
+
+ Capture(DriverChannel channel, NodeInfo nodeInfo) {
+ this.channel = channel;
+ this.nodeInfo = nodeInfo;
}
}
- return true;
}
/**
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/loadbalancing/helper/OptionalLocalDcHelper.java b/core/src/main/java/com/datastax/oss/driver/internal/core/loadbalancing/helper/OptionalLocalDcHelper.java
index b93a16a6525..97aab92fff7 100644
--- a/core/src/main/java/com/datastax/oss/driver/internal/core/loadbalancing/helper/OptionalLocalDcHelper.java
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/loadbalancing/helper/OptionalLocalDcHelper.java
@@ -26,7 +26,6 @@
import edu.umd.cs.findbugs.annotations.NonNull;
import java.util.ArrayList;
import java.util.HashSet;
-import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -68,73 +67,34 @@ public OptionalLocalDcHelper(
@Override
@NonNull
public Optional discoverLocalDc(@NonNull Map nodes) {
- String localDcStr = context.getLocalDatacenter(profile.getName());
- Optional localDc;
- if (localDcStr != null) {
- LOG.debug("[{}] Local DC set programmatically: {}", logPrefix, localDcStr);
- localDc = Optional.of(localDcStr);
+ String localDc = context.getLocalDatacenter(profile.getName());
+ if (localDc != null) {
+ LOG.debug("[{}] Local DC set programmatically: {}", logPrefix, localDc);
} else if (profile.isDefined(DefaultDriverOption.LOAD_BALANCING_LOCAL_DATACENTER)) {
- localDcStr = profile.getString(DefaultDriverOption.LOAD_BALANCING_LOCAL_DATACENTER);
- LOG.debug("[{}] Local DC set from configuration: {}", logPrefix, localDcStr);
- localDc = Optional.of(localDcStr);
- } else {
- localDc = Optional.empty();
- }
- if (localDc.isPresent()) {
- checkLocalDatacenterCompatibility(
- localDc.get(), context.getMetadataManager().getContactPoints());
- // Also warn if the configured DC doesn't match any node in the cluster
- if (!nodes.isEmpty()) {
- boolean found = false;
- for (Node node : nodes.values()) {
- if (localDc.get().equals(node.getDatacenter())) {
- found = true;
- break;
- }
- }
- if (!found) {
- LOG.warn(
- "[{}] Configured local DC '{}' does not match any node's datacenter"
- + " (available DCs: {}); please verify your configuration",
- logPrefix,
- localDc.get(),
- formatDcs(nodes.values()));
- }
- }
+ localDc = profile.getString(DefaultDriverOption.LOAD_BALANCING_LOCAL_DATACENTER);
+ LOG.debug("[{}] Local DC set from configuration: {}", logPrefix, localDc);
} else {
LOG.debug("[{}] Local DC not set, DC awareness will be disabled", logPrefix);
+ return Optional.empty();
}
- return localDc;
- }
-
- /**
- * Checks if the contact points are compatible with the local datacenter specified either through
- * configuration, or programmatically.
- *
- * The default implementation logs a warning when a contact point reports a datacenter
- * different from the local one, and only for the default profile.
- *
- * @param localDc The local datacenter, as specified in the config, or programmatically.
- * @param contactPoints The contact points provided when creating the session.
- */
- protected void checkLocalDatacenterCompatibility(
- @NonNull String localDc, Set extends Node> contactPoints) {
- if (profile.getName().equals(DriverExecutionProfile.DEFAULT_NAME)) {
- Set badContactPoints = new LinkedHashSet<>();
- for (Node node : contactPoints) {
- if (!Objects.equals(localDc, node.getDatacenter())) {
- badContactPoints.add(node);
+ if (!nodes.isEmpty()) {
+ boolean found = false;
+ for (Node node : nodes.values()) {
+ if (localDc.equals(node.getDatacenter())) {
+ found = true;
+ break;
}
}
- if (!badContactPoints.isEmpty()) {
+ if (!found) {
LOG.warn(
- "[{}] You specified {} as the local DC, but some contact points are from a different DC: {}; "
- + "please provide the correct local DC, or check your contact points",
+ "[{}] Configured local DC '{}' does not match any node's datacenter"
+ + " (available DCs: {}); please verify your configuration",
logPrefix,
localDc,
- formatNodesAndDcs(badContactPoints));
+ formatDcs(nodes.values()));
}
}
+ return Optional.of(localDc);
}
/**
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/AddNodeRefresh.java b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/AddNodeRefresh.java
index ac68b92fef2..c69ab18c1cb 100644
--- a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/AddNodeRefresh.java
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/AddNodeRefresh.java
@@ -55,7 +55,16 @@ public Result compute(
// If a node is restarted after changing its broadcast RPC address, Cassandra considers that
// an addition, even though the host_id hasn't changed :(
// Update the existing instance and emit an UP event to trigger a pool reconnection.
- if (!existing.getEndPoint().equals(newNodeInfo.getEndPoint())) {
+ //
+ // sameIdentity(), not equals(), for the reason DefaultNode#setEndPoint and
+ // ControlConnection's post-handshake check already use it: DefaultEndPoint#equals resolves
+ // the unresolved side of a mixed comparison, so a blocking DNS lookup would run here on the
+ // admin event loop. That is no longer hypothetical -- peers are unresolved names under a
+ // translator with resolve-addresses = false, while the control node's endpoint is resolved,
+ // so a NEW_NODE event for the control node is exactly such a mixed compare. The lookup's
+ // answer also depends on which address the resolver happens to list first, which would turn
+ // into a spurious suggestUp and a pool bounce.
+ if (!PinnableEndPoint.sameIdentity(newNodeInfo.getEndPoint(), existing.getEndPoint())) {
copyInfos(newNodeInfo, ((DefaultNode) existing), context);
assert newNodeInfo.getBroadcastRpcAddress().isPresent(); // always for peer nodes
return new Result(
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..b6bc8e52a88 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
@@ -20,19 +20,31 @@
import com.datastax.oss.driver.api.core.metadata.EndPoint;
import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.Nullable;
-import java.io.IOException;
-import java.io.UncheckedIOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.util.Objects;
import java.util.UUID;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ClientRoutesEndPoint implements PinnableEndPoint {
+
+ private static final Logger LOG = LoggerFactory.getLogger(ClientRoutesEndPoint.class);
-public class ClientRoutesEndPoint implements EndPoint {
private final UUID hostId;
private final ClientRoutesTopologyMonitor topologyMonitor;
private final String metricPrefix;
@NonNull private final EndPoint fallbackEndPoint;
+ /** Kept only so that {@link #pinTo(SocketAddress)} can rebuild an identical copy. */
+ @Nullable private final InetAddress broadcastInetAddress;
+
+ /**
+ * The address this endpoint has been {@linkplain #pinTo(SocketAddress) pinned} to, or {@code
+ * null} if it is not pinned. Deliberately excluded from {@link #equals} and {@link #hashCode},
+ * which key off the host id alone.
+ */
+ @Nullable private final InetSocketAddress pinnedAddress;
/**
* @param topologyMonitor the topology monitor used to resolve the endpoint address on demand.
@@ -49,12 +61,23 @@ public ClientRoutesEndPoint(
@NonNull UUID hostId,
@Nullable InetAddress broadcastInetAddress,
@NonNull EndPoint fallbackEndPoint) {
+ this(topologyMonitor, hostId, broadcastInetAddress, fallbackEndPoint, null);
+ }
+
+ private ClientRoutesEndPoint(
+ @NonNull ClientRoutesTopologyMonitor topologyMonitor,
+ @NonNull UUID hostId,
+ @Nullable InetAddress broadcastInetAddress,
+ @NonNull EndPoint fallbackEndPoint,
+ @Nullable InetSocketAddress pinnedAddress) {
this.topologyMonitor =
Objects.requireNonNull(topologyMonitor, "Topology monitor cannot be null");
this.hostId = Objects.requireNonNull(hostId, "HOST uuid cannot be null");
this.fallbackEndPoint =
Objects.requireNonNull(fallbackEndPoint, "Fallback endpoint cannot be null");
this.metricPrefix = buildMetricPrefix(broadcastInetAddress, hostId);
+ this.broadcastInetAddress = broadcastInetAddress;
+ this.pinnedAddress = pinnedAddress;
}
@NonNull
@@ -62,18 +85,124 @@ public UUID getHostId() {
return hostId;
}
+ /**
+ * The endpoint {@link #resolve()} falls back to when this node has no client route.
+ *
+ * Exposed so that {@link ClientRoutesTopologyMonitor#buildNodeEndPoint} can avoid nesting one
+ * of these inside another: for the {@code system.local} row the superclass hands back the control
+ * channel's own endpoint, which in a client-routes deployment is already a {@code
+ * ClientRoutesEndPoint} -- and a pinned one, so nesting it would freeze the fallback on
+ * one proxy IP and add a level per control reconnect.
+ */
+ @NonNull
+ EndPoint getFallbackEndPoint() {
+ return fallbackEndPoint;
+ }
+
+ /**
+ * Returns the address connections should be opened to.
+ *
+ *
The client route for this host id is an in-memory lookup over the cached {@code
+ * system.client_routes} contents, and it yields exactly one address by design, so this neither
+ * blocks nor expands to several candidates. The route's hostname is returned {@linkplain
+ * InetSocketAddress#isUnresolved() unresolved}: {@link
+ * com.datastax.oss.driver.internal.core.channel.ChannelFactory} resolves it through Netty's
+ * configured {@code AddressResolverGroup}, so a custom resolver is honoured and no DNS lookup
+ * runs on the caller (the admin event loop, for control-connection reconnects).
+ *
+ *
When the topology monitor has no route for this host id — i.e. the node is not reached
+ * through a cloud private endpoint — this delegates to the fallback endpoint.
+ *
+ *
Once {@linkplain #pinTo(SocketAddress) pinned} the pinned address is returned directly.
+ */
@NonNull
@Override
public SocketAddress resolve() {
+ if (pinnedAddress != null) {
+ return pinnedAddress;
+ }
+ InetSocketAddress address;
try {
- InetSocketAddress address = topologyMonitor.resolve(hostId);
- if (address != null) {
- return address;
- }
- } catch (IOException e) {
- throw new UncheckedIOException("DNS resolution failed for host_id=" + hostId, e);
+ address = topologyMonitor.resolve(hostId);
+ } catch (IllegalStateException e) {
+ // The monitor is closed, so its route cache is gone -- but resolve() still has to answer, and
+ // the honest answer is "no route available", which is what the fallback endpoint is for.
+ // Throwing here is not contained anywhere useful: PinnableEndPoint#sameIdentity compares
+ // resolve() results for every node of every topology refresh, and neither NodesRefresh nor
+ // MetadataManager#apply catches, so a refresh that raced session shutdown would be dropped
+ // whole and surface only as a DEBUG log in ControlConnection#onSuccessfulReconnect.
+ //
+ // Logged rather than swallowed silently: in a private-endpoint deployment the fallback is the
+ // node's raw broadcast address, which is not client-routable, so the visible symptom is a
+ // bare
+ // connect timeout with nothing naming the cause.
+ LOG.debug(
+ "[{}] Client routes monitor is closed, falling back to {} for this node",
+ hostId,
+ fallbackEndPoint);
+ address = null;
+ }
+ return address != null ? address : fallbackEndPoint.resolve();
+ }
+
+ @NonNull
+ @Override
+ public EndPoint pinTo(@NonNull SocketAddress resolvedAddress) {
+ Objects.requireNonNull(resolvedAddress, "resolvedAddress cannot be null");
+ // Mirror DefaultEndPoint: an address we cannot hold in an InetSocketAddress field skips
+ // pinning rather than failing the connection. So does an unresolved one -- resolve() hands out
+ // the route's hostname unresolved, and ChannelFactory passes it straight back when the user
+ // disabled the resolver or a custom one declines it. Pinning that would freeze the endpoint on
+ // a name that still re-expands on every connect: no address stability gained, and the route
+ // lookup silenced for good, since resolve() short-circuits once pinned.
+ if (!(resolvedAddress instanceof InetSocketAddress)
+ || ((InetSocketAddress) resolvedAddress).isUnresolved()
+ || resolvedAddress.equals(this.pinnedAddress)) {
+ return this;
+ }
+ return new ClientRoutesEndPoint(
+ topologyMonitor,
+ hostId,
+ broadcastInetAddress,
+ fallbackEndPoint,
+ (InetSocketAddress) resolvedAddress);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ *
{@code true} when the address came from a route: a route's addresses are alternative
+ * ways in to this one node, so connections may be spread across them.
+ *
+ *
Otherwise the address is the fallback endpoint's, and whether those addresses are
+ * interchangeable is not this class's to claim -- for the usual fallback, a {@code
+ * DefaultEndPoint} built from a translated broadcast address, it is {@code false}, and a
+ * translator that hands back a name ({@code SubnetAddressTranslator} does, under {@code
+ * resolve-addresses = false}) is exactly the case where spreading would land one node's channels
+ * on different hosts. So the question is deferred to whoever owns the address.
+ *
+ *
Which of the two it is is read off {@code resolvedAddress} rather than by asking the route
+ * cache a second time. The cache is an {@code AtomicReference} swapped from the routes-query
+ * thread, not from the one {@code ChannelFactory#connect} runs on, so a {@code
+ * CLIENT_ROUTES_CHANGE} landing between {@link #resolve()} and this call would otherwise have the
+ * two disagree -- and in the direction that matters, a route appearing after a fallback address
+ * was already chosen, the disagreement authorises shuffling exactly the kind of name this method
+ * exists to protect. Comparing against the fallback cannot race: {@code fallbackEndPoint} is
+ * final and its own {@code resolve()} is a field read.
+ */
+ @Override
+ public boolean addressesAreInterchangeable(@NonNull SocketAddress resolvedAddress) {
+ // Pinned: resolve() short-circuits on the pinned address, so that is what was handed out, and
+ // it denotes the single server this endpoint is now fixed to. Mirrored here so the pinned case
+ // does not consult the route cache at all -- resolve() has not done so since it was pinned.
+ if (pinnedAddress != null) {
+ return false;
}
- return fallbackEndPoint.resolve();
+ // resolve() returns either the route's address or the fallback's, so anything that is not the
+ // fallback's came from a route.
+ return !resolvedAddress.equals(fallbackEndPoint.resolve())
+ || (fallbackEndPoint instanceof PinnableEndPoint
+ && ((PinnableEndPoint) fallbackEndPoint).addressesAreInterchangeable(resolvedAddress));
}
@Override
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesTopologyMonitor.java b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesTopologyMonitor.java
index 1ffc35fd9f4..6d63508171e 100644
--- a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesTopologyMonitor.java
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesTopologyMonitor.java
@@ -21,6 +21,7 @@
import com.datastax.oss.driver.api.core.config.ClientRoutesConfig;
import com.datastax.oss.driver.api.core.config.DefaultDriverOption;
import com.datastax.oss.driver.api.core.metadata.EndPoint;
+import com.datastax.oss.driver.api.core.metadata.Node;
import com.datastax.oss.driver.internal.core.adminrequest.AdminRequestHandler;
import com.datastax.oss.driver.internal.core.adminrequest.AdminResult;
import com.datastax.oss.driver.internal.core.adminrequest.AdminRow;
@@ -32,9 +33,9 @@
import edu.umd.cs.findbugs.annotations.Nullable;
import java.net.InetAddress;
import java.net.InetSocketAddress;
-import java.net.UnknownHostException;
import java.time.Duration;
import java.util.ArrayList;
+import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
@@ -195,9 +196,18 @@ void setResolvedRoutes(Map routes) {
resolvedRoutesCache.set(Collections.unmodifiableMap(new HashMap<>(routes)));
}
+ /**
+ * Returns the client route for {@code hostId} as an {@linkplain InetSocketAddress#isUnresolved()
+ * unresolved} address, or {@code null} if this node has no route.
+ *
+ * The route's hostname is deliberately left unresolved: {@link
+ * com.datastax.oss.driver.internal.core.channel.ChannelFactory} resolves it through Netty's
+ * configured {@code AddressResolverGroup} at connection time. That keeps this method a pure
+ * in-memory cache lookup, so it is safe to call from an event loop, and it means a custom
+ * resolver applies to client routes just like it does to contact points.
+ */
@Nullable
- public InetSocketAddress resolve(@NonNull UUID hostId)
- throws IllegalStateException, UnknownHostException {
+ public InetSocketAddress resolve(@NonNull UUID hostId) throws IllegalStateException {
if (closed) {
throw new IllegalStateException("Topology monitor is closed");
}
@@ -206,7 +216,7 @@ public InetSocketAddress resolve(@NonNull UUID hostId)
return null; // no client route for this node — caller falls back to default
}
- return new InetSocketAddress(resolveAddress(route.getHostname()), route.getPort());
+ return InetSocketAddress.createUnresolved(route.getHostname(), route.getPort());
}
/**
@@ -323,6 +333,29 @@ private CompletionStage executeRefresh(
useSSL ? "tls_port" : "port");
continue;
}
+ // Range-checked here rather than trusted downstream: the route is turned into an
+ // address by InetSocketAddress.createUnresolved(), which throws
+ // IllegalArgumentException outside 0..65535 -- from inside
+ // ClientRoutesEndPoint.resolve(), i.e. from PinnableEndPoint#sameIdentity during
+ // NodesRefresh, which neither catches nor contains it. One bad row would then
+ // drop
+ // every topology refresh for as long as it stayed cached.
+ //
+ // Zero is rejected too, even though createUnresolved() accepts it: it is the
+ // "any port" sentinel, never something a node can be reached on. Letting it
+ // through would cache the row and fail every connection to that node with an
+ // opaque connect error instead of skipping it with the diagnostic below.
+ if (effectivePort <= 0 || effectivePort > 65535) {
+ LOG.error(
+ "[{}] Skipping client route for host_id={} ({}): "
+ + "port column ({}) is out of range: {}",
+ logPrefix,
+ hostId,
+ address,
+ useSSL ? "tls_port" : "port",
+ effectivePort);
+ continue;
+ }
// Apply connectionAddr override if configured for this connection_id
String connId =
@@ -467,6 +500,16 @@ protected EndPoint buildNodeEndPoint(
return super.buildNodeEndPoint(row, broadcastRpcAddress, localEndPoint);
}
EndPoint fallback = super.buildNodeEndPoint(row, broadcastRpcAddress, localEndPoint);
+ if (fallback instanceof ClientRoutesEndPoint) {
+ // The system.local row: the superclass hands back the control channel's own endpoint, which
+ // here is already one of these -- and a pinned one, since ChannelFactory binds the channel's
+ // endpoint to the address it reached. Nesting it would make this endpoint's route-less
+ // fallback a frozen proxy IP instead of a static address, and would add one level per control
+ // reconnect, each retaining a topology monitor and an O(depth) walk in resolve(). Take that
+ // instance's own fallback, which is the static endpoint the chain is supposed to bottom out
+ // at.
+ fallback = ((ClientRoutesEndPoint) fallback).getFallbackEndPoint();
+ }
InetAddress broadcastInetAddress = null;
if (broadcastRpcAddress != null) {
broadcastInetAddress = broadcastRpcAddress.getAddress();
@@ -480,6 +523,47 @@ protected EndPoint buildNodeEndPoint(
return new ClientRoutesEndPoint(this, hostId, broadcastInetAddress, fallback);
}
+ @Override
+ public boolean reresolvesNodeAddresses() {
+ // ClientRoutesEndPoint hands the route hostname over unresolved, so the connection layer
+ // re-expands it on every connection attempt -- but only when a route exists for that host_id
+ // (see ClientRoutesEndPoint#resolve()); for mixed/incomplete route sets it delegates to a
+ // static, already-resolved fallback endpoint instead. Only report true when every
+ // currently-known node actually has a live route; otherwise the contact-point reconnection
+ // fallback must stay available for the nodes stuck on that fallback.
+ //
+ // "Every known node" has to mean at least one: with an empty node set the loop below would
+ // report true vacuously, suppressing the contact-point fallback at the one moment it is the
+ // only
+ // way back -- before the first node refresh, or after the monitor has removed everything. (The
+ // caller happens to exempt an empty query plan as well, but that is a separate safety net and
+ // this must not depend on it.)
+ //
+ // The answer legitimately changes as route coverage does, so successive reconnection rounds can
+ // see different values: that tracks reality rather than flapping. The scan is O(nodes) and runs
+ // once per reconnection attempt, against an in-memory map.
+ //
+ // A closed monitor re-resolves nothing at all: resolve() throws IllegalStateException from the
+ // `closed` guard above, and ClientRoutesEndPoint#resolve() catches that and silently returns
+ // the static fallback endpoint. Every cached route is therefore inert, so answering from the
+ // cache alone would keep reporting true and suppress the contact-point fallback for any
+ // reconnection racing session shutdown.
+ if (closed) {
+ return false;
+ }
+ Collection nodes = context.getMetadataManager().getMetadata().getNodes().values();
+ if (nodes.isEmpty()) {
+ return false;
+ }
+ Map routes = resolvedRoutesCache.get();
+ for (Node node : nodes) {
+ if (!routes.containsKey(node.getHostId())) {
+ return false;
+ }
+ }
+ return true;
+ }
+
/**
* Builds the CQL query to fetch client routes.
*
@@ -645,13 +729,4 @@ public CompletionStage closeAsync() {
LOG.debug("[{}] ClientRoutesTopologyMonitor closed", logPrefix);
return super.closeAsync();
}
-
- /**
- * Resolves a hostname to an {@link InetAddress}. Extracted as a protected method so that unit
- * tests can override it to return stubbed addresses without hitting the network.
- */
- @NonNull
- protected InetAddress resolveAddress(@NonNull String hostname) throws UnknownHostException {
- return InetAddress.getByName(hostname);
- }
}
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/CloudTopologyMonitor.java b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/CloudTopologyMonitor.java
index 021824a9b16..5ad96b48dbb 100644
--- a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/CloudTopologyMonitor.java
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/CloudTopologyMonitor.java
@@ -32,7 +32,30 @@ public class CloudTopologyMonitor extends DefaultTopologyMonitor {
public CloudTopologyMonitor(InternalDriverContext context, InetSocketAddress cloudProxyAddress) {
super(context);
- this.cloudProxyAddress = cloudProxyAddress;
+ // Snapshot the proxy's host string once, here, instead of letting every buildNodeEndPoint()
+ // re-derive it. SniEndPoint stores the proxy address unresolved, and for a *resolved* input it
+ // does that by reading getHostString() -- which, when the InetSocketAddress was built from an
+ // InetAddress rather than from a name, renders that address's mutable, lazily-populated
+ // hostName field. Anything that calls getHostName() on the instance fills it in, and
+ // DefaultSslEngineFactory does exactly that under the default allow-dns-reverse-lookup-san, so
+ // every SniEndPoint built afterwards would get a different equals/hashCode/asMetricPrefix from
+ // the ones built before. Since this monitor rebuilds every node's endpoint on every topology
+ // refresh, that moves all of the cluster's per-node metrics at once, mid-session.
+ //
+ // Only that spelling drifts, and it is worth being precise about which, so nobody reads this
+ // guard as redundant and deletes it. new InetSocketAddress("proxy.example.com", 9042) is safe:
+ // getByName populates the InetAddress's hostName eagerly, so getHostString() answers the same
+ // string before and after any getHostName() call. new InetSocketAddress(
+ // InetAddress.getByAddress(bytes), 9042) is the one that moves, from the IP literal to whatever
+ // the reverse lookup finds. The bundle path never produces either -- CloudConfigFactory
+ // #getSniProxyAddress already returns createUnresolved(), which the branch below passes
+ // through untouched -- so what this protects is the programmatic
+ // SessionBuilder#withCloudProxyAddress, where the caller chooses the constructor.
+ this.cloudProxyAddress =
+ cloudProxyAddress.isUnresolved()
+ ? cloudProxyAddress
+ : InetSocketAddress.createUnresolved(
+ cloudProxyAddress.getHostString(), cloudProxyAddress.getPort());
}
@NonNull
@@ -44,4 +67,14 @@ protected EndPoint buildNodeEndPoint(
UUID hostId = Objects.requireNonNull(row.getUuid("host_id"));
return new SniEndPoint(cloudProxyAddress, hostId.toString());
}
+
+ @Override
+ public boolean reresolvesNodeAddresses() {
+ // Every node is reached through the cloud SNI proxy, and SniEndPoint hands the proxy hostname
+ // over unresolved, so the connection layer re-expands it on every connection attempt (see
+ // ChannelFactory#resolveCandidates). Addresses therefore stay current on their own: appending
+ // the original contact points as a DNS re-resolution fallback would add nothing, and could
+ // resurrect nodes this monitor has authoritatively removed.
+ return true;
+ }
}
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..a0dae6c7be4 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
@@ -18,29 +18,154 @@
package com.datastax.oss.driver.internal.core.metadata;
import com.datastax.oss.driver.api.core.metadata.EndPoint;
+import com.datastax.oss.driver.internal.core.util.AddressUtils;
+import com.datastax.oss.driver.shaded.guava.common.annotations.VisibleForTesting;
import edu.umd.cs.findbugs.annotations.NonNull;
+import edu.umd.cs.findbugs.annotations.Nullable;
import java.io.Serializable;
import java.net.InetSocketAddress;
+import java.net.SocketAddress;
import java.util.Objects;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
-public class DefaultEndPoint implements EndPoint, Serializable {
+public class DefaultEndPoint implements PinnableEndPoint, Serializable {
private static final long serialVersionUID = 1;
+ private static final Logger LOG = LoggerFactory.getLogger(DefaultEndPoint.class);
+
+ /** Static, so the warning below is emitted once per JVM rather than once per endpoint. */
+ @VisibleForTesting
+ static final AtomicBoolean LOGGED_MIXED_COMPARISON_WARNING = new AtomicBoolean();
+
private final InetSocketAddress address;
private final String metricPrefix;
+ /**
+ * The address this endpoint has been {@linkplain #pinTo(SocketAddress) pinned} to, or {@code
+ * null} if it is not pinned. Deliberately excluded from {@link #equals}, {@link #hashCode} and
+ * {@link #asMetricPrefix()}: a pinned copy denotes the same node as the original.
+ */
+ @Nullable private final InetSocketAddress pinnedAddress;
+
public DefaultEndPoint(InetSocketAddress address) {
+ this(address, null);
+ }
+
+ private DefaultEndPoint(InetSocketAddress address, @Nullable InetSocketAddress pinnedAddress) {
this.address = Objects.requireNonNull(address, "address can't be null");
this.metricPrefix = buildMetricPrefix(address);
+ this.pinnedAddress = pinnedAddress;
+ }
+
+ /**
+ * An endpoint identified by {@code identity} but connected to {@code target}:
+ * {@link #asMetricPrefix()}, {@link #equals} and {@link #toString()} answer for {@code identity},
+ * while {@link #resolve()} hands out {@code target}.
+ *
+ * The two differ in their host-name label only — {@code target} is the address a connection
+ * actually reached, {@code identity} is that same address with the label stripped (see {@code
+ * AddressUtils#stripHostName}). Splitting them is what lets {@code
+ * DefaultTopologyMonitor#buildNodeEndPoint} give the connected node an identity of its own
+ * without changing anything about how connections to it are made:
+ *
+ *
+ * - Identity from the bytes. A resolved address's host string is not fixed — it
+ * renders {@code InetAddress}'s cached {@code hostName}, which a TLS handshake fills in
+ * with a reverse-DNS name. Keying the node's metric prefix off it would make that prefix
+ * depend on whether TLS is enabled and on whether a PTR record happens to exist.
+ *
- Target keeps the label. {@code DefaultSslEngineFactory} derives the TLS peer host,
+ * and {@code DseGssApiAuthProviderBase} the Kerberos service name, from {@code resolve()}.
+ * A stripped address would send both to a reverse lookup on an event loop; keeping the
+ * label means they see the name the operator configured, with no lookup, exactly as they
+ * did before the node was re-identified.
+ *
+ *
+ * {@link #pinTo} cannot express this: a resolved {@code InetSocketAddress}'s equality ignores
+ * host names, so it would see {@code target} as the address already held and return {@code this}.
+ */
+ static DefaultEndPoint identifiedBy(InetSocketAddress identity, InetSocketAddress target) {
+ return new DefaultEndPoint(identity, target);
}
+ /**
+ * Returns the address connections should be opened to: the {@linkplain #pinTo(SocketAddress)
+ * pinned} one if this is a pinned copy, otherwise the stored address as-is.
+ *
+ *
This performs no name resolution. If the stored address is a hostname (i.e. {@linkplain
+ * InetSocketAddress#isUnresolved() unresolved} — contact points are always kept unresolved, see
+ * {@link com.datastax.oss.driver.api.core.session.SessionBuilder#addContactPoint}) it is returned
+ * unresolved, and {@link com.datastax.oss.driver.internal.core.channel.ChannelFactory} expands it
+ * to every IP it maps to through Netty's configured {@code AddressResolverGroup}. Resolving there
+ * rather than here is deliberate: it keeps any custom resolver installed via {@link
+ * com.datastax.oss.driver.internal.core.context.NettyOptions#afterBootstrapInitialized} in the
+ * loop, which a direct {@code InetAddress.getAllByName()} call from here would bypass, and it
+ * keeps this method non-blocking so it is safe to call from an event loop.
+ */
@NonNull
@Override
public InetSocketAddress resolve() {
- return address;
+ return pinnedAddress != null ? pinnedAddress : address;
}
+ @NonNull
+ @Override
+ public EndPoint pinTo(@NonNull SocketAddress resolvedAddress) {
+ Objects.requireNonNull(resolvedAddress, "resolvedAddress can't be null");
+ if (!(resolvedAddress instanceof InetSocketAddress)
+ // An unresolved address, as ClientRoutesEndPoint and SniEndPoint also refuse: this endpoint
+ // hands a hostname over unresolved and ChannelFactory passes it straight back when the user
+ // disabled the resolver or a custom one declines it. Pinning that would freeze resolve() on
+ // a name that must re-expand on every connect.
+ || ((InetSocketAddress) resolvedAddress).isUnresolved()
+ || resolvedAddress.equals(this.pinnedAddress)
+ // The address we already hold: pinning to it changes nothing, since resolve() and
+ // toString() would keep yielding what they already do. Returning this rather than an equal
+ // copy is load-bearing beyond sparing an allocation: {@code
+ // DefaultTopologyMonitor#connectedNodeEndPoint} keeps the control node's endpoint {@code
+ // ==}
+ // to the channel's, which is what lets {@code refreshNode}'s control-node check settle on
+ // the identity short-circuit in equals() instead of comparing addresses.
+ || resolvedAddress.equals(this.address)) {
+ return this;
+ }
+ return new DefaultEndPoint(address, (InetSocketAddress) resolvedAddress);
+ }
+
+ /**
+ * Whether {@code other} denotes the same node: the stored addresses are compared, ignoring which
+ * one either endpoint may be {@linkplain #pinTo(SocketAddress) pinned} to.
+ *
+ *
Comparing an unresolved name against a resolved address costs a DNS lookup,
+ * taken inline on the calling thread, because the unresolved side has to be resolved first. It is
+ * also arbitrary: {@code new InetSocketAddress(name, port)} keeps only the first address
+ * the name maps to, so for a multi-record name the answer is "equal iff this node is the one the
+ * resolver happened to list first". And it does not agree with {@link #hashCode()}, which keys on
+ * the stored address alone -- so a hostname and one of its IPs can be {@code equals} while
+ * hashing differently, and a hash-based collection of endpoints (the contact-point {@code Set},
+ * for one) never treats them as the same entry.
+ *
+ *
None of that applies when the unresolved side is an IP literal, which is the ordinary
+ * case rather than an exotic one: contact points are stored unresolved whatever their form (see
+ * {@code AddressUtils#extract}, which {@code SessionBuilder} always calls with {@code resolve =
+ * false}), so a plain {@code 1.2.3.4:9042} reaches this branch on every comparison against a
+ * resolved peer. Re-building it parses the literal with no resolver call, keeps the only address
+ * it can denote, and agrees with {@link #hashCode()}. The warning below is gated on {@link
+ * AddressUtils#carriesName} for exactly that reason -- it answers name-versus-literal without a
+ * lookup, and warning on a literal would report three hazards that are all false.
+ *
+ *
The branch exists for {@link
+ * com.datastax.oss.driver.api.core.metadata.Metadata#findNode(EndPoint)}, whose caller may hold
+ * either form. That includes a driver-internal one: {@code DefaultSchemaQueriesFactory} looks the
+ * channel's endpoint up on every schema refresh, and the two forms do meet there under an {@code
+ * AddressTranslator} that hands back a name -- {@code SubnetAddressTranslator} does, under its
+ * default {@code resolve-addresses = false} -- because the control node's endpoint is resolved
+ * while the peers' are not. A miss there is not fatal (the factory falls back to an arbitrary
+ * node), but it is a lookup per node per DDL. It warns once per JVM, as a canary for what still
+ * depends on it -- see https://github.com/scylladb/java-driver/issues/1006.
+ */
@Override
public boolean equals(Object other) {
if (other == this) {
@@ -48,12 +173,17 @@ public boolean equals(Object other) {
} else if (other instanceof DefaultEndPoint) {
InetSocketAddress thisAddress = this.address;
InetSocketAddress thatAddress = ((DefaultEndPoint) other).address;
- // If only one of the addresses is unresolved, resolve the other. Otherwise (both resolved or
- // both unresolved), compare as-is.
- if (thisAddress.isUnresolved() && !thatAddress.isUnresolved()) {
- thisAddress = new InetSocketAddress(thisAddress.getHostName(), thisAddress.getPort());
- } else if (thatAddress.isUnresolved() && !thisAddress.isUnresolved()) {
- thatAddress = new InetSocketAddress(thatAddress.getHostName(), thatAddress.getPort());
+ // If only one of the addresses is unresolved, resolve it. Otherwise (both resolved or both
+ // unresolved), compare as-is.
+ if (thisAddress.isUnresolved() != thatAddress.isUnresolved()) {
+ if (AddressUtils.carriesName(thisAddress.isUnresolved() ? thisAddress : thatAddress)) {
+ warnAboutMixedComparison(thisAddress, thatAddress);
+ }
+ if (thisAddress.isUnresolved()) {
+ thisAddress = new InetSocketAddress(thisAddress.getHostName(), thisAddress.getPort());
+ } else {
+ thatAddress = new InetSocketAddress(thatAddress.getHostName(), thatAddress.getPort());
+ }
}
return thisAddress.equals(thatAddress);
} else {
@@ -61,6 +191,18 @@ public boolean equals(Object other) {
}
}
+ private static void warnAboutMixedComparison(InetSocketAddress one, InetSocketAddress other) {
+ if (LOGGED_MIXED_COMPARISON_WARNING.compareAndSet(false, true)) {
+ LOG.warn(
+ "Compared an unresolved host name against a resolved endpoint address ({} vs {}). This"
+ + " performs a DNS lookup on the calling thread, only compares the first address the"
+ + " name maps to, and does not agree with hashCode(); see"
+ + " https://github.com/scylladb/java-driver/issues/1006. This message is logged once.",
+ one,
+ other);
+ }
+ }
+
@Override
public int hashCode() {
return address.hashCode();
@@ -68,6 +210,9 @@ public int hashCode() {
@Override
public String toString() {
+ // Deliberately identical for a pinned copy: see PinnableEndPoint. Which IP a given connection
+ // landed on is in the channel's own toString(), which Netty builds from the actual remote
+ // address.
return address.toString();
}
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/DefaultNode.java b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/DefaultNode.java
index 1b09c26ce16..16da0750f8f 100644
--- a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/DefaultNode.java
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/DefaultNode.java
@@ -102,15 +102,105 @@ public EndPoint getEndPoint() {
}
public void setEndPoint(@NonNull EndPoint newEndPoint, @NonNull InternalDriverContext context) {
- if (!newEndPoint.equals(endPoint)) {
- endPoint = newEndPoint;
- // metricUpdater is transient, so it can be null on deserialized nodes.
- NodeMetricUpdater previousMetricUpdater = metricUpdater;
- if (previousMetricUpdater != null
- && !(previousMetricUpdater instanceof NoopNodeMetricUpdater)) {
- metricUpdater = context.getMetricsFactory().newNodeUpdater(this);
- previousMetricUpdater.clearMetrics();
- }
+ // Nothing downstream can tell the two instances apart, so keep the one already held. Not merely
+ // an optimization: the instance this node holds may carry a reverse-DNS name cached on its
+ // InetAddress by an earlier TLS handshake (DefaultSslEngineFactory calls getHostName() under
+ // the
+ // default advanced.ssl-engine-factory.allow-dns-reverse-lookup-san = true), and every full
+ // topology refresh mints a brand-new endpoint over a brand-new InetSocketAddress for every
+ // node.
+ // Adopting each one unconditionally would throw that name away and make the next connection to
+ // the node repeat the blocking reverse lookup on a Netty I/O loop -- once per node per refresh
+ // instead of once per node per session, during exactly the refresh-then-reconnect storms this
+ // feature exists for.
+ //
+ // Deliberately not equals(): see PinnableEndPoint#sameIdentity, which is also what
+ // ControlConnection uses when it decides whether the control channel should adopt a node's
+ // endpoint.
+ if (PinnableEndPoint.sameIdentity(newEndPoint, endPoint)) {
+ return;
+ }
+
+ // Metrics are registered under names derived from the endpoint, so they have to be
+ // re-registered
+ // whenever those names change -- which is not the same question as whether this is a different
+ // node. It is narrower in one direction: a PinnableEndPoint copy differs from the original only
+ // by the address it is pinned to, and both equals() and the metric identity ignore that by
+ // contract (see PinnableEndPoint). And it is wider in the other: an unresolved hostname and the
+ // resolved address it maps to compare *equal* (see DefaultEndPoint#equals) while their metric
+ // prefixes differ, which is exactly what happens when a contact-point node adopts the endpoint
+ // built from its system.local row.
+ //
+ // asMetricPrefix() alone, deliberately, even though the tagging MetricIdGenerator tags metrics
+ // with the endpoint's toString() rather than its prefix. toString() cannot be used as an
+ // identity key because it is not stable across equal instances: DefaultEndPoint delegates it to
+ // InetSocketAddress, which renders InetAddress's *cached* hostName field, and that field is
+ // populated the first time anything calls getHostName(). DefaultSslEngineFactory does exactly
+ // that while building an engine, under the default advanced.ssl-engine-factory
+ // .allow-dns-reverse-lookup-san = true -- on the very instance this node holds, since an
+ // already-resolved endpoint is passed through unchanged by resolveCandidates() and pin(). So
+ // after the first channel this node's endpoint renders as "host/1.2.3.4:9042" while the one the
+ // next refresh decodes from system.peers renders as "/1.2.3.4:9042", and keying on that would
+ // clear and re-register every node's metrics on every topology refresh -- widening the
+ // clear/rebuild race described below from "once per endpoint change" to "always".
+ //
+ // The cost of leaving it out: a tagging generator can keep reporting under an endpoint string
+ // the node no longer answers to, until something else changes the prefix.
+ boolean differentMetricIdentity =
+ !newEndPoint.asMetricPrefix().equals(endPoint.asMetricPrefix());
+ // metricUpdater is transient, so it can be null on deserialized nodes.
+ NodeMetricUpdater previousMetricUpdater = metricUpdater;
+ boolean rebuildMetricUpdater =
+ differentMetricIdentity
+ && previousMetricUpdater != null
+ && !(previousMetricUpdater instanceof NoopNodeMetricUpdater);
+
+ // Clearing comes *before* the swap. Dropwizard and MicroProfile do not remember the ids they
+ // registered under; clearMetrics() recomputes each one from this node's current endpoint (see
+ // DropwizardMetricUpdater#clearMetrics and MetricIdGenerator#nodeMetricId). Clearing after the
+ // swap would therefore delete the series the new updater had just registered and leave the old
+ // ones behind, under a name nothing writes to any more. Micrometer removes the Meter instances
+ // it holds and does not care either way.
+ //
+ // The three steps are not atomic with respect to concurrent metric writes: metricUpdater is
+ // volatile and read from I/O threads, so a write landing between the clear and the rebuild
+ // goes through the updater that was just cleared, and Dropwizard re-registers on demand
+ // (getOrCreateCounterFor -> registry.counter(getMetricId(m))). That resurrects one series,
+ // named from whichever endpoint this node holds at that instant.
+ //
+ // Note the window is narrow but its effect is not transient: the resurrected metric is cached
+ // in the old updater's map, and ChannelFactory snapshots node.getMetricUpdater() once per
+ // connection and hands it to the traffic meters, which hold it for the channel's life. So a
+ // mark that lands here keeps reporting under the old endpoint's name until every channel open
+ // at that moment has been recycled. Nothing throws -- registry.counter() is get-or-create --
+ // and the request path re-reads getMetricUpdater() per request, so the misreporting is
+ // confined to the byte counters. Closing it properly means having clearMetrics() remove the
+ // ids it registered under rather than recomputing them from the current endpoint, which is a
+ // change to every metrics implementation and would also fix a second problem: two nodes can
+ // briefly share a metric prefix (a control node's endpoint is its contact point's), and then
+ // this clear deletes the series the other node just registered.
+ if (rebuildMetricUpdater) {
+ previousMetricUpdater.clearMetrics();
+ }
+
+ // Adopt the newest instance even when it compares equal: a pinned copy carries the address
+ // every
+ // subsequent connection to this node will use, so refusing it would freeze the node on the
+ // first
+ // address it ever connected to, even after the control connection moved to another one and told
+ // us about it. (The early return above lets through exactly the instances that differ in that
+ // address, or in metric identity, or in kind.)
+ endPoint = newEndPoint;
+
+ // And building comes *after* it: the updaters register every enabled metric from their
+ // constructor, deriving the names from the endpoint this node holds at that moment.
+ if (rebuildMetricUpdater) {
+ NodeMetricUpdater newMetricUpdater = context.getMetricsFactory().newNodeUpdater(this);
+ // Carry over any pending metrics expiration before publishing the replacement: the factories
+ // arm and cancel it through node.getMetricUpdater(), so from here on they would only ever
+ // reach the new one, leaving the old one's timer pending on an object nothing refers to.
+ newMetricUpdater.adoptExpirationFrom(previousMetricUpdater);
+ metricUpdater = newMetricUpdater;
}
}
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..bee3f6490b2 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
@@ -30,6 +30,7 @@
import com.datastax.oss.driver.internal.core.channel.DriverChannel;
import com.datastax.oss.driver.internal.core.context.InternalDriverContext;
import com.datastax.oss.driver.internal.core.control.ControlConnection;
+import com.datastax.oss.driver.internal.core.util.AddressUtils;
import com.datastax.oss.driver.internal.core.util.concurrent.CompletableFutures;
import com.datastax.oss.driver.shaded.guava.common.annotations.VisibleForTesting;
import com.datastax.oss.driver.shaded.guava.common.collect.ImmutableList;
@@ -352,22 +353,25 @@ public CompletionStage getChannelNodeInfo(DriverChannel channel) {
}
EndPoint localEndPoint = channel.getEndPoint();
return query(channel, buildQuery(localColumns, "system.local", "key='local'"))
- .thenApply(
- result -> {
- if (localColumns == null && !result.getColumnNames().isEmpty()) {
- localColumns =
- intersectWithNeeded(result.getColumnNames(), LOCAL_COLUMNS_OF_INTEREST);
- }
- Iterator iterator = result.iterator();
- if (!iterator.hasNext()) {
- throw new IllegalStateException(
- "Expected a row in system.local for node info resolution, got empty result");
- }
- AdminRow localRow = iterator.next();
- InetSocketAddress broadcastRpcAddress =
- getBroadcastRpcAddress(localRow, localEndPoint);
- return nodeInfoBuilder(localRow, broadcastRpcAddress, localEndPoint).build();
- });
+ .thenApply(result -> toLocalNodeInfo(result, localEndPoint));
+ }
+
+ /**
+ * Decodes the single {@code system.local} row of {@code result} into a {@link NodeInfo}, warming
+ * the local column cache from it on the way.
+ */
+ private NodeInfo toLocalNodeInfo(AdminResult result, EndPoint localEndPoint) {
+ if (localColumns == null && !result.getColumnNames().isEmpty()) {
+ localColumns = intersectWithNeeded(result.getColumnNames(), LOCAL_COLUMNS_OF_INTEREST);
+ }
+ Iterator iterator = result.iterator();
+ if (!iterator.hasNext()) {
+ throw new IllegalStateException(
+ "Expected a row in system.local for node info resolution, got empty result");
+ }
+ AdminRow localRow = iterator.next();
+ InetSocketAddress broadcastRpcAddress = getBroadcastRpcAddress(localRow, localEndPoint);
+ return nodeInfoBuilder(localRow, broadcastRpcAddress, localEndPoint).build();
}
@Override
@@ -659,8 +663,78 @@ protected EndPoint buildNodeEndPoint(
// Don't rely on system.local.rpc_address for the control node, because it mistakenly
// reports the normal RPC address instead of the broadcast one (CASSANDRA-11181). We
// already know the endpoint anyway since we've just used it to query.
+ return connectedNodeEndPoint(localEndPoint);
+ }
+ }
+
+ /**
+ * The endpoint to register the connected node under: the address the control channel actually
+ * reached, rather than the contact point it was reached through.
+ *
+ * They differ when the control connection came up through a contact point, because a contact
+ * point is kept unresolved and {@code ChannelFactory} binds a {@linkplain PinnableEndPoint
+ * pinned} copy of it to the one address the channel reached -- a copy that, by that interface's
+ * contract, is identified exactly like the unpinned original. Registering the node under it would
+ * give it a hostname identity: metric names and tags derived from a name that denotes the
+ * whole cluster rather than this node. That is bad enough on its own, but the real damage is that
+ * the identity is not the node's: the reconnection fallback hands the contact points back on
+ * every reconnection round, so each successive control node acquires the same one. Two live nodes
+ * then report under a single metric prefix, sharing get-or-create metric objects, until the next
+ * refresh moves the older one back to its own address -- and {@code clearMetrics()} recomputes
+ * the names to delete from the prefix the node still holds, taking the newcomer's freshly
+ * registered series with it (see {@code DefaultNode#setEndPoint}).
+ *
+ *
Deriving the identity from the address actually connected to fixes all of that at once: it
+ * is this node's own address, so it is unique to it, and re-registering an unchanged control node
+ * becomes a no-op instead of an identity change.
+ *
+ *
The identity comes from the connected address's bytes, not from its host string. That
+ * string is not the node's either -- for a hostname contact point it is the queried name, which
+ * every resolver attaches to what it returns and {@code ChannelFactory#reattachHostname} restores
+ * when a custom one does not, so reading it back here would produce the contact point's prefix
+ * again and this method would do nothing at all. It is also not stable: for an IP-literal contact
+ * point it starts out as the literal and begins reporting a reverse-DNS name as soon as {@code
+ * DefaultSslEngineFactory} calls {@code getHostName()} on the shared {@code InetAddress}, so an
+ * identity keyed off it would depend on whether TLS is enabled and whether a PTR record exists.
+ * Stripping the label (see {@link AddressUtils#stripHostName}) settles both.
+ *
+ *
What the node connects to is unaffected: the rebuilt endpoint still {@linkplain
+ * EndPoint#resolve() resolves} to the labelled address the channel reached, so the TLS peer host
+ * and the Kerberos service name stay the name the operator configured, with no reverse lookup --
+ * see {@link DefaultEndPoint#identifiedBy}.
+ *
+ *
Endpoints this cannot rebuild are returned untouched -- a third-party {@link EndPoint}, or
+ * one whose {@code resolve()} is not a resolved {@code InetSocketAddress} (the user disabled
+ * Netty's resolver, or a custom one declined the address, so nothing was pinned). So is one that
+ * already carries the connected address, which is every reconnection to an identified node and
+ * every refresh after the first: the existing instance is kept so that the control node's
+ * endpoint stays {@code ==} to the channel's, which is what lets {@link #refreshNode}'s
+ * control-node check settle on the identity short-circuit in {@code equals()}. Where that does
+ * not hold -- the channel kept an unresolved endpoint because adoption was skipped -- the check
+ * falls through to a full address comparison, which for a name costs a lookup on the admin
+ * thread and only answers "equal" if the resolver lists the reached address first. A miss there
+ * is not fatal, but it does send refreshNode on to query the peers table for the control node's
+ * own address, which by definition has no row; see
+ * https://github.com/scylladb/java-driver/issues/1006.
+ */
+ private static EndPoint connectedNodeEndPoint(EndPoint localEndPoint) {
+ if (!(localEndPoint instanceof DefaultEndPoint)) {
+ return localEndPoint;
+ }
+ SocketAddress connected = localEndPoint.resolve();
+ if (!(connected instanceof InetSocketAddress)
+ || ((InetSocketAddress) connected).isUnresolved()) {
+ return localEndPoint;
+ }
+ InetSocketAddress reached = (InetSocketAddress) connected;
+ InetSocketAddress identity = AddressUtils.stripHostName(reached);
+ if (identity == null) {
return localEndPoint;
}
+ DefaultEndPoint asConnected = DefaultEndPoint.identifiedBy(identity, reached);
+ return asConnected.asMetricPrefix().equals(localEndPoint.asMetricPrefix())
+ ? localEndPoint
+ : asConnected;
}
// Called when a new node is being added; the peers table is keyed by broadcast_address,
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/LoadBalancingPolicyWrapper.java b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/LoadBalancingPolicyWrapper.java
index f3f3e4fe346..fd11f4c902f 100644
--- a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/LoadBalancingPolicyWrapper.java
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/LoadBalancingPolicyWrapper.java
@@ -27,6 +27,8 @@
import com.datastax.oss.driver.api.core.session.Request;
import com.datastax.oss.driver.api.core.session.Session;
import com.datastax.oss.driver.internal.core.context.InternalDriverContext;
+import com.datastax.oss.driver.internal.core.util.collection.CompositeQueryPlan;
+import com.datastax.oss.driver.internal.core.util.collection.SimpleQueryPlan;
import com.datastax.oss.driver.internal.core.util.concurrent.ReplayingEventFilter;
import com.datastax.oss.driver.shaded.guava.common.collect.ImmutableMap;
import com.datastax.oss.driver.shaded.guava.common.collect.ImmutableSet;
@@ -147,7 +149,9 @@ public Queue newQueryPlan(
switch (stateRef.get()) {
case BEFORE_INIT:
case DURING_INIT:
- // The contact points are not stored in the metadata yet:
+ // The contact points are not stored in the metadata yet. Each unresolved hostname is
+ // expanded to all its DNS IPs at connection time by ChannelFactory, so one entry per
+ // contact point is enough here.
List nodes = new ArrayList<>(context.getMetadataManager().getContactPoints());
Collections.shuffle(nodes);
return new ConcurrentLinkedQueue<>(nodes);
@@ -164,20 +168,69 @@ public Queue newQueryPlan(
@NonNull
public Queue newControlReconnectionQueryPlan() {
+ // Read the state once, before building the regular plan. State transitions are monotonic
+ // (BEFORE_INIT -> DURING_INIT -> RUNNING -> ...), so this captured value is <= the value
+ // newQueryPlan() reads internally; that guarantees we never both build the plan from the
+ // contact points (pre-RUNNING branch of newQueryPlan) and append them again below.
+ //
+ // Note: this is still two separate reads of stateRef (this one, and newQueryPlan()'s own
+ // internal read a moment later), so a transition landing exactly between them is possible: if
+ // state flips BEFORE_INIT/DURING_INIT -> RUNNING in that window, newQueryPlan() takes the
+ // RUNNING branch (a real LBP-built plan) while the state captured here is still pre-RUNNING,
+ // so the contact-point fallback below is skipped for this one call even though
+ // regularQueryPlan didn't come from the contact-point branch. This is benign: no crash, no
+ // duplicate entries, and it self-corrects on the very next reconnection attempt.
+ //
+ // Monotonicity leaves the other direction open, and it is worth naming: a RUNNING -> CLOSING
+ // flip in that same window makes newQueryPlan() take its default branch and return an empty
+ // plan, which passes both the RUNNING check below and the empty-plan exemption from the
+ // re-resolving-monitor rule, so the plan handed back is the contact points alone. Also benign:
+ // ControlConnection abandons a reconnection attempt on closeWasCalled, and every node in that
+ // plan is one it already had.
+ State state = stateRef.get();
Queue regularQueryPlan = newQueryPlan(null, DriverExecutionProfile.DEFAULT_NAME, null);
- if (context
- .getConfig()
- .getDefaultProfile()
- .getBoolean(DefaultDriverOption.CONTROL_CONNECTION_RECONNECT_CONTACT_POINTS)) {
- Set originalNodes = context.getMetadataManager().getContactPoints();
- List contactNodes = new ArrayList<>();
- for (DefaultNode node : originalNodes) {
- contactNodes.add(DefaultNode.newContactPoint(node.getEndPoint(), context));
- }
+ // Only append the contact points as an explicit fallback once the LBP is RUNNING: before that
+ // (BEFORE_INIT/DURING_INIT), newQueryPlan() above already built regularQueryPlan directly from
+ // the contact points, so appending them again here would just duplicate every entry.
+ //
+ // Skipped when the topology monitor re-resolves node addresses on its own (e.g. proxy-based
+ // monitors such as client routes or the cloud SNI proxy): those keep addresses fresh without
+ // this fallback, and appending raw contact points could resurrect nodes the monitor has
+ // authoritatively removed. The exception is an empty regular plan: with no live node to try,
+ // reconnection cannot recover on its own, so the contact-point fallback is kept even for those
+ // monitors.
+ if (state == State.RUNNING
+ && context
+ .getConfig()
+ .getDefaultProfile()
+ .getBoolean(DefaultDriverOption.CONTROL_CONNECTION_RECONNECT_CONTACT_POINTS)
+ && (!context.getTopologyMonitor().reresolvesNodeAddresses()
+ || regularQueryPlan.isEmpty())) {
+ // Append the original (unresolved) contact points so every IP their hostname resolves to is
+ // tried as a fallback: ChannelFactory expands each one at connection time, instead of the
+ // driver being stuck with whatever single IP a metadata node happens to hold.
+ //
+ // The retained instances, not fresh copies. MetadataManager holds the contact-point nodes for
+ // the session's lifetime and the pre-RUNNING branch of newQueryPlan() already hands out these
+ // very objects, so minting a copy per plan would give each reconnection round a distinct node
+ // firing its own controlConnectionFailed event -- one set per round, for as long as
+ // reconnection lasts. Shuffling a fresh list leaves the retained set itself untouched.
+ //
+ // Metrics are not a reason either way, and are worth stating because it looks as though they
+ // should be: DefaultNode.newContactPoint installs NoopNodeMetricUpdater, so a contact-point
+ // node records nothing -- no bytes, no errors.connection.init, no errors.connection.auth --
+ // and a fresh copy would be no worse. Now that this fallback is on by default, that blind
+ // spot covers every reconnect that reaches a contact point rather than only session init, so
+ // an operator watching errors.connection.auth will not see a contact point failing to
+ // authenticate. Giving these nodes real updaters would register metrics under names for
+ // ephemeral objects that are deliberately absent from metadata, so it is left as is.
+ List contactNodes = new ArrayList<>(context.getMetadataManager().getContactPoints());
Collections.shuffle(contactNodes);
- // Append contact points to the end of the regular query plan so they serve as a fallback
- regularQueryPlan.addAll(contactNodes);
+ // 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()));
}
return regularQueryPlan;
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/MetadataManager.java b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/MetadataManager.java
index cd765c818e6..d8671678306 100644
--- a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/MetadataManager.java
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/MetadataManager.java
@@ -188,6 +188,12 @@ public boolean wasImplicitContactPoint() {
* they are never added to metadata and never exposed to user-facing APIs (events, {@link
* com.datastax.oss.driver.api.core.metadata.Metadata#getNodes()}, or {@link
* com.datastax.oss.driver.api.core.metadata.NodeStateListener} callbacks).
+ *
+ * The metadata node stores {@code nodeInfo.getEndPoint()} as-is and never re-resolves it on
+ * its own. Re-resolving the original contact-point hostname to pick up current DNS only happens
+ * through the original-contact-point reconnection fallback (see {@code
+ * advanced.control-connection.reconnection.fallback-to-original-contact-points}), which re-enters
+ * the contact points and lets {@code ChannelFactory} expand each hostname at connection time.
*/
public CompletionStage registerNode(NodeInfo nodeInfo) {
Preconditions.checkNotNull(nodeInfo.getHostId(), "Cannot register node without hostId");
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/PinnableEndPoint.java b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/PinnableEndPoint.java
new file mode 100644
index 00000000000..ca1acb5d9a9
--- /dev/null
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/PinnableEndPoint.java
@@ -0,0 +1,163 @@
+/*
+ * 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 com.datastax.oss.driver.api.core.metadata.EndPoint;
+import edu.umd.cs.findbugs.annotations.NonNull;
+import java.net.SocketAddress;
+import java.util.Objects;
+
+/**
+ * An {@link EndPoint} that can produce a copy of itself bound ("pinned") to one specific address.
+ *
+ * An endpoint whose hostname maps to several IPs describes a set of candidate addresses,
+ * but a channel is always connected to exactly one of them. {@link
+ * com.datastax.oss.driver.internal.core.channel.ChannelFactory} pins the endpoint to the address it
+ * actually used, and hands the pinned copy to the channel. That matters for two reasons:
+ *
+ *
+ * - Node identity. Once the driver has learnt, over a given connection, that {@code
+ * host_id} X answers at a given IP, that node must keep reconnecting to that IP. If
+ * the node kept the multi-address endpoint, a later reconnect could land on a different node
+ * while still being treated as X (see {@code DefaultTopologyMonitor#buildNodeEndPoint} and
+ * {@code ControlConnection}, which skip identity re-resolution for nodes that already have a
+ * host id).
+ *
- No re-resolution on the channel path. Components handed the channel's endpoint call
+ * {@link EndPoint#resolve()} — SSL engine creation, GSSAPI service-name lookup, {@code
+ * DefaultTopologyMonitor#savePort}. On a pinned endpoint that is a field read, so it neither
+ * blocks on DNS (SSL setup runs on a Netty event loop) nor risks picking a different address
+ * than the one the channel is connected to.
+ *
+ *
+ * This is an internal extension point: {@code ChannelFactory} pins endpoints that implement it
+ * and leaves any other implementation untouched, so third-party {@link EndPoint}s keep working
+ * exactly as before.
+ *
+ *
Implementations must keep {@link Object#equals}, {@link Object#hashCode}, {@link
+ * EndPoint#asMetricPrefix()} and {@link Object#toString()} identical to the unpinned
+ * original: a pinned copy denotes the same node, and every one of those is part of how the node is
+ * identified from the outside. Metric names in particular must not change depending on which IP a
+ * connection happened to land on — and that includes {@code toString()}, which is what {@code
+ * TaggingMetricIdGenerator} tags node metrics with, and what any third-party {@code
+ * MetricIdGenerator} is equally free to use. Nodes do adopt pinned copies (see {@code
+ * DefaultNode#setEndPoint}), so an identity that varied with the pin would silently re-tag a node's
+ * metrics mid-session. Equality must also stay symmetric: {@code original.equals(pinned)} and
+ * {@code pinned.equals(original)} must agree, since endpoints are used as set and map keys.
+ *
+ *
The pinned address is therefore observable only through {@link EndPoint#resolve()}. That is no
+ * loss for diagnostics: the address a channel is actually connected to appears in the channel's own
+ * {@code toString()}, which Netty builds from its remote address, and {@code ChannelFactory} logs
+ * each candidate as it tries it.
+ */
+public interface PinnableEndPoint extends EndPoint {
+
+ /**
+ * Returns a copy of this endpoint that resolves to exactly {@code resolvedAddress}.
+ *
+ *
Implementations may return {@code this} when pinning does not apply (for example when the
+ * address is not of a type they can hold on to), or when it would be a no-op because the endpoint
+ * already resolves to exactly that address.
+ *
+ * @param resolvedAddress the address a connection was successfully established to; must not be
+ * null and must already be resolved.
+ */
+ @NonNull
+ EndPoint pinTo(@NonNull SocketAddress resolvedAddress);
+
+ /**
+ * Whether the addresses this endpoint expands to are interchangeable, i.e. reaching any one of
+ * them is reaching the same node.
+ *
+ *
This is what decides whether {@code ChannelFactory} may spread connections across them. The
+ * question is a property of what the name denotes, and it splits the name-based endpoints
+ * in two:
+ *
+ *
+ * - A front door — an SNI proxy, a cloud private-endpoint route — publishes several
+ * addresses that all lead to the same node by construction: the proxy routes by server
+ * name, not by which of its own IPs the client picked. Spreading across them is the whole
+ * point of publishing more than one, and it is what the driver did before multi-address
+ * support, when {@code SniEndPoint#resolve()} rotated through the proxy's A-records on
+ * every call.
+ *
- A name supplied by an {@code AddressTranslator} ({@code SubnetAddressTranslator} returns
+ * one by default, under {@code resolve-addresses = false}) carries no such guarantee: it
+ * may cover several hosts. Spreading one node's connections across those would land the
+ * channels of a single {@code Node} on different servers, while routing, shard awareness
+ * and per-node metrics all attribute them to that one node. Such an endpoint keeps the
+ * resolver's order, so a pool converges on one address and the rest serve as fallback.
+ *
+ *
+ * Only consulted for a node the driver has already identified. A contact point is spread
+ * across its addresses regardless, since they may well be different nodes and there is no node
+ * identity to preserve yet.
+ *
+ *
The answer must be derived from {@code resolvedAddress} — what {@link #resolve()} just
+ * returned for this connect — and not by asking the same source again. An endpoint whose answer
+ * comes from mutable state consulted twice can be asked on either side of a change and give two
+ * answers that describe different addresses, and the one that matters is the address actually
+ * about to be dialled. {@code ClientRoutesEndPoint} is the case in point: a route appearing
+ * between the two reads would authorise spreading for an address that came from its
+ * fallback endpoint, which is the one thing this method exists to prevent.
+ *
+ * @param resolvedAddress the address {@link #resolve()} returned for this connect attempt.
+ */
+ default boolean addressesAreInterchangeable(@NonNull SocketAddress resolvedAddress) {
+ return false;
+ }
+
+ /**
+ * Whether two endpoints denote the same node and are indistinguishable to everything that
+ * reads one: same runtime type, same {@linkplain EndPoint#asMetricPrefix() metric identity}, same
+ * {@linkplain EndPoint#resolve() current address}.
+ *
+ *
Deliberately not {@link Object#equals}: {@code DefaultEndPoint#equals} resolves the
+ * unresolved side of a mixed comparison, which would put a blocking DNS lookup on the admin
+ * thread for every endpoint that is still a hostname — and contact points are now kept
+ * unresolved, so that is reachable (see issue #1006). It is also narrower than {@code equals} in
+ * one direction and wider in another, which is exactly what callers need:
+ *
+ *
+ * - Narrower: a pinned copy differs from its original only by the pin, and both {@code
+ * equals} and the metric identity ignore that by contract (above), so the {@code resolve()}
+ * comparison is what tells the two apart.
+ *
- Wider: an unresolved hostname and the address it maps to compare equal under
+ * {@code DefaultEndPoint#equals} while their metric prefixes differ — the case a
+ * contact-point node hits when it adopts the endpoint built from its {@code system.local}
+ * row.
+ *
+ *
+ * The class check keeps a node from staying on a plain fallback endpoint when a dynamic one
+ * ({@code ClientRoutesEndPoint}) with the same current address arrives.
+ *
+ *
{@code toString()} is not part of the test, even though {@code TaggingMetricIdGenerator}
+ * tags metrics with it rather than with the prefix. It is not stable across equal instances:
+ * {@code DefaultEndPoint} delegates it to {@code InetSocketAddress}, which renders {@code
+ * InetAddress}'s cached {@code hostName} field, and that field is populated the first time
+ * anything calls {@code getHostName()} — which {@code DefaultSslEngineFactory} does while
+ * building an engine, under the default {@code
+ * advanced.ssl-engine-factory.allow-dns-reverse-lookup-san = true}. Keying on it would report a
+ * difference for every node on every topology refresh. The cost of leaving it out: a tagging
+ * generator can keep reporting under an endpoint string the node no longer answers to, until
+ * something else changes the prefix.
+ */
+ static boolean sameIdentity(@NonNull EndPoint first, @NonNull EndPoint second) {
+ return first.getClass() == second.getClass()
+ && first.asMetricPrefix().equals(second.asMetricPrefix())
+ && Objects.equals(first.resolve(), second.resolve());
+ }
+}
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..dd32dcf16b5 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
@@ -18,61 +18,143 @@
package com.datastax.oss.driver.internal.core.metadata;
import com.datastax.oss.driver.api.core.metadata.EndPoint;
-import com.datastax.oss.driver.shaded.guava.common.primitives.UnsignedBytes;
import edu.umd.cs.findbugs.annotations.NonNull;
-import java.net.InetAddress;
+import edu.umd.cs.findbugs.annotations.Nullable;
import java.net.InetSocketAddress;
-import java.net.UnknownHostException;
-import java.util.Arrays;
-import java.util.Comparator;
+import java.net.SocketAddress;
import java.util.Objects;
-import java.util.concurrent.atomic.AtomicInteger;
-public class SniEndPoint implements EndPoint {
- private static final AtomicInteger OFFSET = new AtomicInteger();
+public class SniEndPoint implements PinnableEndPoint {
private final InetSocketAddress proxyAddress;
private final String serverName;
/**
- * @param proxyAddress the address of the proxy. If it is {@linkplain
- * InetSocketAddress#isUnresolved() unresolved}, each call to {@link #resolve()} will
- * re-resolve it, fetch all of its A-records, and if there are more than 1 pick one in a
- * round-robin fashion.
+ * The proxy IP this endpoint has been {@linkplain #pinTo(SocketAddress) pinned} to, or {@code
+ * null} if it is not pinned. Deliberately excluded from {@link #equals} and {@link #hashCode}: a
+ * pinned copy denotes the same node as the original.
+ */
+ @Nullable private final InetSocketAddress pinnedAddress;
+
+ /**
+ * @param proxyAddress the address of the proxy. Stored {@linkplain
+ * InetSocketAddress#isUnresolved() unresolved}, whatever form it was supplied in, so that the
+ * driver expands a proxy hostname to all of its A-records at connection time and tries each
+ * of them — see {@link #storeUnresolved}.
* @param serverName the SNI server name. In the context of Cloud, this is the string
* representation of the host id.
*/
public SniEndPoint(InetSocketAddress proxyAddress, String serverName) {
- this.proxyAddress = Objects.requireNonNull(proxyAddress, "SNI address cannot be null");
+ this(proxyAddress, serverName, null);
+ }
+
+ private SniEndPoint(
+ InetSocketAddress proxyAddress,
+ String serverName,
+ @Nullable InetSocketAddress pinnedAddress) {
+ this.proxyAddress =
+ storeUnresolved(Objects.requireNonNull(proxyAddress, "SNI address cannot be null"));
this.serverName = Objects.requireNonNull(serverName, "SNI Server name cannot be null");
+ this.pinnedAddress = pinnedAddress;
+ }
+
+ /**
+ * Stores the proxy address unresolved, whatever form it arrived in.
+ *
+ *
{@link #resolve()} hands the stored address to the connection layer as-is, and only an
+ * unresolved one gets expanded and re-expanded there. A proxy hostname supplied already resolved
+ * would therefore stay bound to whichever single IP its lookup happened to return, for the life
+ * of the session: no spreading across the proxy's A-records, no fallback when that one IP stops
+ * answering, and no pick-up of a DNS change. That is a real possibility for a hostname handed to
+ * {@link
+ * com.datastax.oss.driver.api.core.session.SessionBuilder#withCloudProxyAddress(InetSocketAddress)},
+ * because the ordinary {@code InetSocketAddress(String, int)} constructor resolves eagerly.
+ * ({@code CloudConfigFactory}, the usual path, already builds an unresolved address.)
+ *
+ *
An address that is already an IP literal is stored unresolved too, even though it has
+ * nothing to expand, because that is what makes this endpoint's identity stable. A
+ * resolved address's {@code getHostString()} is not fixed: it starts out as the IP literal and
+ * begins reporting the reverse-DNS name as soon as anything calls {@code getHostName()} on the
+ * underlying {@code InetAddress} — which {@code SniSslEngineFactory#newSslEngine} does, on this
+ * very instance, under the default {@code
+ * advanced.ssl-engine-factory.allow-dns-reverse-lookup-san = true}. Keying {@link #equals} and
+ * {@link #asMetricPrefix()} off a string that can change underneath them would move a node's
+ * metrics mid-session and make endpoints built before and after the first TLS handshake compare
+ * unequal. An unresolved address has no such field to fill in: its host string is fixed at
+ * construction, and {@code getHostName()} on it performs no lookup. SNI's reverse lookup still
+ * happens, on the {@linkplain #pinTo(SocketAddress) pinned} copy that carries the IP the channel
+ * actually reached.
+ *
+ *
What this cannot defend against is an instance the caller polluted before handing it over,
+ * i.e. called {@code getHostName()} on themselves.
+ *
+ *
Normalizing here rather than at the call site keeps every {@code SniEndPoint} built from the
+ * same proxy comparable — {@link #equals} keys on this field — and matches what this endpoint did
+ * before resolution moved to the connection layer, when it re-resolved the proxy hostname on
+ * every {@code resolve()} call.
+ */
+ private static InetSocketAddress storeUnresolved(InetSocketAddress proxyAddress) {
+ return proxyAddress.isUnresolved()
+ ? proxyAddress
+ : InetSocketAddress.createUnresolved(proxyAddress.getHostString(), proxyAddress.getPort());
}
public String getServerName() {
return serverName;
}
+ /**
+ * Returns the proxy address connections should be opened to.
+ *
+ *
Unpinned, this is the stored proxy address as-is — always unresolved (see {@link
+ * #storeUnresolved}), which {@link com.datastax.oss.driver.internal.core.channel.ChannelFactory}
+ * expands to every proxy A-record, trying each in turn — so a single unreachable proxy IP no
+ * longer fails the connection. Re-resolving here instead would block whichever event loop called
+ * us, and would bypass a custom Netty resolver.
+ *
+ *
Once {@linkplain #pinTo(SocketAddress) pinned} this returns that one proxy IP. That is what
+ * {@link com.datastax.oss.driver.internal.core.ssl.SniSslEngineFactory#newSslEngine} sees: it
+ * runs inside Netty's channel initializer, so it gets the exact IP the channel is connected to
+ * without a lookup on the event loop.
+ */
@NonNull
@Override
public InetSocketAddress resolve() {
- try {
- InetAddress[] aRecords = InetAddress.getAllByName(proxyAddress.getHostName());
- if (aRecords.length == 0) {
- // Probably never happens, but the JDK docs don't explicitly say so
- throw new IllegalArgumentException(
- "Could not resolve proxy address " + proxyAddress.getHostName());
- }
- // The order of the returned address is unspecified. Sort by IP to make sure we get a true
- // round-robin
- Arrays.sort(aRecords, IP_COMPARATOR);
- int index =
- (aRecords.length == 1)
- ? 0
- : OFFSET.getAndUpdate(x -> x == Integer.MAX_VALUE ? 0 : x + 1) % aRecords.length;
- return new InetSocketAddress(aRecords[index], proxyAddress.getPort());
- } catch (UnknownHostException e) {
- throw new IllegalArgumentException(
- "Could not resolve proxy address " + proxyAddress.getHostName(), e);
+ return pinnedAddress != null ? pinnedAddress : proxyAddress;
+ }
+
+ @NonNull
+ @Override
+ public EndPoint pinTo(@NonNull SocketAddress resolvedAddress) {
+ Objects.requireNonNull(resolvedAddress, "resolvedAddress cannot be null");
+ // Mirrors DefaultEndPoint and ClientRoutesEndPoint: an address this endpoint cannot hold in an
+ // InetSocketAddress field skips pinning rather than failing the connection, and so does an
+ // unresolved one. resolve() hands the proxy address over unresolved, and ChannelFactory passes
+ // it straight back when the user disabled the resolver or a custom one declines it; pinning
+ // that would freeze this endpoint on a name that must re-expand on every connect -- no address
+ // stability gained, and the proxy's A-record fallback silenced for good.
+ if (!(resolvedAddress instanceof InetSocketAddress)
+ || ((InetSocketAddress) resolvedAddress).isUnresolved()
+ || resolvedAddress.equals(this.pinnedAddress)) {
+ return this;
}
+ return new SniEndPoint(proxyAddress, serverName, (InetSocketAddress) resolvedAddress);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ *
{@code true}: the proxy routes by server name, so every one of its A-records reaches this
+ * same node, and connections may be spread across them. That restores what this endpoint did
+ * itself before resolution moved to the connection layer, when {@code resolve()} sorted the proxy
+ * A-records and rotated through them on every call.
+ *
+ *
The address is not consulted: there is only ever one source here — the proxy — so every
+ * address this endpoint can hand out has the same answer.
+ */
+ @Override
+ public boolean addressesAreInterchangeable(@NonNull SocketAddress resolvedAddress) {
+ return true;
}
@Override
@@ -94,10 +176,10 @@ public int hashCode() {
@Override
public String toString() {
- // Note that this uses the original proxy address, so if there are multiple A-records it won't
- // show which one was selected. If that turns out to be a problem for debugging, we might need
- // to store the result of resolve() in Connection and log that instead of the endpoint.
- return proxyAddress.toString() + ":" + serverName;
+ // Deliberately identical for a pinned copy: see PinnableEndPoint. Which proxy IP a given
+ // connection landed on is in the channel's own toString(), which Netty builds from the actual
+ // remote address.
+ return proxyAddress + ":" + serverName;
}
@NonNull
@@ -110,10 +192,4 @@ public String asMetricPrefix() {
}
return hostString.replace('.', '_') + ':' + proxyAddress.getPort() + '_' + serverName;
}
-
- @SuppressWarnings("UnnecessaryLambda")
- private static final Comparator IP_COMPARATOR =
- (InetAddress address1, InetAddress address2) ->
- UnsignedBytes.lexicographicalComparator()
- .compare(address1.getAddress(), address2.getAddress());
}
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/TopologyMonitor.java b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/TopologyMonitor.java
index 1bb8e343d96..b51392d4eab 100644
--- a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/TopologyMonitor.java
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/TopologyMonitor.java
@@ -141,4 +141,45 @@ public interface TopologyMonitor extends AsyncAutoCloseable {
* {@link DefaultTopologyMonitor}) should override this method.
*/
default void resetColumnCaches() {}
+
+ /**
+ * Whether this monitor re-resolves node addresses dynamically on every connection attempt (for
+ * example by re-resolving a proxy hostname each time), rather than relying on an endpoint address
+ * captured once at node-registration time.
+ *
+ * When this returns {@code true}, the control connection's reconnection query plan must not
+ * append the original contact points as a DNS re-resolution fallback (see {@code
+ * advanced.control-connection.reconnection.fallback-to-original-contact-points}): the monitor
+ * already keeps addresses fresh, and appending raw contact points could resurrect nodes that the
+ * monitor has authoritatively removed.
+ *
+ *
The default implementation returns {@code false}, which is correct for {@link
+ * DefaultTopologyMonitor}: the peer nodes it registers hold a {@code DefaultEndPoint} built from
+ * the broadcast RPC address in {@code system.peers}, an already-resolved physical IP that never
+ * needs re-resolving.
+ *
+ *
Unless the configured {@code AddressTranslator} hands back a name -- {@code
+ * SubnetAddressTranslator} does, since its {@code resolve-addresses} option defaults to {@code
+ * false}. Such a peer endpoint is re-expanded per connection attempt by {@code
+ * ChannelFactory}, and if that name maps to more than one host, one {@code Node}'s connections
+ * can land on different ones while routing, shard awareness and per-node metrics all attribute
+ * them to that single node. The candidate loop keeps such addresses in resolver order rather than
+ * shuffling them -- not because the node is identified, but because {@code DefaultEndPoint}
+ * reports its addresses as not interchangeable (see {@code
+ * PinnableEndPoint#addressesAreInterchangeable()} and {@code ChannelFactory#shuffleAndLimit}) --
+ * so a pool stays on one host in practice, but the driver has no way to verify the premise. That
+ * is a property of the translator's output, not of this monitor, so it does not change what this
+ * flag reports.
+ *
+ *
The connected node's own {@code EndPoint} is a different case again. It originates from the
+ * contact point the control connection used, and {@code ChannelFactory} binds it to the single
+ * address that connection reached (see {@code PinnableEndPoint}), so it does not re-expand
+ * on later connection attempts. Recovering from an address change for that node therefore depends
+ * on this flag being {@code false}, i.e. on the contact-point fallback described above.
+ *
+ *
Proxy-based monitors that re-resolve per call should override this to return {@code true}.
+ */
+ default boolean reresolvesNodeAddresses() {
+ return false;
+ }
}
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/metrics/AbstractMetricUpdater.java b/core/src/main/java/com/datastax/oss/driver/internal/core/metrics/AbstractMetricUpdater.java
index 3d7dc50a7c0..b09f06a286c 100644
--- a/core/src/main/java/com/datastax/oss/driver/internal/core/metrics/AbstractMetricUpdater.java
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/metrics/AbstractMetricUpdater.java
@@ -35,6 +35,7 @@
import java.time.Duration;
import java.util.Set;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -50,6 +51,16 @@ public abstract class AbstractMetricUpdater implements MetricUpdater enabledMetrics;
private final AtomicReference metricsExpirationTimeoutRef = new AtomicReference<>();
+
+ /**
+ * Whether this updater's expiration has already run, as opposed to being pending or never armed.
+ *
+ * The timeout reference alone cannot say: it is null both before anything is armed and after
+ * the task has cleared the metrics, and {@link #adoptExpirationFrom} has to tell a live node with
+ * no countdown from a node whose metrics have already expired.
+ */
+ private final AtomicBoolean expired = new AtomicBoolean();
+
private final Duration expireAfter;
protected AbstractMetricUpdater(InternalDriverContext context, Set enabledMetrics) {
@@ -148,6 +159,7 @@ protected int orphanedStreamIds(Node node) {
}
protected void startMetricsExpirationTimeout() {
+ expired.set(false);
metricsExpirationTimeoutRef.accumulateAndGet(
newTimeout(),
(current, update) -> {
@@ -161,12 +173,63 @@ protected void startMetricsExpirationTimeout() {
}
protected void cancelMetricsExpirationTimeout() {
+ // Called when the node comes back up, so whatever expiry happened is spent: there is nothing
+ // left for a later adoptExpirationFrom() to carry over.
+ expired.set(false);
Timeout t = metricsExpirationTimeoutRef.getAndSet(null);
if (t != null) {
t.cancel();
}
}
+ /**
+ * Moves a pending expiration from the updater being replaced onto this one. See {@link
+ * NodeMetricUpdater#adoptExpirationFrom}.
+ *
+ * Re-armed rather than handed over as-is, so the replacement's own {@code
+ * startMetricsExpirationTimeout()} runs and the countdown belongs to the object whose metrics it
+ * will clear. That restarts the clock; expiry is a coarse, hour-scale cleanup and the node has to
+ * stay down for the whole period either way, so the reset is not worth carrying the original
+ * deadline around for.
+ *
+ *
An expiration that has already run is carried over too, not just a pending one. That
+ * is not redundant: the replacement's constructor eagerly re-registers the node's whole metric
+ * set, so a node that expired while down and then had its endpoint change comes back with every
+ * series present again and, if nothing were armed here, no countdown to clear them. The only
+ * other caller of {@code startMetricsExpirationTimeout()} is the metrics factory's
+ * DOWN/FORCED_DOWN/removed handler, and a node that is already down produces no such event -- so
+ * "nothing armed" would mean the resurrected series outlive the node until it next comes up or is
+ * removed, which may be never.
+ *
+ *
A node that is merely live is not caught by that: {@code
+ * cancelMetricsExpirationTimeout()}, which the same handler calls on UP, clears the flag, so an
+ * endpoint change on a healthy node still arms nothing.
+ *
+ *
Re-arming is best effort. {@code HashedWheelTimer.newTimeout} throws once the timer has been
+ * stopped ({@code NettyOptions#onClose}) or its pending-task ceiling is reached, and the only
+ * caller is {@code DefaultNode#setEndPoint} -- reached from {@code NodesRefresh#copyInfos} inside
+ * {@code MetadataManager}'s apply step, which neither catches nor contains throwables. Letting
+ * one out would drop an entire metadata refresh, surfacing only as a DEBUG line, over an
+ * hour-scale cleanup countdown. Losing the countdown costs at worst one node's metrics not
+ * expiring.
+ */
+ public void adoptExpirationFrom(NodeMetricUpdater previous) {
+ if (!(previous instanceof AbstractMetricUpdater)) {
+ return;
+ }
+ AbstractMetricUpdater> replaced = (AbstractMetricUpdater>) previous;
+ Timeout pending = replaced.metricsExpirationTimeoutRef.getAndSet(null);
+ boolean stillPending = pending != null && pending.cancel();
+ if (!stillPending && !replaced.expired.get()) {
+ return;
+ }
+ try {
+ startMetricsExpirationTimeout();
+ } catch (RuntimeException e) {
+ LOG.debug("Could not re-arm the metrics expiration timeout, skipping it", e);
+ }
+ }
+
protected Timeout newTimeout() {
return context
.getNettyOptions()
@@ -174,7 +237,22 @@ protected Timeout newTimeout() {
.newTimeout(
t -> {
clearMetrics();
+ // Through cancelMetricsExpirationTimeout() rather than clearing the reference inline:
+ // MicrometerNodeMetricUpdater and MicroProfileNodeMetricUpdater override it, and
+ // inlining would quietly remove the only virtual dispatch of it on the expiry path.
+ // Both overrides are pure super-delegation today -- they exist so a factory in
+ // another package can reach a protected member -- so this is invisible now and would
+ // only bite whoever first puts real behaviour in one.
cancelMetricsExpirationTimeout();
+ // Last, because the call above clears this flag.
+ //
+ // A concurrent adoptExpirationFrom() can still miss the hand-over: Netty marks a
+ // timeout ST_EXPIRED before invoking the task, so cancel() can already be failing
+ // while this flag is still false. Ordering cannot close that -- only folding the
+ // timeout and the flag into a single atomic would -- and the cost of losing is one
+ // node's metrics not expiring, so the window is left open and named rather than
+ // claimed away.
+ expired.set(true);
},
expireAfter.toNanos(),
TimeUnit.NANOSECONDS);
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/metrics/NodeMetricUpdater.java b/core/src/main/java/com/datastax/oss/driver/internal/core/metrics/NodeMetricUpdater.java
index 93d003f0a03..af84782c989 100644
--- a/core/src/main/java/com/datastax/oss/driver/internal/core/metrics/NodeMetricUpdater.java
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/metrics/NodeMetricUpdater.java
@@ -19,4 +19,23 @@
import com.datastax.oss.driver.api.core.metrics.NodeMetric;
-public interface NodeMetricUpdater extends MetricUpdater {}
+public interface NodeMetricUpdater extends MetricUpdater {
+
+ /**
+ * Takes over the metrics-expiration countdown from the updater this one is replacing, when a node
+ * rebuilds its updater after its endpoint changed.
+ *
+ * Without this the countdown is simply lost. It is armed and cancelled through {@code
+ * node.getMetricUpdater()} -- by the metrics factories, on node state events -- so once a node
+ * has swapped in a replacement, the cancel that a later UP event triggers reaches the new updater
+ * and finds nothing, while the old updater's timer is still pending on an object nothing else
+ * refers to. Both halves of that are wrong: the replacement never expires, because a node that is
+ * already down will not produce another DOWN event to arm it, and the orphan eventually fires
+ * {@link #clearMetrics()} on names it recomputes from whatever endpoint the node holds by then.
+ *
+ *
Implementations that do not expire metrics can ignore this.
+ */
+ default void adoptExpirationFrom(NodeMetricUpdater previous) {
+ // nothing to hand over
+ }
+}
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/pool/ChannelPool.java b/core/src/main/java/com/datastax/oss/driver/internal/core/pool/ChannelPool.java
index c9bc5df2f85..8d8897f6466 100644
--- a/core/src/main/java/com/datastax/oss/driver/internal/core/pool/ChannelPool.java
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/pool/ChannelPool.java
@@ -553,21 +553,57 @@ private CompletionStage addMissingChannels() {
private void handleError(
Throwable error, Consumer onFatal, Consumer onKeyspaceError) {
+ // ChannelFactory.isAuthOnly, not a bare instanceof: one failure no longer means one address.
+ // A node whose endpoint is a name reports a single failure with the other addresses' failures
+ // attached as suppressed, and the factory promotes an authentication failure over transport
+ // ones -- so [refused, refused, auth] would otherwise count as errors.connection.auth alone,
+ // leaving errors.connection.init at zero while most of the node is unreachable, and tell the
+ // operator their credentials are wrong. The routing below is unaffected: which failure to act
+ // on is already decided by ChannelFactory#surfacedFailure.
+ boolean authOnly = ChannelFactory.isAuthOnly(error);
((DefaultNode) node)
.getMetricUpdater()
.incrementCounter(
- error instanceof AuthenticationException
+ authOnly
? DefaultNodeMetric.AUTHENTICATION_ERRORS
: DefaultNodeMetric.CONNECTION_INIT_ERRORS,
null);
+ if (!authOnly && error instanceof AuthenticationException) {
+ // Mixed [refused, refused, auth]: both things really did happen, so both are counted.
+ // Routing the mixed case to errors.connection.init alone would be the opposite mistake to
+ // the one above -- this method is the driver's only writer of errors.connection.auth, so
+ // for any node whose endpoint is a name (SNI/cloud proxy, a client route, or a translator
+ // with resolve-addresses = false) that metric could never leave zero, however wrong the
+ // credentials are. An operator watching it would see nothing while every connect failed on
+ // authentication. Counting both keeps errors.connection.init honest about the unreachable
+ // addresses without making the auth signal unobservable.
+ ((DefaultNode) node)
+ .getMetricUpdater()
+ .incrementCounter(DefaultNodeMetric.AUTHENTICATION_ERRORS, null);
+ }
if (error instanceof ClusterNameMismatchException
|| error instanceof UnsupportedProtocolVersionException) {
// This will likely be thrown by all channels, but finish the loop cleanly
onFatal.accept(error);
- } else if (error instanceof AuthenticationException) {
+ } else if (authOnly) {
// Always warn because this is most likely something the operator needs to fix.
// Keep going to reconnect if it can be fixed without bouncing the client.
Loggers.warnWithException(LOG, "[{}] Authentication error", logPrefix, error);
+ } else if (error instanceof AuthenticationException) {
+ // Authentication on some addresses, something else on the others: the credentials are not
+ // the whole story, so the message says so -- but this still warns unconditionally, exactly
+ // like the auth-only branch above. advanced.connection.warn-on-init-error exists to mute
+ // the noise of nodes that cannot be reached; it was never a switch for "your credentials
+ // are wrong", and before multi-address support every AuthenticationException warned here
+ // regardless of it. Gating the mixed case on it would mean a name whose records fail
+ // [refused, refused, auth] logs at DEBUG, so the one part of the failure the operator can
+ // actually fix is the part they never see.
+ Loggers.warnWithException(
+ LOG,
+ "[{}] Error while opening new channel (authentication failed on some of the node's"
+ + " addresses, other addresses failed for other reasons)",
+ logPrefix,
+ error);
} else if (error instanceof InvalidKeyspaceException) {
onKeyspaceError.accept(null);
} else {
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/util/AddressUtils.java b/core/src/main/java/com/datastax/oss/driver/internal/core/util/AddressUtils.java
index 8905edb9192..6a3621eae1a 100644
--- a/core/src/main/java/com/datastax/oss/driver/internal/core/util/AddressUtils.java
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/util/AddressUtils.java
@@ -18,6 +18,9 @@
package com.datastax.oss.driver.internal.core.util;
import com.datastax.oss.driver.shaded.guava.common.collect.ImmutableSet;
+import com.datastax.oss.driver.shaded.guava.common.net.InetAddresses;
+import edu.umd.cs.findbugs.annotations.Nullable;
+import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.UnknownHostException;
@@ -56,4 +59,132 @@ public static Set extract(String address, boolean resolve) {
return result;
}
}
+
+ /**
+ * Whether {@code address} denotes a host name, as opposed to an IP address written out in
+ * literal form.
+ *
+ * The distinction matters wherever a name is treated as something that can be resolved — and
+ * re-resolved — while a literal is taken as the final answer. Both forms can appear resolved or
+ * unresolved, so neither {@link InetSocketAddress#isUnresolved()} nor the presence of an {@link
+ * InetAddress} tells them apart.
+ *
+ *
Performs no lookup of any kind.
+ *
+ *
The answer is only stable for an {@linkplain InetSocketAddress#isUnresolved() unresolved}
+ * address. A resolved one has no host string of its own: {@code getHostString()} renders the
+ * {@link InetAddress}'s cached {@code hostName} field, which is empty until the first time
+ * anything calls {@code getHostName()} on that instance and holds a reverse-DNS name afterwards —
+ * and {@code DefaultSslEngineFactory} calls it while building an engine, under the default {@code
+ * advanced.ssl-engine-factory.allow-dns-reverse-lookup-san = true}. So a resolved address built
+ * over an IP literal answers {@code false} before the first TLS handshake to that node and {@code
+ * true} after it, for the very same instance. Callers that must not flip with it either restrict
+ * themselves to unresolved addresses (as {@code ChannelFactory#reattachHostname} does) or strip
+ * the label first (see {@link #stripHostName}).
+ *
+ *
On the unresolved branch, a scoped IPv6 literal (say {@code fe80::1%eth0}) and a
+ * bracketed one ({@code [2001:db8::5]}, the spelling {@link #extract} preserves) are both
+ * correctly reported as literals. Callers that go on to parse the string must therefore be ready
+ * for a zone and for brackets — {@link
+ * com.datastax.oss.driver.shaded.guava.common.net.InetAddresses#forString} rejects the bracketed
+ * form outright, and resolves a zone against the local interfaces, throwing {@link
+ * IllegalArgumentException} when no interface matches (see {@code
+ * ChannelFactory#reattachHostname}, which strips both before parsing).
+ */
+ public static boolean carriesName(InetSocketAddress address) {
+ String hostString = address.getHostString();
+ if (hostString == null) {
+ return false;
+ }
+ // A resolved address is compared against the literal its own bytes produce, which is cheaper
+ // and
+ // stricter than parsing; an unresolved one has no bytes, so its string has to be parsed.
+ InetAddress ip = address.getAddress();
+ return ip != null ? !hostString.equals(ip.getHostAddress()) : !isLiteral(hostString);
+ }
+
+ /**
+ * Whether an unresolved address's host string is an IP address in literal form.
+ *
+ *
Two spellings count. {@code InetAddresses#isInetAddress} accepts the bare form, zone
+ * included ({@code 2001:db8::5}, {@code fe80::1%eth0}); only {@code
+ * InetAddresses#isUriInetAddress} accepts the bracketed URI form ({@code [2001:db8::5]}).
+ *
+ *
The bracketed form is not hypothetical. {@link #extract} splits a contact point on its
+ * last colon and keeps whatever precedes it verbatim, so {@code [2001:db8::5]:9042} yields
+ * the host string {@code [2001:db8::5]}, and {@code InetAddress.getAllByName} accepts that
+ * spelling — the configuration works end to end. Testing the bare form alone would report it as a
+ * host name, which costs on both sides: {@code DefaultEndPoint#equals} would fire the mixed
+ * unresolved/resolved warning and burn its once-per-JVM canary on a message whose stated hazards
+ * are all false for a literal, and {@code ChannelFactory#reattachHostname} would take the
+ * name-wins branch and relabel even a resolver-redirected candidate, bypassing the byte-equality
+ * guard the literal branch exists for.
+ */
+ private static boolean isLiteral(String hostString) {
+ return InetAddresses.isInetAddress(hostString) || InetAddresses.isUriInetAddress(hostString);
+ }
+
+ /**
+ * Returns a copy of {@code ip} labelled with {@code hostName}, or with no label at all when
+ * {@code hostName} is {@code null}, preserving an IPv6 zone if there is one.
+ *
+ *
{@link InetAddress#getByAddress(String, byte[])} cannot carry a zone, and dropping one would
+ * change where the address actually points — a link-local address is only meaningful together
+ * with its zone. {@link Inet6Address#getByAddress(String, byte[], int)} carries the zone as its
+ * numeric id, which is what the connect itself goes on.
+ *
+ *
That overload is used only for an address that really has a zone. {@code
+ * Inet6AddressHolder.init} treats any {@code scope_id >= 0} as zone-present, so handing it the
+ * {@code 0} an unscoped address reports produces a spurious {@code %0} suffix — verified on JDK
+ * 11.0.30: {@code Inet6Address.getByAddress("db.example.com", bytes, 0).getHostAddress()} is
+ * {@code "2001:db8:0:0:0:0:0:5%0"}, while the two-arg overload yields the clean form. That suffix
+ * would reach node metric tags through an endpoint's {@code toString()}, and would break {@link
+ * #carriesName}'s resolved branch, which needs the host string and the literal to compare equal.
+ *
+ *
The sibling overload taking a {@link java.net.NetworkInterface} is deliberately not used: it
+ * re-derives the numeric zone by searching that interface for an address of the same local type,
+ * and throws {@code UnknownHostException("no scope_id found")} when it finds none — so it can
+ * fail for an address that was legitimately built from an interface in the first place. All that
+ * is lost by going numeric is the interface name, which surfaces in {@code toString()} and
+ * nowhere else.
+ *
+ *
Performs no lookup.
+ */
+ public static InetAddress withHostName(@Nullable String hostName, InetAddress ip)
+ throws UnknownHostException {
+ if (ip instanceof Inet6Address) {
+ int scopeId = ((Inet6Address) ip).getScopeId();
+ if (scopeId != 0) {
+ return Inet6Address.getByAddress(hostName, ip.getAddress(), scopeId);
+ }
+ }
+ return InetAddress.getByAddress(hostName, ip.getAddress());
+ }
+
+ /**
+ * Returns {@code address} with its host-name label removed, so that {@code getHostString()}
+ * reports the IP literal and cannot start reporting something else later, or {@code null} if
+ * {@code address} carries no {@link InetAddress} to strip.
+ *
+ *
Anything deriving a durable identity from a resolved address's host string has to do
+ * this first: that string renders a mutable field on the shared {@code InetAddress} (see {@link
+ * #carriesName}), so an identity keyed off it moves the first time the node is connected to over
+ * TLS. Stripping makes the identity a function of the address bytes alone.
+ *
+ *
Performs no lookup.
+ */
+ @Nullable
+ public static InetSocketAddress stripHostName(InetSocketAddress address) {
+ InetAddress ip = address.getAddress();
+ if (ip == null) {
+ return null;
+ }
+ try {
+ return new InetSocketAddress(withHostName(null, ip), address.getPort());
+ } catch (UnknownHostException impossible) {
+ // getByAddress only rejects illegal byte lengths, and these bytes come from a real
+ // InetAddress.
+ return null;
+ }
+ }
}
diff --git a/core/src/main/resources/reference.conf b/core/src/main/resources/reference.conf
index 590784b70c5..dca47b0f46c 100644
--- a/core/src/main/resources/reference.conf
+++ b/core/src/main/resources/reference.conf
@@ -541,6 +541,35 @@ datastax-java-driver {
# Overridable in a profile: no
max-orphan-requests = 256
+ # The maximum number of addresses a single connection attempt will try, when the endpoint it
+ # connects to is a DNS name that resolves to several addresses.
+ #
+ # Each address tried is a full TCP connect plus protocol handshake -- with wrong credentials,
+ # that includes a rejected login. This cap bounds what one attempt can cost in time and in
+ # login attempts.
+ #
+ # Whether the addresses are shuffled first depends on the endpoint. For a contact point, and for
+ # a node reached through the Cloud SNI proxy or a cloud private-endpoint client route, every
+ # address is another way in to the same place, so the order is shuffled on every attempt and
+ # addresses beyond the cap are not lost: successive attempts (e.g. reconnection rounds) sample
+ # different subsets. For a node whose address came from an `AddressTranslator` that returned a
+ # name (`SubnetAddressTranslator` does, under `resolve-addresses = false`), that name is not
+ # known to cover only that one node, so the resolver's order is kept instead -- a pool then
+ # converges on one address and the rest serve as fallback, but the cap is a hard limit and
+ # records beyond it are never reached.
+ #
+ # Sampling across attempts also needs there to be more than one attempt. At session
+ # initialization there is exactly one, unless `advanced.reconnect-on-init` is enabled, so a name
+ # with more records than the cap can fail `build()` while a healthy address goes untried.
+ #
+ # Setting this to 1 restores pre-multi-address behavior: one address tried per attempt.
+ #
+ # Required: yes
+ # Modifiable at runtime: yes, the new value will be used for connections created after the
+ # change.
+ # Overridable in a profile: no
+ max-candidate-addresses = 5
+
# Whether to log non-fatal errors when the driver tries to open a new connection.
#
# This error as recoverable, as the driver will try to reconnect according to the reconnection
@@ -1233,27 +1262,25 @@ datastax-java-driver {
}
- # Whether to resolve the addresses passed to `basic.contact-points`.
+ # DEPRECATED: this option no longer has any effect and will be removed in a future release.
#
- # If this is true, addresses are created with `InetSocketAddress(String, int)`: the host name will
- # be resolved the first time, and the driver will use the resolved IP address for all subsequent
- # connection attempts.
+ # Contact points given here are now always kept as unresolved hostnames and expanded to all of
+ # their DNS-mapped IPs lazily at connection time. This means the driver tries every IP a hostname
+ # resolves to, and re-resolves the hostname on each new connection so DNS changes are picked up
+ # automatically. Previously this option selected between resolving a contact-point hostname once
+ # (true) and re-resolving it on every connection (false); that distinction no longer applies.
#
- # If this is false, addresses are created with `InetSocketAddress.createUnresolved()`: the host
- # name will be resolved again every time the driver opens a new connection. This is useful for
- # containerized environments where DNS records are more likely to change over time (note that the
- # JVM and OS have their own DNS caching mechanisms, so you might need additional configuration
- # beyond the driver).
+ # The lookup goes through Netty's configured AddressResolverGroup -- the same resolver an
+ # unresolved address would have reached had it been passed straight to Bootstrap.connect() -- so a
+ # custom resolver installed via NettyOptions.afterBootstrapInitialized() still applies. With
+ # Netty's default (JDK) resolver the lookup blocks the I/O event loop it runs on; install
+ # DnsAddressResolverGroup if you need it to be non-blocking.
#
- # This option only applies to the contact points specified in the configuration. It has no effect
- # on:
- # - programmatic contact points passed to SessionBuilder.addContactPoints: these addresses are
- # built outside of the driver, so it is your responsibility to provide unresolved instances.
- # - dynamically discovered peers: the driver relies on Cassandra system tables, which expose raw
- # IP addresses. Use a custom address translator to convert them to unresolved addresses (if
- # you're in a containerized environment, you probably already need address translation anyway).
+ # This option only ever applied to the contact points specified in the configuration -- never to
+ # programmatic contact points passed to SessionBuilder.addContactPoints, nor to dynamically
+ # discovered peers.
#
- # Required: no (defaults to false)
+ # Required: no
# Modifiable at runtime: no
# Overridable in a profile: no
advanced.resolve-contact-points = false
@@ -2347,14 +2374,43 @@ datastax-java-driver {
}
reconnection {
- # Whether to forcibly add original contact points held by MetadataManager to the reconnection plan,
- # in case there is no live nodes available according to LBP.
- # Experimental.
+ # Whether to append the original contact points held by MetadataManager to the reconnection
+ # plan, after the live nodes reported by the load balancing policy.
+ #
+ # This is also the driver's DNS re-resolution path. Contact points are kept as unresolved
+ # hostnames and expanded to their current DNS IPs at connection time, through Netty's
+ # configured resolver. Metadata nodes, in contrast, store an already-resolved endpoint that
+ # is never re-resolved, so once DNS records change they would otherwise become stale. Keeping
+ # this enabled lets control-connection reconnects re-resolve the original hostnames and pick up
+ # the new IPs once the live-node plan is exhausted.
+ #
+ # Note the cost: the contact points are appended without being compared against the live-node
+ # plan, because at plan time they are still hostnames and the live nodes are already-resolved
+ # IPs. When DNS has not changed they therefore expand to addresses the plan just failed on, so
+ # a reconnection round that exhausts the live nodes retries them a second time.
+ #
+ # How much that adds depends on how many addresses each contact point resolves to: a live node
+ # is one address, while a contact point is expanded to up to
+ # `advanced.connection.max-candidate-addresses` of them (5 by default). So three contact points
+ # can append up to 15 attempts to a round, not 3. Each attempt costs a connect -- up to
+ # `advanced.connection.connect-timeout` -- and, if the address accepts the connection but then
+ # stalls, the init handshake on top, whose steps each arm their own
+ # `advanced.connection.init-query-timeout`. The attempts are serial, and the control connection
+ # stays down for the whole round. Lowering `max-candidate-addresses` bounds that instead.
+ #
+ # Setting this to false switches DNS re-resolution off entirely -- there is no other path to
+ # it. Every metadata node holds an already-resolved address, and so does the node the control
+ # connection is on, which is registered under the address it reached rather than under the
+ # contact point it was reached through. (The one exception is an AddressTranslator that
+ # returns a hostname, as SubnetAddressTranslator does under `resolve-addresses = false`; those
+ # endpoints re-expand on every attempt regardless of this setting.) So set it to false only
+ # when the contact points are IP literals, or when their records never change and you would
+ # rather keep reconnection rounds short.
#
# Required: yes
# Modifiable at runtime: yes, the new value will be used for checks issued after the change.
# Overridable in a profile: no
- fallback-to-original-contact-points = false
+ fallback-to-original-contact-points = true
}
}
diff --git a/core/src/test/java/com/datastax/dse/driver/internal/core/insights/InsightsClientTest.java b/core/src/test/java/com/datastax/dse/driver/internal/core/insights/InsightsClientTest.java
index 5085432dbec..be531cf8785 100644
--- a/core/src/test/java/com/datastax/dse/driver/internal/core/insights/InsightsClientTest.java
+++ b/core/src/test/java/com/datastax/dse/driver/internal/core/insights/InsightsClientTest.java
@@ -277,6 +277,38 @@ public void should_construct_json_event_status_message() throws IOException {
"127.0.0.1:20", new SessionStateForNode(2, 20)));
}
+ @Test
+ public void should_merge_nodes_that_report_under_the_same_address() throws IOException {
+ // Behind an SNI proxy or a cloud client route, every node's endpoint resolves to the same proxy
+ // address, so the map key connectedNodes is built from is not unique per node. Collectors.toMap
+ // throws IllegalStateException on a duplicate key, which would propagate out of
+ // createStatusMessage() and abort the status report on every interval for any such deployment
+ // with more than one node open.
+ DefaultDriverContext context = mockDefaultDriverContext();
+ mockConnectionPoolsBehindOneProxy(context);
+ InsightsClient insightsClient =
+ new InsightsClient(
+ context,
+ MOCK_TIME_SUPPLIER,
+ INSIGHTS_CONFIGURATION,
+ null,
+ null,
+ null,
+ null,
+ null,
+ EMPTY_STACK_TRACE);
+
+ // when
+ String statusMessage = insightsClient.createStatusMessage();
+
+ // then -- one entry, carrying the totals reached through that address.
+ Insight insight =
+ new ObjectMapper()
+ .readValue(statusMessage, new TypeReference>() {});
+ assertThat(insight.getInsightData().getConnectedNodes())
+ .isEqualTo(ImmutableMap.of("proxy.example.com:9042", new SessionStateForNode(3, 30)));
+ }
+
@Test
public void should_schedule_task_with_initial_delay() {
// given
@@ -510,6 +542,32 @@ private DefaultDriverContext mockDefaultDriverContext() throws UnknownHostExcept
return context;
}
+ /** Two nodes whose endpoints resolve to one and the same (unresolved) proxy address. */
+ private void mockConnectionPoolsBehindOneProxy(DefaultDriverContext driverContext) {
+ InetSocketAddress proxy = InetSocketAddress.createUnresolved("proxy.example.com", 9042);
+
+ Node node1 = mock(Node.class);
+ EndPoint endPoint1 = mock(EndPoint.class);
+ when(endPoint1.resolve()).thenReturn(proxy);
+ when(node1.getEndPoint()).thenReturn(endPoint1);
+ when(node1.getOpenConnections()).thenReturn(1);
+ ChannelPool channelPool1 = mock(ChannelPool.class);
+ when(channelPool1.getInFlight()).thenReturn(10);
+
+ Node node2 = mock(Node.class);
+ EndPoint endPoint2 = mock(EndPoint.class);
+ when(endPoint2.resolve()).thenReturn(proxy);
+ when(node2.getEndPoint()).thenReturn(endPoint2);
+ when(node2.getOpenConnections()).thenReturn(2);
+ ChannelPool channelPool2 = mock(ChannelPool.class);
+ when(channelPool2.getInFlight()).thenReturn(20);
+
+ PoolManager poolManager = mock(PoolManager.class);
+ when(poolManager.getPools())
+ .thenReturn(ImmutableMap.of(node1, channelPool1, node2, channelPool2));
+ when(driverContext.getPoolManager()).thenReturn(poolManager);
+ }
+
private void mockConnectionPools(DefaultDriverContext driverContext) {
Node node1 = mock(Node.class);
EndPoint endPoint1 = mock(EndPoint.class);
diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/addresstranslation/FixedHostNameAddressTranslatorTest.java b/core/src/test/java/com/datastax/oss/driver/internal/core/addresstranslation/FixedHostNameAddressTranslatorTest.java
index da9c40f033c..ea5d628bf4a 100644
--- a/core/src/test/java/com/datastax/oss/driver/internal/core/addresstranslation/FixedHostNameAddressTranslatorTest.java
+++ b/core/src/test/java/com/datastax/oss/driver/internal/core/addresstranslation/FixedHostNameAddressTranslatorTest.java
@@ -19,6 +19,7 @@
import static com.datastax.oss.driver.api.core.config.DefaultDriverOption.ADDRESS_TRANSLATOR_ADVERTISED_HOSTNAME;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assumptions.assumeThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -45,4 +46,31 @@ public void should_translate_address() {
assertThat(translator.translate(address))
.isEqualTo(InetSocketAddress.createUnresolved("myaddress", 6061));
}
+
+ /**
+ * The sibling above cannot fail: {@code myaddress} does not resolve, so {@code new
+ * InetSocketAddress(String, int)} leaves it unresolved too and the two spellings compare equal. A
+ * name that does resolve is what tells them apart -- and telling them apart is the
+ * point, because a resolved address is never expanded to the proxy's other addresses.
+ */
+ @Test
+ public void should_not_resolve_a_hostname_that_would_resolve() {
+ assumeThat(new InetSocketAddress("localhost", 6061).isUnresolved())
+ .as("requires a host where localhost resolves; where it does not, both spellings agree")
+ .isFalse();
+
+ DriverExecutionProfile defaultProfile = mock(DriverExecutionProfile.class);
+ when(defaultProfile.getString(ADDRESS_TRANSLATOR_ADVERTISED_HOSTNAME)).thenReturn("localhost");
+ DefaultDriverContext defaultDriverContext =
+ MockedDriverContextFactory.defaultDriverContext(Optional.of(defaultProfile));
+
+ FixedHostNameAddressTranslator translator =
+ new FixedHostNameAddressTranslator(defaultDriverContext);
+
+ InetSocketAddress translated = translator.translate(new InetSocketAddress("192.0.2.5", 6061));
+
+ assertThat(translated.isUnresolved()).isTrue();
+ assertThat(translated.getHostString()).isEqualTo("localhost");
+ assertThat(translated.getPort()).isEqualTo(6061);
+ }
}
diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryBootstrapHookTest.java b/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryBootstrapHookTest.java
new file mode 100644
index 00000000000..dbe60ae4432
--- /dev/null
+++ b/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryBootstrapHookTest.java
@@ -0,0 +1,71 @@
+/*
+ * 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.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+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.internal.core.context.NettyOptions;
+import com.datastax.oss.driver.internal.core.metrics.NoopNodeMetricUpdater;
+import io.netty.bootstrap.Bootstrap;
+import io.netty.channel.ChannelInboundHandlerAdapter;
+import java.util.concurrent.CompletionStage;
+import org.junit.Test;
+
+/**
+ * Verifies the {@link NettyOptions#afterBootstrapInitialized(Bootstrap)} contract: the hook runs on
+ * a handler-less bootstrap, and a handler it installs is replaced by the driver's own.
+ */
+public class ChannelFactoryBootstrapHookTest extends ChannelFactoryTestBase {
+
+ @Test
+ public void should_replace_handler_installed_by_bootstrap_hook() {
+ // Given – a hook that (incorrectly) installs its own channel handler. The driver sets its own
+ // handler on each per-attempt copy afterwards, logging a one-time warning; if the dummy
+ // handler below survived instead, the protocol handshake would never happen and this connect
+ // would fail on the init timeout.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ doAnswer(
+ invocation -> {
+ Bootstrap bootstrap = invocation.getArgument(0);
+ bootstrap.handler(new ChannelInboundHandlerAdapter());
+ return null;
+ })
+ .when(nettyOptions)
+ .afterBootstrapInitialized(any(Bootstrap.class));
+ ChannelFactory factory = newChannelFactory();
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(
+ SERVER_ADDRESS,
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE);
+ completeSimpleChannelInit();
+
+ // Then
+ assertThatStage(channelFuture).isSuccess();
+ }
+}
diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryConnectHookTest.java b/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryConnectHookTest.java
new file mode 100644
index 00000000000..191a5627972
--- /dev/null
+++ b/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryConnectHookTest.java
@@ -0,0 +1,677 @@
+/*
+ * 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.assertThat;
+import static com.datastax.oss.driver.Assertions.assertThatStage;
+import static org.awaitility.Awaitility.await;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.Mockito.doThrow;
+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.connection.ConnectionInitException;
+import com.datastax.oss.driver.internal.core.TestResponses;
+import com.datastax.oss.driver.internal.core.config.typesafe.TypesafeDriverConfig;
+import com.datastax.oss.driver.internal.core.metadata.DefaultEndPoint;
+import com.datastax.oss.driver.internal.core.metrics.NoopNodeMetricUpdater;
+import com.datastax.oss.driver.shaded.guava.common.collect.ImmutableList;
+import com.datastax.oss.protocol.internal.Frame;
+import com.datastax.oss.protocol.internal.ProtocolConstants;
+import com.datastax.oss.protocol.internal.request.Options;
+import com.datastax.oss.protocol.internal.request.Register;
+import com.datastax.oss.protocol.internal.request.Startup;
+import com.datastax.oss.protocol.internal.response.Error;
+import com.datastax.oss.protocol.internal.response.Ready;
+import java.net.InetSocketAddress;
+import java.net.SocketAddress;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.Test;
+
+/**
+ * Verifies the two steps {@link ChannelFactory} runs between protocol initialization and the
+ * completion of a candidate attempt: the caller's {@link ConnectHook}, and the REGISTER request
+ * that moved out of the init handshake so that a channel the hook is about to reject never
+ * registers for events.
+ */
+public class ChannelFactoryConnectHookTest extends ChannelFactoryTestBase {
+
+ private static final Duration HOOK_TIMEOUT = Duration.ofSeconds(5);
+
+ /** The name the endpoint reports, and that only the resolver knows how to expand. */
+ private static final InetSocketAddress HOSTNAME =
+ InetSocketAddress.createUnresolved("test.cluster.fake", 9042);
+
+ private void givenNegotiableProtocol() {
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ }
+
+ /**
+ * Drives one candidate's handshake to successful completion. Only the factory's first channel
+ * sends OPTIONS, so later candidates start straight at STARTUP.
+ */
+ private void completeInit() {
+ Frame requestFrame = readOutboundFrame();
+ if (requestFrame.message instanceof Options) {
+ writeInboundFrame(requestFrame, TestResponses.supportedResponse("mock_key", "mock_value"));
+ requestFrame = readOutboundFrame();
+ }
+ assertThat(requestFrame.message).isInstanceOf(Startup.class);
+ writeInboundFrame(requestFrame, new Ready());
+ writeInboundFrame(readOutboundFrame(), TestResponses.clusterNameResponse("mockClusterName"));
+ }
+
+ private static DriverChannelOptions optionsWithHook(ConnectHook hook) {
+ return DriverChannelOptions.builder().withConnectHook(hook, HOOK_TIMEOUT).build();
+ }
+
+ private static DriverChannelOptions optionsWithHookAndEvents(ConnectHook hook) {
+ return DriverChannelOptions.builder()
+ .withConnectHook(hook, HOOK_TIMEOUT)
+ .withEvents(ImmutableList.of("foo", "bar"), mock(EventCallback.class))
+ .build();
+ }
+
+ @Test
+ public void should_complete_candidate_only_after_hook_accepts() {
+ // Given — a hook whose completion the test controls.
+ givenNegotiableProtocol();
+ ChannelFactory factory = newChannelFactory();
+ CompletableFuture gate = new CompletableFuture<>();
+ List vettedChannels = new CopyOnWriteArrayList<>();
+ ConnectHook hook =
+ channel -> {
+ vettedChannels.add(channel);
+ return gate;
+ };
+
+ // When — init completes but the hook has not answered yet.
+ CompletionStage channelFuture =
+ factory.connect(
+ SERVER_ADDRESS, null, null, optionsWithHook(hook), NoopNodeMetricUpdater.INSTANCE);
+ completeInit();
+
+ // Then — the attempt is not successful until the hook says so. (The hook runs on the event
+ // loop after init succeeds, so wait for the invocation before asserting on the future.)
+ await().atMost(java.time.Duration.ofSeconds(2)).until(() -> vettedChannels.size() == 1);
+ assertThat(channelFuture.toCompletableFuture()).isNotDone();
+
+ // When
+ gate.complete(null);
+
+ // Then — the vetted channel is the one handed to the caller.
+ assertThatStage(channelFuture)
+ .isSuccess(channel -> assertThat(channel).isSameAs(vettedChannels.get(0)));
+ }
+
+ @Test
+ public void should_try_next_address_when_hook_rejects_candidate() {
+ // Given — a name expanding to two addresses of the live server, and a hook that rejects the
+ // first candidate and accepts the second: the caller's acceptance criteria are per address,
+ // and a rejection must not write off the endpoint.
+ givenNegotiableProtocol();
+ SocketAddress serverAddress = SERVER_ADDRESS.resolve();
+ installResolver(new TestAddressResolverGroup(Arrays.asList(serverAddress, serverAddress)));
+ ChannelFactory factory = newChannelFactory();
+ AtomicInteger invocations = new AtomicInteger();
+ ConnectHook hook =
+ channel -> {
+ CompletableFuture result = new CompletableFuture<>();
+ if (invocations.incrementAndGet() == 1) {
+ result.completeExceptionally(new IllegalStateException("not this one"));
+ } else {
+ result.complete(null);
+ }
+ return result;
+ };
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(
+ new DefaultEndPoint(HOSTNAME),
+ null,
+ null,
+ optionsWithHook(hook),
+ NoopNodeMetricUpdater.INSTANCE);
+ completeInit();
+ completeInit();
+
+ // Then
+ assertThatStage(channelFuture).isSuccess();
+ assertThat(invocations.get()).isEqualTo(2);
+ }
+
+ @Test
+ public void should_fail_connect_when_hook_rejects_the_last_candidate() {
+ // Given — a single address, so the rejection has nowhere to advance to.
+ givenNegotiableProtocol();
+ ChannelFactory factory = newChannelFactory();
+ IllegalStateException rejection = new IllegalStateException("cannot identify itself");
+ ConnectHook hook =
+ channel -> {
+ CompletableFuture result = new CompletableFuture<>();
+ result.completeExceptionally(rejection);
+ return result;
+ };
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(
+ SERVER_ADDRESS, null, null, optionsWithHook(hook), NoopNodeMetricUpdater.INSTANCE);
+ completeInit();
+
+ // Then — the rejection's cause is preserved for diagnosis.
+ assertThatStage(channelFuture)
+ .isFailed(
+ error -> {
+ assertThat(error).isInstanceOf(ConnectionInitException.class);
+ assertThat(error.getCause()).isSameAs(rejection);
+ });
+ }
+
+ @Test
+ public void should_treat_synchronous_hook_throw_as_rejection() {
+ // A hook is a caller-supplied callback running inside a Netty listener: a leaked throwable
+ // would otherwise leave the attempt hanging forever.
+ givenNegotiableProtocol();
+ ChannelFactory factory = newChannelFactory();
+ IllegalStateException thrown = new IllegalStateException("hook blew up");
+ ConnectHook hook =
+ channel -> {
+ throw thrown;
+ };
+
+ CompletionStage channelFuture =
+ factory.connect(
+ SERVER_ADDRESS, null, null, optionsWithHook(hook), NoopNodeMetricUpdater.INSTANCE);
+ completeInit();
+
+ assertThatStage(channelFuture)
+ .isFailed(
+ error -> {
+ assertThat(error).isInstanceOf(ConnectionInitException.class);
+ assertThat(error.getCause()).isSameAs(thrown);
+ });
+ }
+
+ @Test
+ public void should_reject_candidate_when_hook_times_out() {
+ // Given — a hook whose stage never completes; only the driver can bound that.
+ givenNegotiableProtocol();
+ ChannelFactory factory = newChannelFactory();
+ ConnectHook hook = channel -> new CompletableFuture<>();
+ DriverChannelOptions options =
+ DriverChannelOptions.builder().withConnectHook(hook, Duration.ofMillis(100)).build();
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(SERVER_ADDRESS, null, null, options, NoopNodeMetricUpdater.INSTANCE);
+ completeInit();
+
+ // Then
+ assertThatStage(channelFuture)
+ .isFailed(error -> assertThat(error).hasMessageContaining("timed out"));
+ }
+
+ @Test
+ public void should_register_for_events_only_after_hook_accepts() {
+ // Given — events requested and a gated hook.
+ givenNegotiableProtocol();
+ ChannelFactory factory = newChannelFactory();
+ CompletableFuture gate = new CompletableFuture<>();
+ ConnectHook hook = channel -> gate;
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(
+ SERVER_ADDRESS,
+ null,
+ null,
+ optionsWithHookAndEvents(hook),
+ NoopNodeMetricUpdater.INSTANCE);
+ completeInit();
+ // The hook has not accepted yet: were REGISTER part of init, it would already be on the wire.
+ assertThat(tryReadOutboundFrame(200)).isNull();
+ gate.complete(null);
+
+ // Then — REGISTER goes out only now, and the attempt completes once it is acknowledged.
+ Frame registerFrame = readOutboundFrame();
+ assertThat(registerFrame.message).isInstanceOf(Register.class);
+ assertThat(((Register) registerFrame.message).eventTypes).containsExactly("foo", "bar");
+ writeInboundFrame(registerFrame, new Ready());
+ assertThatStage(channelFuture).isSuccess();
+ }
+
+ @Test
+ public void should_fail_candidate_when_the_step_after_the_hook_throws()
+ throws InterruptedException {
+ // Given — events requested, and a config read that starts throwing once the hook has accepted
+ // (an option gone missing across a config reload, a custom DriverConfig, a wrong type).
+ // registerForEvents() runs inside the hook stage's whenComplete callback: nobody consumes the
+ // stage that callback returns, and the hook timeout that would have failed the candidate has
+ // just been cancelled, so without a blanket catch there the attempt would hang forever.
+ givenNegotiableProtocol();
+ AtomicBoolean poisoned = new AtomicBoolean();
+ when(defaultProfile.getDuration(DefaultDriverOption.CONNECTION_INIT_QUERY_TIMEOUT))
+ .thenAnswer(
+ invocation -> {
+ if (poisoned.get()) {
+ throw new IllegalStateException("config reloaded without the option");
+ }
+ return Duration.ofMillis(500);
+ });
+ ChannelFactory factory = newChannelFactory();
+ AtomicReference vettedChannel = new AtomicReference<>();
+ ConnectHook hook =
+ channel -> {
+ vettedChannel.set(channel);
+ poisoned.set(true);
+ return CompletableFuture.completedFuture(null);
+ };
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(
+ SERVER_ADDRESS,
+ null,
+ null,
+ optionsWithHookAndEvents(hook),
+ NoopNodeMetricUpdater.INSTANCE);
+ completeInit();
+
+ // Then — the attempt fails instead of hanging, REGISTER never goes out, and the channel it had
+ // already opened is closed rather than left dangling with nothing holding it.
+ assertThatStage(channelFuture)
+ .isFailed(
+ error -> {
+ assertThat(error).isInstanceOf(ConnectionInitException.class);
+ assertThat(error.getCause()).hasMessageContaining("config reloaded");
+ });
+ assertThat(tryReadOutboundFrame(200)).isNull();
+ assertThat(vettedChannel.get().closeFuture().await(500, TimeUnit.MILLISECONDS))
+ .as("the abandoned candidate's channel should have been closed")
+ .isTrue();
+ }
+
+ @Test
+ public void should_register_for_events_after_init_when_no_hook_is_set() {
+ // Given — events but no hook (REGISTER still has to happen even when there is nothing to vet).
+ givenNegotiableProtocol();
+ ChannelFactory factory = newChannelFactory();
+ DriverChannelOptions options =
+ DriverChannelOptions.builder()
+ .withEvents(ImmutableList.of("foo", "bar"), mock(EventCallback.class))
+ .build();
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(SERVER_ADDRESS, null, null, options, NoopNodeMetricUpdater.INSTANCE);
+ completeInit();
+
+ // Then
+ Frame registerFrame = readOutboundFrame();
+ assertThat(registerFrame.message).isInstanceOf(Register.class);
+ writeInboundFrame(registerFrame, new Ready());
+ assertThatStage(channelFuture).isSuccess();
+ }
+
+ @Test
+ public void should_try_next_address_when_registration_fails() {
+ // Given — two addresses; the first candidate's REGISTER is refused. A registration failure is
+ // a per-candidate failure, exactly as it was when REGISTER was an init step.
+ givenNegotiableProtocol();
+ SocketAddress serverAddress = SERVER_ADDRESS.resolve();
+ installResolver(new TestAddressResolverGroup(Arrays.asList(serverAddress, serverAddress)));
+ ChannelFactory factory = newChannelFactory();
+ DriverChannelOptions options =
+ DriverChannelOptions.builder()
+ .withEvents(ImmutableList.of("foo", "bar"), mock(EventCallback.class))
+ .build();
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(
+ new DefaultEndPoint(HOSTNAME), null, null, options, NoopNodeMetricUpdater.INSTANCE);
+ completeInit();
+ Frame registerFrame = readOutboundFrame();
+ assertThat(registerFrame.message).isInstanceOf(Register.class);
+ writeInboundFrame(registerFrame, new Error(ProtocolConstants.ErrorCode.SERVER_ERROR, "nope"));
+
+ // Then — the loop advances; the second candidate registers successfully.
+ completeInit();
+ registerFrame = readOutboundFrame();
+ assertThat(registerFrame.message).isInstanceOf(Register.class);
+ writeInboundFrame(registerFrame, new Ready());
+ assertThatStage(channelFuture).isSuccess();
+ }
+
+ @Test
+ public void should_treat_a_zero_hook_timeout_as_unbounded() {
+ // The hook timeout comes from advanced.control-connection.timeout, and every other consumer of
+ // a
+ // driver timeout option reads a non-positive duration as "no timeout" (see
+ // AdminRequestHandler#onWriteComplete). Scheduled anyway, a zero delay fires on the next
+ // event-loop turn -- before any round trip can complete -- and would abandon every candidate of
+ // every contact point, so an operator who disabled that timeout could not initialize a session.
+ givenNegotiableProtocol();
+ ChannelFactory factory = newChannelFactory();
+ CompletableFuture gate = new CompletableFuture<>();
+ ConnectHook hook = channel -> gate;
+ DriverChannelOptions options =
+ DriverChannelOptions.builder().withConnectHook(hook, Duration.ZERO).build();
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(SERVER_ADDRESS, null, null, options, NoopNodeMetricUpdater.INSTANCE);
+ completeInit();
+
+ // Then — the hook is given as long as it needs, and the candidate survives.
+ assertThat(tryReadOutboundFrame(200)).isNull();
+ assertThat(channelFuture.toCompletableFuture()).isNotDone();
+ gate.complete(null);
+
+ assertThatStage(channelFuture).isSuccess();
+ }
+
+ @Test
+ public void should_not_latch_negotiated_state_from_a_candidate_the_hook_rejects() {
+ // The protocol version and cluster name used to be latched as soon as the transport connect and
+ // init handshake succeeded, which was safe while init had the last word on a candidate. It no
+ // longer does: this hook rejects one, and REGISTER (below) can too. A stale DNS record pointing
+ // at a foreign cluster would otherwise leave that cluster's name latched here, and every later
+ // connection -- to any node -- would fail its cluster-name check, which ChannelPool turns into
+ // an irreversible forced-down node.
+ givenNegotiableProtocol();
+ ChannelFactory factory = newChannelFactory();
+ ConnectHook hook =
+ channel -> {
+ CompletableFuture rejected = new CompletableFuture<>();
+ rejected.completeExceptionally(new IllegalStateException("no host_id"));
+ return rejected;
+ };
+
+ // When — the only candidate is rejected after a fully successful handshake.
+ CompletionStage channelFuture =
+ factory.connect(
+ SERVER_ADDRESS, null, null, optionsWithHook(hook), NoopNodeMetricUpdater.INSTANCE);
+ completeInit();
+
+ // Then
+ assertThatStage(channelFuture)
+ .isFailed(error -> assertThat(error).hasMessageContaining("hook"));
+ assertThat(factory.protocolVersion).isNull();
+ assertThat(factory.getClusterName()).isNull();
+ }
+
+ @Test
+ public void should_not_latch_negotiated_state_from_a_candidate_whose_registration_fails() {
+ givenNegotiableProtocol();
+ ChannelFactory factory = newChannelFactory();
+ DriverChannelOptions options =
+ DriverChannelOptions.builder()
+ .withEvents(ImmutableList.of("foo", "bar"), mock(EventCallback.class))
+ .build();
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(SERVER_ADDRESS, null, null, options, NoopNodeMetricUpdater.INSTANCE);
+ completeInit();
+ Frame registerFrame = readOutboundFrame();
+ assertThat(registerFrame.message).isInstanceOf(Register.class);
+ writeInboundFrame(registerFrame, new Error(ProtocolConstants.ErrorCode.SERVER_ERROR, "nope"));
+
+ // Then
+ assertThatStage(channelFuture).isFailed();
+ assertThat(factory.protocolVersion).isNull();
+ assertThat(factory.getClusterName()).isNull();
+ }
+
+ @Test
+ public void should_not_latch_negotiated_state_from_a_candidate_the_timeout_abandoned() {
+ // The third way a candidate is thrown away, and the one no ordering alone protects against: the
+ // hook timeout and the hook's own success race. cancel(false) can lose to a timeout task that
+ // has already started running -- abandonCandidate documents exactly that -- and the candidate
+ // then walks on through to completeCandidate with its future already failed.
+ //
+ // It must not latch on the way past. What decides that is the one-shot settle on the
+ // candidate's
+ // future, not the order of the two statements inside completeCandidate: the accepted candidate
+ // has to latch *before* it publishes, since complete() releases callers on other threads
+ // synchronously, so "latch only if we then win" is not available.
+ givenNegotiableProtocol();
+ ChannelFactory factory = newChannelFactory();
+ CompletableFuture gate = new CompletableFuture<>();
+ DriverChannelOptions options =
+ DriverChannelOptions.builder()
+ .withConnectHook(channel -> gate, Duration.ofMillis(100))
+ .build();
+
+ // When -- the handshake succeeds, then the timeout fires while the hook is still pending.
+ CompletionStage channelFuture =
+ factory.connect(SERVER_ADDRESS, null, null, options, NoopNodeMetricUpdater.INSTANCE);
+ completeInit();
+ assertThatStage(channelFuture)
+ .isFailed(error -> assertThat(error).hasMessageContaining("timed out"));
+
+ // And only now does the hook accept, sending an already-abandoned candidate on to
+ // completeCandidate -- with no event types requested, straight there and with no REGISTER in
+ // between, which is what makes this reachable rather than hypothetical.
+ gate.complete(null);
+
+ // Then -- nothing of that candidate's handshake survives it.
+ assertThat(factory.protocolVersion).isNull();
+ assertThat(factory.getClusterName()).isNull();
+ }
+
+ @Test
+ public void should_fail_the_candidate_when_recording_the_negotiated_state_throws() {
+ // Winning the settle makes completeCandidate the only call that can still complete the future:
+ // every blanket catch downstream discharges that duty through abandonCandidate, and that is a
+ // no-op once the candidate is settled. A throw out of onAccepted would therefore strand the
+ // attempt -- never completed, channel never closed, Reconnection stuck in ATTEMPT_IN_PROGRESS,
+ // and nothing left to time it out, since REGISTER is done and the hook timeout is cancelled.
+ //
+ // latchNegotiatedState is not throw-free: on the Cloud path it reaches
+ // TypesafeDriverConfig#overrideDefaults, which re-parses the whole configuration.
+ givenNegotiableProtocol();
+ ChannelFactory factory = newChannelFactory();
+ TypesafeDriverConfig typesafeConfig = mock(TypesafeDriverConfig.class);
+ when(typesafeConfig.getDefaultProfile()).thenReturn(defaultProfile);
+ doThrow(new IllegalArgumentException("bad reload"))
+ .when(typesafeConfig)
+ .overrideDefaults(anyMap());
+ when(context.getConfig()).thenReturn(typesafeConfig);
+
+ // A hook and no event types: completeCandidate is then reached from the hook stage's
+ // whenComplete, whose catch calls abandonCandidate -- the path that would hang.
+ CompletionStage channelFuture =
+ factory.connect(
+ SERVER_ADDRESS,
+ null,
+ null,
+ optionsWithHook(channel -> CompletableFuture.completedFuture(null)),
+ NoopNodeMetricUpdater.INSTANCE);
+
+ // The server advertises the Cloud product type, which is what takes latchNegotiatedState into
+ // the branch that throws.
+ Frame requestFrame = readOutboundFrame();
+ assertThat(requestFrame.message).isInstanceOf(Options.class);
+ writeInboundFrame(
+ requestFrame, TestResponses.supportedResponse("PRODUCT_TYPE", "DATASTAX_APOLLO"));
+ requestFrame = readOutboundFrame();
+ assertThat(requestFrame.message).isInstanceOf(Startup.class);
+ writeInboundFrame(requestFrame, new Ready());
+ writeInboundFrame(readOutboundFrame(), TestResponses.clusterNameResponse("mockClusterName"));
+
+ // Then -- failed, which is the point: isFailed() waits two seconds and reports a timeout
+ // rather than blocking, so a hang here shows up as a failure and not as a stuck build.
+ assertThatStage(channelFuture).isFailed(error -> assertThat(error).isNotNull());
+ }
+
+ @Test
+ public void should_latch_negotiated_state_once_a_candidate_is_accepted() {
+ givenNegotiableProtocol();
+ ChannelFactory factory = newChannelFactory();
+
+ CompletionStage channelFuture =
+ factory.connect(
+ SERVER_ADDRESS,
+ null,
+ null,
+ optionsWithHook(channel -> CompletableFuture.completedFuture(null)),
+ NoopNodeMetricUpdater.INSTANCE);
+ completeInit();
+
+ assertThatStage(channelFuture).isSuccess();
+ assertThat(factory.protocolVersion).isEqualTo(DefaultProtocolVersion.V4);
+ assertThat(factory.getClusterName()).isEqualTo("mockClusterName");
+ }
+
+ @Test
+ public void should_stop_the_candidate_loop_when_the_event_type_rejection_speaks_for_them_all() {
+ // Given — an identified node with two addresses, and a server that does not support the event
+ // type being registered for. Every address of an identified node is that same node, so the
+ // rejection describes all of them: replaying it can only fail the same way while paying a full
+ // TCP connect plus the STARTUP/AUTH/cluster-name handshake for each. Stopping at the first
+ // restores what this rejection cost while REGISTER was an init step, which was one failed
+ // connect per node.
+ givenNegotiableProtocol();
+ SocketAddress serverAddress = SERVER_ADDRESS.resolve();
+ installResolver(new TestAddressResolverGroup(Arrays.asList(serverAddress, serverAddress)));
+ ChannelFactory factory = newChannelFactory();
+ DriverChannelOptions options =
+ DriverChannelOptions.builder()
+ .withEvents(
+ ImmutableList.of(ProtocolConstants.EventType.CLIENT_ROUTES_CHANGE),
+ mock(EventCallback.class))
+ .build();
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(
+ new DefaultEndPoint(HOSTNAME),
+ null,
+ null,
+ options,
+ NoopNodeMetricUpdater.INSTANCE,
+ /* nodeIsIdentified = */ true);
+ completeInit();
+ Frame registerFrame = readOutboundFrame();
+ assertThat(registerFrame.message).isInstanceOf(Register.class);
+ writeInboundFrame(
+ registerFrame,
+ new Error(
+ ProtocolConstants.ErrorCode.PROTOCOL_ERROR,
+ "Unknown event type: " + ProtocolConstants.EventType.CLIENT_ROUTES_CHANGE));
+
+ // Then — the attempt is already failed, without the second address having been dialled. Compare
+ // should_try_next_address_when_registration_fails, where the stage is still pending at this
+ // point because the loop moved on.
+ assertThatStage(channelFuture)
+ .isFailed(
+ error -> {
+ assertThat(error).isInstanceOf(ConnectionInitException.class);
+ assertThat(error).hasMessageContaining("CLIENT_ROUTES_CHANGE");
+ });
+ }
+
+ @Test
+ public void should_try_next_address_when_only_the_first_server_lacks_the_event_type() {
+ // The same rejection against an unidentified contact point on a plain multi-record name. Those
+ // records may be distinct servers -- which is what a rolling upgrade looks like from the client
+ // -- so the one that answered speaks only for itself, and writing the name off would skip the
+ // upgraded node behind the second record. Only node identity, or an endpoint that says its
+ // addresses are interchangeable, makes the rejection node-wide.
+ givenNegotiableProtocol();
+ SocketAddress serverAddress = SERVER_ADDRESS.resolve();
+ installResolver(new TestAddressResolverGroup(Arrays.asList(serverAddress, serverAddress)));
+ ChannelFactory factory = newChannelFactory();
+ DriverChannelOptions options =
+ DriverChannelOptions.builder()
+ .withEvents(
+ ImmutableList.of(ProtocolConstants.EventType.CLIENT_ROUTES_CHANGE),
+ mock(EventCallback.class))
+ .build();
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(
+ new DefaultEndPoint(HOSTNAME), null, null, options, NoopNodeMetricUpdater.INSTANCE);
+ completeInit();
+ Frame registerFrame = readOutboundFrame();
+ assertThat(registerFrame.message).isInstanceOf(Register.class);
+ writeInboundFrame(
+ registerFrame,
+ new Error(
+ ProtocolConstants.ErrorCode.PROTOCOL_ERROR,
+ "Unknown event type: " + ProtocolConstants.EventType.CLIENT_ROUTES_CHANGE));
+
+ // Then — the loop advances; the second address, running newer software, registers successfully.
+ completeInit();
+ registerFrame = readOutboundFrame();
+ assertThat(registerFrame.message).isInstanceOf(Register.class);
+ writeInboundFrame(registerFrame, new Ready());
+ assertThatStage(channelFuture).isSuccess();
+ }
+
+ @Test
+ public void should_translate_client_routes_register_rejection() {
+ // The one REGISTER rejection with a known cause keeps its clear message, as it had when
+ // REGISTER was an init step: the caller (ClientRoutesTopologyMonitor.init()) reports it
+ // instead of silently degrading.
+ givenNegotiableProtocol();
+ ChannelFactory factory = newChannelFactory();
+ DriverChannelOptions options =
+ DriverChannelOptions.builder()
+ .withEvents(
+ ImmutableList.of(ProtocolConstants.EventType.CLIENT_ROUTES_CHANGE),
+ mock(EventCallback.class))
+ .build();
+
+ CompletionStage channelFuture =
+ factory.connect(SERVER_ADDRESS, null, null, options, NoopNodeMetricUpdater.INSTANCE);
+ completeInit();
+ Frame registerFrame = readOutboundFrame();
+ assertThat(registerFrame.message).isInstanceOf(Register.class);
+ writeInboundFrame(
+ registerFrame,
+ new Error(
+ ProtocolConstants.ErrorCode.PROTOCOL_ERROR,
+ "Unknown event type: " + ProtocolConstants.EventType.CLIENT_ROUTES_CHANGE));
+
+ assertThatStage(channelFuture)
+ .isFailed(
+ error -> {
+ assertThat(error).isInstanceOf(ConnectionInitException.class);
+ assertThat(error).hasMessageContaining("CLIENT_ROUTES_CHANGE");
+ assertThat(error).hasMessageContaining("ScyllaDB Enterprise >= 2026.1");
+ });
+ }
+}
diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryMultiAddressTest.java b/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryMultiAddressTest.java
new file mode 100644
index 00000000000..f3d4325a92e
--- /dev/null
+++ b/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryMultiAddressTest.java
@@ -0,0 +1,1145 @@
+/*
+ * 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.assertj.core.api.Assumptions.assumeThat;
+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.auth.AuthenticationException;
+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.TestResponses;
+import com.datastax.oss.driver.internal.core.metadata.DefaultEndPoint;
+import com.datastax.oss.driver.internal.core.metadata.PinnableEndPoint;
+import com.datastax.oss.driver.internal.core.metadata.SniEndPoint;
+import com.datastax.oss.driver.internal.core.metrics.NoopNodeMetricUpdater;
+import com.datastax.oss.driver.internal.core.util.AddressUtils;
+import com.datastax.oss.protocol.internal.Frame;
+import com.datastax.oss.protocol.internal.request.Options;
+import com.datastax.oss.protocol.internal.request.Startup;
+import com.datastax.oss.protocol.internal.response.Authenticate;
+import com.datastax.oss.protocol.internal.response.Ready;
+import edu.umd.cs.findbugs.annotations.NonNull;
+import io.netty.channel.local.LocalAddress;
+import java.net.Inet6Address;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.NetworkInterface;
+import java.net.SocketAddress;
+import java.util.ArrayDeque;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Deque;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Random;
+import java.util.Set;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.junit.Test;
+
+/**
+ * Verifies how {@link ChannelFactory#connect} treats the several addresses a name expands to: they
+ * are tried in sequence in a shuffled order, at most {@code
+ * advanced.connection.max-candidate-addresses} of them, and failures are aggregated rather than
+ * dropped.
+ *
+ * The expansion itself is exercised in {@link ChannelFactoryNettyResolverTest}; here the
+ * resolver is only the mechanism for producing more than one address from a single endpoint.
+ */
+public class ChannelFactoryMultiAddressTest extends ChannelFactoryTestBase {
+
+ // Local addresses that no server is bound to: connecting to them fails immediately.
+ private static final SocketAddress UNREACHABLE_1 =
+ new LocalAddress(ChannelFactoryMultiAddressTest.class.getSimpleName() + "-unreachable-1");
+ private static final SocketAddress UNREACHABLE_2 =
+ new LocalAddress(ChannelFactoryMultiAddressTest.class.getSimpleName() + "-unreachable-2");
+ private static final SocketAddress UNREACHABLE_3 =
+ new LocalAddress(ChannelFactoryMultiAddressTest.class.getSimpleName() + "-unreachable-3");
+
+ /** The name the endpoint reports, and that only the resolver knows how to expand. */
+ private static final InetSocketAddress HOSTNAME =
+ InetSocketAddress.createUnresolved("test.cluster.fake", 9042);
+
+ @Test
+ public void should_fail_with_suppressed_causes_when_all_addresses_are_unreachable() {
+ // Given – a name that expands to two dead addresses.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ installResolver(new TestAddressResolverGroup(Arrays.asList(UNREACHABLE_1, UNREACHABLE_2)));
+ ChannelFactory factory = newChannelFactory();
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(
+ new DefaultEndPoint(HOSTNAME),
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE);
+
+ // Then – the future fails, and the earlier address's failure is preserved as a suppressed
+ // exception on the last one's error rather than being silently dropped.
+ assertThatStage(channelFuture)
+ .isFailed(
+ e ->
+ assertThat(e.getSuppressed())
+ .as("earlier address failures should be attached as suppressed exceptions")
+ .isNotEmpty());
+ }
+
+ @Test
+ public void should_attach_each_earlier_failure_at_most_once() {
+ // Given – three dead addresses, so there are earlier failures to carry.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ installResolver(
+ new TestAddressResolverGroup(Arrays.asList(UNREACHABLE_1, UNREACHABLE_2, UNREACHABLE_3)));
+ ChannelFactory factory = newChannelFactory();
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(
+ new DefaultEndPoint(HOSTNAME),
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE);
+
+ // Then – attaching to the error being reported mutates an object the driver does not own, so
+ // every cause has to appear at most once and the error must never suppress itself. Nothing
+ // stops two candidates from failing with the same instance -- a pipeline handler that throws a
+ // stackless singleton, say -- and such an instance would otherwise grow a suppressed entry on
+ // every connect for as long as the JVM lives.
+ assertThatStage(channelFuture)
+ .isFailed(
+ e -> {
+ List suppressed = Arrays.asList(e.getSuppressed());
+ assertThat(suppressed).isNotEmpty();
+ for (int i = 0; i < suppressed.size(); i++) {
+ assertThat(suppressed.get(i)).isNotSameAs(e);
+ for (int j = i + 1; j < suppressed.size(); j++) {
+ assertThat(suppressed.get(i)).isNotSameAs(suppressed.get(j));
+ }
+ }
+ });
+ }
+
+ @Test
+ public void should_try_next_address_when_authentication_fails_on_a_contact_point() {
+ // Given – a name expanding to two addresses, both the same live server, which asks for
+ // authentication the driver has no provider for. The endpoint is a bare contact point, so the
+ // driver does not yet know which node -- or even which cluster -- any of these addresses is.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ when(context.getAuthProvider()).thenReturn(Optional.empty());
+ SocketAddress serverAddress = SERVER_ADDRESS.resolve();
+ installResolver(new TestAddressResolverGroup(Arrays.asList(serverAddress, serverAddress)));
+ ChannelFactory factory = newChannelFactory();
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(
+ new DefaultEndPoint(HOSTNAME),
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE);
+
+ Frame requestFrame = readOutboundFrame();
+ assertThat(requestFrame.message).isInstanceOf(Options.class);
+ writeInboundFrame(requestFrame, TestResponses.supportedResponse("mock_key", "mock_value"));
+ requestFrame = readOutboundFrame();
+ assertThat(requestFrame.message).isInstanceOf(Startup.class);
+ writeInboundFrame(requestFrame, new Authenticate("mockAuthenticator"));
+
+ // Then – the loop advances. Authentication completes before the cluster-name check
+ // (ProtocolInitHandler runs STARTUP -> AUTH_RESPONSE -> GET_CLUSTER_NAME), so a stale record
+ // pointing at a foreign cluster that wants different credentials fails here rather than at the
+ // cluster-name mismatch that would have advanced. Writing off the whole name on this error
+ // would therefore make that rule unreachable in exactly the multi-record case this loop is for.
+ requestFrame = readOutboundFrame();
+ assertThat(requestFrame.message)
+ .as("the second candidate should have been attempted")
+ .isInstanceOf(Options.class);
+ writeInboundFrame(requestFrame, TestResponses.supportedResponse("mock_key", "mock_value"));
+ requestFrame = readOutboundFrame();
+ assertThat(requestFrame.message).isInstanceOf(Startup.class);
+ writeInboundFrame(requestFrame, new Authenticate("mockAuthenticator"));
+
+ // And – once both are exhausted the failure is still an AuthenticationException, with the first
+ // address's copy attached rather than dropped.
+ assertThatStage(channelFuture)
+ .isFailed(
+ e -> {
+ assertThat(e).isInstanceOf(AuthenticationException.class);
+ assertThat(e.getSuppressed())
+ .as("the first candidate's failure should be attached as suppressed")
+ .hasSize(1);
+ });
+ }
+
+ @Test
+ public void should_surface_the_authentication_failure_when_another_address_fails_on_transport() {
+ // Given – a name expanding to the live server (which asks for authentication the driver has no
+ // provider for) and a dead address. The shuffled order does not matter: whichever is tried
+ // first, the pass ends with one authentication failure and one transport failure.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ when(context.getAuthProvider()).thenReturn(Optional.empty());
+ SocketAddress serverAddress = SERVER_ADDRESS.resolve();
+ installResolver(new TestAddressResolverGroup(Arrays.asList(serverAddress, UNREACHABLE_1)));
+ ChannelFactory factory = newChannelFactory();
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(
+ new DefaultEndPoint(HOSTNAME),
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE);
+
+ Frame requestFrame = readOutboundFrame();
+ assertThat(requestFrame.message).isInstanceOf(Options.class);
+ writeInboundFrame(requestFrame, TestResponses.supportedResponse("mock_key", "mock_value"));
+ requestFrame = readOutboundFrame();
+ assertThat(requestFrame.message).isInstanceOf(Startup.class);
+ writeInboundFrame(requestFrame, new Authenticate("mockAuthenticator"));
+
+ // Then – even when the transport failure is the *last* error, propagating it would report a
+ // connect failure for what is really a rejected password: callers branch on the type of what
+ // they receive (ChannelPool#handleError, ControlConnection's auth-specific warning and its
+ // errors.connection.auth metric), and with a shuffled multi-record name which address happens
+ // to be tried last is arbitrary. The classified failure wins, and the transport one is still
+ // attached.
+ assertThatStage(channelFuture)
+ .isFailed(
+ e -> {
+ assertThat(e)
+ .as("an authentication failure must not be demoted by a later transport failure")
+ .isInstanceOf(AuthenticationException.class);
+ assertThat(e.getSuppressed())
+ .as("the transport failure should still be attached")
+ .hasSize(1);
+ assertThat(e.getSuppressed()[0]).isNotInstanceOf(AuthenticationException.class);
+ });
+ }
+
+ @Test
+ public void should_not_surface_a_cluster_name_mismatch_that_only_one_address_reported() {
+ // Given – a factory that already knows the cluster name (from a first connection), then a name
+ // expanding to the live server -- which now answers with a *different* cluster name -- and a
+ // dead address. Whichever order the shuffle picks, the pass ends with one cluster-name mismatch
+ // and one transport failure.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ ChannelFactory factory = newChannelFactory();
+ CompletionStage firstChannel =
+ factory.connect(
+ SERVER_ADDRESS,
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE);
+ completeSimpleChannelInit();
+ assertThatStage(firstChannel).isSuccess();
+ installResolver(
+ new TestAddressResolverGroup(Arrays.asList(SERVER_ADDRESS.resolve(), UNREACHABLE_1)));
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(
+ new DefaultEndPoint(HOSTNAME),
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE);
+
+ // The dead address sends nothing, so this drives the live candidate whichever position it got.
+ // The protocol version and the product type are known by now, hence no OPTIONS request.
+ writeInboundFrame(readOutboundFrame(), new Ready());
+ writeInboundFrame(readOutboundFrame(), TestResponses.clusterNameResponse("wrongClusterName"));
+
+ // Then – the mismatch must not be the failure that surfaces, not even as the last error of the
+ // pass. ChannelPool#handleError turns it into TopologyEvent.forceDown and nothing in the driver
+ // ever reverses one, while one address of a multi-record name fronting another cluster is a
+ // stale record rather than a verdict about the node. It is still attached, so nothing is lost.
+ assertThatStage(channelFuture)
+ .isFailed(
+ e -> {
+ assertThat(e)
+ .as("a mismatch from a single address must not be promoted over the others")
+ .isNotInstanceOf(ClusterNameMismatchException.class);
+ assertThat(
+ Arrays.stream(e.getSuppressed())
+ .anyMatch(s -> s instanceof ClusterNameMismatchException))
+ .as("the mismatch should still be attached as a suppressed exception")
+ .isTrue();
+ });
+ }
+
+ @Test
+ public void should_try_next_address_when_authentication_fails_on_an_identified_node() {
+ // Given – the same server and the same two addresses, but a node the driver has already
+ // identified (nodeIsIdentified = true, i.e. its host id was read from system.local/peers).
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ when(context.getAuthProvider()).thenReturn(Optional.empty());
+ SocketAddress serverAddress = SERVER_ADDRESS.resolve();
+ installResolver(new TestAddressResolverGroup(Arrays.asList(serverAddress, serverAddress)));
+ ChannelFactory factory = newChannelFactory();
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(
+ new DefaultEndPoint(HOSTNAME),
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE,
+ true);
+
+ failAuthenticationOnNextCandidate();
+
+ // Then – the loop advances, exactly as it does for a contact point: no single address's
+ // failure writes off the endpoint, and the candidate cap -- not a node-wide classification --
+ // is what bounds the cost of genuinely wrong credentials.
+ failAuthenticationOnNextCandidate();
+
+ assertThatStage(channelFuture)
+ .isFailed(
+ e -> {
+ assertThat(e).isInstanceOf(AuthenticationException.class);
+ assertThat(e.getSuppressed())
+ .as("the first candidate's failure should be attached as suppressed")
+ .hasSize(1);
+ });
+ }
+
+ /** Drives one candidate's handshake as far as the server's authentication challenge. */
+ private void failAuthenticationOnNextCandidate() {
+ Frame requestFrame = readOutboundFrame();
+ assertThat(requestFrame.message).isInstanceOf(Options.class);
+ writeInboundFrame(requestFrame, TestResponses.supportedResponse("mock_key", "mock_value"));
+ requestFrame = readOutboundFrame();
+ assertThat(requestFrame.message).isInstanceOf(Startup.class);
+ writeInboundFrame(requestFrame, new Authenticate("mockAuthenticator"));
+ }
+
+ @Test
+ public void should_stop_after_the_configured_number_of_addresses() {
+ // Given – a name expanding to three addresses, but a cap of two. Every address tried is a full
+ // connect plus handshake -- and, with wrong credentials, a rejected login -- and the
+ // reconnection fallback re-appends the contact points to every round, so an unbounded walk
+ // would repeat per contact point, per round, for as long as the session lives. The cap is what
+ // bounds that.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ when(defaultProfile.getInt(DefaultDriverOption.CONNECTION_MAX_CANDIDATE_ADDRESSES))
+ .thenReturn(2);
+ installResolver(
+ new TestAddressResolverGroup(Arrays.asList(UNREACHABLE_1, UNREACHABLE_2, UNREACHABLE_3)));
+ ChannelFactory factory = newChannelFactory();
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(
+ new DefaultEndPoint(HOSTNAME),
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE);
+
+ // Then – only two addresses were dialed. The count of suppressed entries is not what to assert
+ // on: a single dead candidate can contribute two entangled failures (the transport refusal,
+ // plus an init-write failure that PromiseCombiner attaches to it as suppressed). The set of
+ // addresses named anywhere in the aggregate is what reflects the dials.
+ assertThatStage(channelFuture)
+ .isFailed(
+ e ->
+ assertThat(mentionedUnreachableAddresses(e))
+ .as("a cap of 2 means exactly two addresses dialed")
+ .hasSize(2));
+ }
+
+ private static final Pattern UNREACHABLE_NAME = Pattern.compile("unreachable-\\d");
+
+ /** The distinct dead-address names mentioned anywhere in {@code error}'s suppressed tree. */
+ private static Set mentionedUnreachableAddresses(Throwable error) {
+ Set names = new HashSet<>();
+ Deque toVisit = new ArrayDeque<>();
+ toVisit.push(error);
+ while (!toVisit.isEmpty()) {
+ Throwable current = toVisit.pop();
+ String message = current.getMessage();
+ if (message != null) {
+ Matcher matcher = UNREACHABLE_NAME.matcher(message);
+ while (matcher.find()) {
+ names.add(matcher.group());
+ }
+ }
+ for (Throwable suppressed : current.getSuppressed()) {
+ toVisit.push(suppressed);
+ }
+ }
+ return names;
+ }
+
+ @Test
+ public void should_shuffle_candidates_without_losing_any() {
+ // The order is random per connect -- that is what spreads load across a name's records and
+ // varies the starting address between successive attempts -- but every address must survive
+ // the shuffle, since the loop's fallback walks this list.
+ ChannelFactory factory = newChannelFactory();
+
+ assertThat(
+ factory.shuffleAndLimit(
+ Arrays.asList(UNREACHABLE_1, UNREACHABLE_2, UNREACHABLE_3), true))
+ .containsExactlyInAnyOrder(UNREACHABLE_1, UNREACHABLE_2, UNREACHABLE_3);
+ }
+
+ @Test
+ public void should_order_candidates_by_the_injected_random_source() {
+ // The injection point for ordering-sensitive tests: a seeded Random produces the same
+ // permutation on two factories, so a scenario that needs a particular order picks a seed
+ // instead of depending on a sort the production code no longer performs.
+ List addresses = Arrays.asList(UNREACHABLE_1, UNREACHABLE_2, UNREACHABLE_3);
+ ChannelFactory one = newChannelFactory();
+ ChannelFactory other = newChannelFactory();
+ one.random = new Random(42);
+ other.random = new Random(42);
+
+ assertThat(one.shuffleAndLimit(addresses, true))
+ .containsExactlyElementsOf(other.shuffleAndLimit(addresses, true));
+ }
+
+ @Test
+ public void should_leave_a_single_address_alone() {
+ ChannelFactory factory = newChannelFactory();
+
+ assertThat(factory.shuffleAndLimit(Collections.singletonList(UNREACHABLE_1), true))
+ .containsExactly(UNREACHABLE_1);
+ }
+
+ @Test
+ public void should_truncate_the_shuffled_list_to_the_cap() {
+ when(defaultProfile.getInt(DefaultDriverOption.CONNECTION_MAX_CANDIDATE_ADDRESSES))
+ .thenReturn(2);
+ ChannelFactory factory = newChannelFactory();
+ List addresses = Arrays.asList(UNREACHABLE_1, UNREACHABLE_2, UNREACHABLE_3);
+
+ List capped = factory.shuffleAndLimit(addresses, true);
+
+ assertThat(capped).hasSize(2);
+ assertThat(addresses).containsAll(capped);
+ }
+
+ @Test
+ public void should_clamp_the_cap_to_at_least_one_address() {
+ // Zero or a negative value cannot mean "dial nothing" -- the attempt would fail without ever
+ // trying an address. It degrades to the pre-multi-address behavior of one address per attempt.
+ when(defaultProfile.getInt(DefaultDriverOption.CONNECTION_MAX_CANDIDATE_ADDRESSES))
+ .thenReturn(0);
+ ChannelFactory factory = newChannelFactory();
+
+ assertThat(factory.shuffleAndLimit(Arrays.asList(UNREACHABLE_1, UNREACHABLE_2), true))
+ .hasSize(1);
+ }
+
+ @Test
+ public void should_not_shuffle_when_the_addresses_are_not_interchangeable() {
+ // A name that may denote different hosts -- what an AddressTranslator can hand back, and
+ // SubnetAddressTranslator does by default -- must keep the resolver's order: a random one would
+ // scatter a single Node's pool across hosts that routing, shard awareness and per-node metrics
+ // all attribute to that one node. Keeping the order makes such a pool converge on one address,
+ // as it did before multi-address support, while the rest of the list still serves as fallback.
+ List addresses = Arrays.asList(UNREACHABLE_1, UNREACHABLE_2, UNREACHABLE_3);
+ ChannelFactory factory = newChannelFactory();
+ // A seed that does permute this list, so the assertion below fails if the shuffle still runs.
+ factory.random = new Random(42);
+ assertThat(factory.shuffleAndLimit(addresses, true)).isNotEqualTo(addresses);
+
+ assertThat(factory.shuffleAndLimit(addresses, false)).containsExactlyElementsOf(addresses);
+ }
+
+ @Test
+ public void should_still_cap_the_candidates_when_the_order_is_kept() {
+ // The cap is what bounds the cost of one attempt, and that applies whether or not the addresses
+ // were shuffled.
+ when(defaultProfile.getInt(DefaultDriverOption.CONNECTION_MAX_CANDIDATE_ADDRESSES))
+ .thenReturn(2);
+ ChannelFactory factory = newChannelFactory();
+
+ assertThat(
+ factory.shuffleAndLimit(
+ Arrays.asList(UNREACHABLE_1, UNREACHABLE_2, UNREACHABLE_3), false))
+ .containsExactly(UNREACHABLE_1, UNREACHABLE_2);
+ }
+
+ // ---- addressesAreInterchangeable() and the two booleans derived from it ----
+
+ /** The endpoints below only have to exist; nothing in this section connects to them. */
+ private static final InetSocketAddress SOME_ADDRESS =
+ InetSocketAddress.createUnresolved("node.example.com", 9042);
+
+ @Test
+ public void should_report_a_proxy_endpoint_interchangeable() {
+ // An SNI proxy routes by server name, so every one of its A-records reaches the same node.
+ assertThat(
+ ChannelFactory.addressesAreInterchangeable(
+ new SniEndPoint(SOME_ADDRESS, "server-name"), SOME_ADDRESS))
+ .isTrue();
+ }
+
+ @Test
+ public void should_not_report_a_plain_endpoint_interchangeable() {
+ // The case the flag exists to exclude: a DefaultEndPoint holding a name an AddressTranslator
+ // supplied carries no guarantee that its addresses are one server.
+ assertThat(
+ ChannelFactory.addressesAreInterchangeable(
+ new DefaultEndPoint(SOME_ADDRESS), SOME_ADDRESS))
+ .isFalse();
+ }
+
+ @Test
+ public void should_not_report_a_third_party_endpoint_interchangeable() {
+ // An EndPoint that does not implement PinnableEndPoint cannot say, and the conservative reading
+ // is the one that assumes nothing.
+ EndPoint thirdParty = mock(EndPoint.class);
+ when(thirdParty.resolve()).thenReturn(SOME_ADDRESS);
+
+ assertThat(ChannelFactory.addressesAreInterchangeable(thirdParty, SOME_ADDRESS)).isFalse();
+ }
+
+ @Test
+ public void should_spread_unless_an_identified_node_says_its_addresses_are_not_one_server() {
+ // A contact point always spreads: nothing is known about its addresses -- they may be
+ // different nodes -- so there is no node identity to preserve.
+ assertThat(ChannelFactory.spreadAcrossAddresses(false, false)).isTrue();
+ assertThat(ChannelFactory.spreadAcrossAddresses(false, true)).isTrue();
+ // An identified node spreads only where its addresses are interchangeable.
+ assertThat(ChannelFactory.spreadAcrossAddresses(true, true)).isTrue();
+ assertThat(ChannelFactory.spreadAcrossAddresses(true, false)).isFalse();
+ }
+
+ @Test
+ public void should_treat_one_server_as_answering_everywhere_only_on_identity_or_interchange() {
+ // Not the negation of the above, and the difference is the whole of DRIVER-201's rolling-
+ // upgrade case: an unidentified contact point on a plain multi-record name is spread across
+ // its addresses *and* must not let one address's rejection speak for the others.
+ assertThat(ChannelFactory.sameServerAtEveryAddress(false, false)).isFalse();
+ assertThat(ChannelFactory.sameServerAtEveryAddress(false, true)).isTrue();
+ assertThat(ChannelFactory.sameServerAtEveryAddress(true, false)).isTrue();
+ assertThat(ChannelFactory.sameServerAtEveryAddress(true, true)).isTrue();
+ }
+
+ // ---- reattachHostname() ---------------------------------------------------
+
+ @Test
+ public void should_reattach_queried_hostname_to_nameless_resolved_address() throws Exception {
+ // A custom resolver may build its results from raw address bytes; the queried name must be
+ // re-attached so TLS hostname validation checks the configured name (not the IP or a PTR
+ // record) and reading the host name never triggers a reverse lookup on the event loop.
+ InetSocketAddress candidate =
+ new InetSocketAddress(InetAddress.getByAddress(new byte[] {10, 0, 0, 1}), 9999);
+
+ InetSocketAddress result =
+ (InetSocketAddress) ChannelFactory.reattachHostname(HOSTNAME, candidate);
+
+ assertThat(result.isUnresolved()).isFalse();
+ // getHostString() never looks anything up; getHostName() reverse-resolves a *nameless*
+ // address, so it returning the queried name proves the name is embedded, not looked up.
+ assertThat(result.getHostString()).isEqualTo("test.cluster.fake");
+ assertThat(result.getHostName()).isEqualTo("test.cluster.fake");
+ assertThat(result.getAddress().getHostAddress()).isEqualTo("10.0.0.1");
+ // The candidate's port wins over the original's: a resolver may remap ports too.
+ assertThat(result.getPort()).isEqualTo(9999);
+ // Equality is unchanged (a resolved InetSocketAddress compares IP bytes + port only), so
+ // pinning and the pin-equality shortcuts behave exactly as with the raw candidate.
+ assertThat(result).isEqualTo(candidate);
+ }
+
+ @Test
+ public void should_override_resolver_provided_hostname_with_queried_name() throws Exception {
+ // A resolver may label its results with a canonical/CNAME name of its own. That name would end
+ // up on the pinned endpoint and hence be the one TLS hostname verification checks the server
+ // certificate against, so the name the user configured has to win over it.
+ InetSocketAddress candidate =
+ new InetSocketAddress(
+ InetAddress.getByAddress("cname.example.fake", new byte[] {10, 0, 0, 1}), 9042);
+
+ InetSocketAddress result =
+ (InetSocketAddress) ChannelFactory.reattachHostname(HOSTNAME, candidate);
+
+ assertThat(result.getHostString()).isEqualTo("test.cluster.fake");
+ assertThat(result.getAddress().getHostAddress()).isEqualTo("10.0.0.1");
+ assertThat(result.getPort()).isEqualTo(9042);
+ }
+
+ @Test
+ public void should_pass_candidate_through_when_it_already_carries_the_queried_name()
+ throws Exception {
+ // The common case: the JDK and Netty-DNS resolvers attach the queried name themselves, so
+ // there is nothing to rebuild.
+ InetSocketAddress candidate =
+ new InetSocketAddress(
+ InetAddress.getByAddress("test.cluster.fake", new byte[] {10, 0, 0, 1}), 9042);
+
+ assertThat(ChannelFactory.reattachHostname(HOSTNAME, candidate)).isSameAs(candidate);
+ }
+
+ @Test
+ public void should_pass_non_inet_candidate_through() {
+ // The local-transport addresses these unit tests connect over must never be touched.
+ assertThat(ChannelFactory.reattachHostname(HOSTNAME, UNREACHABLE_1)).isSameAs(UNREACHABLE_1);
+ }
+
+ @Test
+ public void should_pass_redirected_candidate_through_when_original_is_an_ip_literal()
+ throws Exception {
+ // An original written as an IP literal has no name to carry over, and inventing one from the
+ // literal would be worse than leaving the candidate alone: a resolver is free to redirect it to
+ // a different IP, which would then be labelled with the literal form of a *different* address.
+ InetSocketAddress original = InetSocketAddress.createUnresolved("127.0.0.1", 9042);
+ InetSocketAddress candidate =
+ new InetSocketAddress(InetAddress.getByAddress(new byte[] {10, 0, 0, 1}), 9042);
+
+ assertThat(ChannelFactory.reattachHostname(original, candidate)).isSameAs(candidate);
+ assertThat(AddressUtils.carriesName(original)).isFalse();
+ assertThat(AddressUtils.carriesName(InetSocketAddress.createUnresolved("10.0.0.1", 9042)))
+ .isFalse();
+ }
+
+ @Test
+ public void should_reattach_the_literal_when_the_resolver_returns_the_same_address()
+ throws Exception {
+ // Not a no-op, even though the label says the same thing the bytes do: a *nameless* address is
+ // what InetSocketAddress#getHostName() answers with a blocking reverse lookup, so leaving the
+ // candidate unlabelled is what would send DefaultSslEngineFactory to a PTR record instead of
+ // the
+ // literal the operator configured. Before multi-address support the contact point stayed
+ // unresolved and the literal came back with no lookup at all; labelling restores exactly that.
+ InetSocketAddress original = InetSocketAddress.createUnresolved("127.0.0.1", 9042);
+ InetSocketAddress candidate =
+ new InetSocketAddress(InetAddress.getByAddress(new byte[] {127, 0, 0, 1}), 9042);
+
+ InetSocketAddress result =
+ (InetSocketAddress) ChannelFactory.reattachHostname(original, candidate);
+
+ assertThat(result.getHostString()).isEqualTo("127.0.0.1");
+ // The point of the exercise: getHostName() is a field read answering the configured literal,
+ // not a reverse lookup.
+ assertThat(result.getAddress().getHostName()).isEqualTo("127.0.0.1");
+ assertThat(result.getAddress().getHostAddress()).isEqualTo("127.0.0.1");
+ // And the labelled candidate still reports as a literal, so nothing downstream mistakes it for
+ // a name.
+ assertThat(AddressUtils.carriesName(result)).isFalse();
+ }
+
+ @Test
+ public void should_match_a_non_canonical_ipv6_literal_against_the_candidate() throws Exception {
+ // The literal is compared as an address, not as a string: "::1" and the candidate's
+ // getHostAddress() ("0:0:0:0:0:0:0:1") never compare equal as text.
+ InetSocketAddress original = InetSocketAddress.createUnresolved("::1", 9042);
+ byte[] loopback = new byte[16];
+ loopback[15] = 1;
+ InetSocketAddress candidate = new InetSocketAddress(InetAddress.getByAddress(loopback), 9042);
+
+ InetSocketAddress result =
+ (InetSocketAddress) ChannelFactory.reattachHostname(original, candidate);
+
+ assertThat(result.getHostString()).isEqualTo("::1");
+ assertThat(result.getAddress()).isEqualTo(candidate.getAddress());
+ }
+
+ // ---- materializeLiteral() --------------------------------------------------
+
+ @Test
+ public void should_materialize_an_unresolved_ipv4_literal() {
+ // A literal needs no name service, so an endpoint holding one has no business failing where
+ // resolution is unavailable -- and endpoints hold one routinely now that contact points are
+ // kept unresolved whatever they were written as.
+ InetSocketAddress literal = InetSocketAddress.createUnresolved("127.0.0.1", 9042);
+
+ InetSocketAddress result = (InetSocketAddress) ChannelFactory.materializeLiteral(literal);
+
+ assertThat(result.isUnresolved()).isFalse();
+ assertThat(result.getAddress().getAddress()).isEqualTo(new byte[] {127, 0, 0, 1});
+ assertThat(result.getPort()).isEqualTo(9042);
+ // Labelled with the literal, not left nameless: getHostName() on a nameless address is a
+ // blocking reverse lookup, and DefaultSslEngineFactory would validate the certificate against
+ // whatever PTR record it returned instead of what the operator configured.
+ assertThat(result.getHostName()).isEqualTo("127.0.0.1");
+ assertThat(AddressUtils.carriesName(result)).isFalse();
+ }
+
+ @Test
+ public void should_materialize_a_bracketed_ipv6_literal() {
+ // The spelling AddressUtils#extract preserves: it splits a contact point on its last colon, so
+ // "[::1]:9042" arrives with the brackets still on.
+ InetSocketAddress literal = InetSocketAddress.createUnresolved("[::1]", 9042);
+
+ InetSocketAddress result = (InetSocketAddress) ChannelFactory.materializeLiteral(literal);
+
+ byte[] loopback = new byte[16];
+ loopback[15] = 1;
+ assertThat(result.isUnresolved()).isFalse();
+ assertThat(result.getAddress().getAddress()).isEqualTo(loopback);
+ // Without the brackets: InetAddress.getByAddress(String, byte[]) strips them from the label it
+ // is handed. Still a literal, so getHostName() still answers without a reverse lookup, which is
+ // the only property this label exists for.
+ assertThat(result.getHostName()).isEqualTo("::1");
+ }
+
+ @Test
+ public void should_not_materialize_a_hostname() {
+ // The whole point of the diagnostic this sits in front of: a name genuinely needs a resolver,
+ // and passing it through would fail later inside Netty with UnresolvedAddressException, naming
+ // neither the address nor the reason.
+ assertThat(
+ ChannelFactory.materializeLiteral(
+ InetSocketAddress.createUnresolved("node.example.com", 9042)))
+ .isNull();
+ }
+
+ @Test
+ public void should_not_materialize_an_already_resolved_address() throws Exception {
+ // Nothing to do; the caller passes it through untouched.
+ assertThat(
+ ChannelFactory.materializeLiteral(
+ new InetSocketAddress(InetAddress.getByAddress(new byte[] {10, 0, 0, 1}), 9042)))
+ .isNull();
+ }
+
+ @Test
+ public void should_reattach_the_name_of_an_already_resolved_original() throws Exception {
+ // A resolved original reaches this only because a custom resolver reported it as unresolved in
+ // order to redirect it, and its name is re-attached like any other. `new
+ // InetSocketAddress(String, int)` resolves eagerly and keeps the name it was given, so this is
+ // the shape an AddressTranslator or a third-party EndPoint hands over -- and leaving the
+ // redirected candidate nameless is not neutral: DefaultSslEngineFactory would then take the TLS
+ // peer host from a blocking reverse lookup and validate the certificate against a PTR record
+ // instead of the configured DNS SAN, which is not what the pre-multi-address path did.
+ InetSocketAddress original = new InetSocketAddress("localhost", 9042);
+ InetSocketAddress candidate =
+ new InetSocketAddress(InetAddress.getByAddress(new byte[] {10, 0, 0, 1}), 9042);
+
+ assertThat(original.isUnresolved()).isFalse();
+ assertThat(AddressUtils.carriesName(original)).isTrue();
+
+ InetSocketAddress result =
+ (InetSocketAddress) ChannelFactory.reattachHostname(original, candidate);
+
+ assertThat(result.getHostString()).isEqualTo("localhost");
+ assertThat(result.getAddress().getAddress()).isEqualTo(new byte[] {10, 0, 0, 1});
+ assertThat(result.getPort()).isEqualTo(9042);
+ }
+
+ @Test
+ public void should_leave_a_resolved_original_alone_when_it_carries_no_name() throws Exception {
+ // The other half: a resolved original whose InetAddress has no cached hostName renders the IP
+ // literal, so it takes the literal branch and only matches the address it denotes. A redirect
+ // stays unlabelled rather than being given a name that resolves elsewhere.
+ InetSocketAddress original =
+ new InetSocketAddress(InetAddress.getByAddress(new byte[] {10, 0, 0, 2}), 9042);
+ InetSocketAddress candidate =
+ new InetSocketAddress(InetAddress.getByAddress(new byte[] {10, 0, 0, 1}), 9042);
+
+ assertThat(AddressUtils.carriesName(original)).isFalse();
+ assertThat(ChannelFactory.reattachHostname(original, candidate)).isSameAs(candidate);
+ }
+
+ @Test
+ public void should_reattach_hostname_to_nameless_ipv6_address() throws Exception {
+ byte[] loopback = new byte[16];
+ loopback[15] = 1; // ::1
+ InetSocketAddress candidate = new InetSocketAddress(InetAddress.getByAddress(loopback), 9042);
+
+ InetSocketAddress result =
+ (InetSocketAddress) ChannelFactory.reattachHostname(HOSTNAME, candidate);
+
+ assertThat(result.getHostString()).isEqualTo("test.cluster.fake");
+ assertThat(result.getAddress()).isEqualTo(candidate.getAddress());
+ assertThat(result.getPort()).isEqualTo(9042);
+ }
+
+ @Test
+ public void should_label_a_candidate_from_a_bracketed_ipv6_literal_original() throws Exception {
+ // A contact point written "[2001:db8::5]:9042" reaches here with its brackets on: extract()
+ // splits on the last colon and keeps everything before it. carriesName() classifies that as a
+ // literal, so this takes the IP-literal branch -- and the branch has to unwrap the brackets
+ // before parsing, because InetAddresses.forString rejects the bracketed form outright.
+ // Failing to parse would return the candidate unlabelled and hand getHostName() a reverse
+ // lookup, which is the outcome the branch exists to prevent.
+ byte[] bytes = InetAddress.getByName("2001:db8::5").getAddress();
+ InetSocketAddress original = InetSocketAddress.createUnresolved("[2001:db8::5]", 9042);
+ InetSocketAddress candidate =
+ new InetSocketAddress(InetAddress.getByAddress(null, bytes), 9042);
+
+ InetSocketAddress result =
+ (InetSocketAddress) ChannelFactory.reattachHostname(original, candidate);
+
+ // Labelled with the literal, and with no lookup. The brackets are gone because
+ // InetAddress.getByAddress(String, byte[]) strips a surrounding pair from the name it is
+ // given -- which is the canonical outcome: getHostString() now answers a bare literal, so
+ // carriesName() reports it as a literal on the way back too.
+ assertThat(result.getHostString()).isEqualTo("2001:db8::5");
+ assertThat(result.getAddress().getAddress()).isEqualTo(bytes);
+ assertThat(result.getPort()).isEqualTo(9042);
+ }
+
+ @Test
+ public void should_not_label_a_redirected_candidate_from_a_bracketed_original() throws Exception {
+ // The redirect guard has to survive the unwrapping: a candidate that is not the address the
+ // bracketed literal denotes must come back unlabelled. Before brackets were recognised this
+ // case took the name-wins branch instead, which relabels unconditionally.
+ InetSocketAddress original = InetSocketAddress.createUnresolved("[2001:db8::5]", 9042);
+ InetSocketAddress candidate =
+ new InetSocketAddress(
+ InetAddress.getByAddress(null, InetAddress.getByName("2001:db8::6").getAddress()),
+ 9042);
+
+ assertThat(ChannelFactory.reattachHostname(original, candidate)).isSameAs(candidate);
+ }
+
+ @Test
+ public void should_label_a_candidate_from_a_bracketed_and_zoned_ipv6_literal_original()
+ throws Exception {
+ // Brackets *and* a zone: the brackets have to come off first, or splitting on '%' leaves the
+ // closing bracket inside the zone and the opening one inside the literal, and neither half
+ // parses.
+ byte[] linkLocal = new byte[16];
+ linkLocal[0] = (byte) 0xfe;
+ linkLocal[1] = (byte) 0x80;
+ linkLocal[15] = 1;
+ InetSocketAddress original = InetSocketAddress.createUnresolved("[fe80::1%eth0]", 9042);
+ InetSocketAddress candidate =
+ new InetSocketAddress(Inet6Address.getByAddress(null, linkLocal, 3), 9042);
+
+ InetSocketAddress result =
+ (InetSocketAddress) ChannelFactory.reattachHostname(original, candidate);
+
+ // Bare literal with the zone intact -- getByAddress() strips only the brackets.
+ assertThat(result.getHostString()).isEqualTo("fe80::1%eth0");
+ assertThat(result.getAddress().getAddress()).isEqualTo(linkLocal);
+ }
+
+ @Test
+ public void should_label_a_candidate_from_a_zoned_ipv6_literal_original() throws Exception {
+ // The original is a *literal* with a zone, which carriesName() reports as a literal (Guava's
+ // isInetAddress accepts a zone suffix), so reattachHostname takes its IP-literal branch. That
+ // branch cannot hand the string to InetAddresses.forString: Guava resolves the zone against the
+ // local interfaces and throws when it does not name one -- it rejects even "%lo" on a host that
+ // has an lo interface. Failing there would return the candidate unlabelled, and getHostName()
+ // would then answer with a reverse lookup, which is precisely what this branch exists to stop.
+ byte[] linkLocal = new byte[16];
+ linkLocal[0] = (byte) 0xfe;
+ linkLocal[1] = (byte) 0x80;
+ linkLocal[15] = 1;
+ InetSocketAddress original = InetSocketAddress.createUnresolved("fe80::1%eth0", 9042);
+ InetSocketAddress candidate =
+ new InetSocketAddress(Inet6Address.getByAddress(null, linkLocal, 3), 9042);
+
+ InetSocketAddress result =
+ (InetSocketAddress) ChannelFactory.reattachHostname(original, candidate);
+
+ // Labelled with the literal exactly as configured, zone included, and with no lookup.
+ assertThat(result.getHostString()).isEqualTo("fe80::1%eth0");
+ assertThat(result.getAddress().getAddress()).isEqualTo(linkLocal);
+ assertThat(result.getPort()).isEqualTo(9042);
+ }
+
+ @Test
+ public void should_not_label_a_candidate_that_is_a_different_address_from_a_zoned_original()
+ throws Exception {
+ // The redirect guard still has to hold on the zoned path: a candidate that is not the address
+ // the literal denotes must come back unlabelled.
+ byte[] other = new byte[16];
+ other[0] = (byte) 0xfe;
+ other[1] = (byte) 0x80;
+ other[15] = 2;
+ InetSocketAddress original = InetSocketAddress.createUnresolved("fe80::1%eth0", 9042);
+ InetSocketAddress candidate =
+ new InetSocketAddress(Inet6Address.getByAddress(null, other, 3), 9042);
+
+ assertThat(ChannelFactory.reattachHostname(original, candidate)).isSameAs(candidate);
+ }
+
+ @Test
+ public void should_keep_the_scope_when_reattaching_to_a_scoped_ipv6_address() throws Exception {
+ // A link-local address only points anywhere together with its zone, so the queried name has to
+ // be re-attached without dropping the scope. InetAddress.getByAddress(host, bytes) cannot carry
+ // one, but Inet6Address.getByAddress(host, bytes, scopeId) can.
+ byte[] linkLocal = new byte[16];
+ linkLocal[0] = (byte) 0xfe;
+ linkLocal[1] = (byte) 0x80;
+ linkLocal[15] = 1;
+ InetSocketAddress candidate =
+ new InetSocketAddress(Inet6Address.getByAddress(null, linkLocal, 3), 9042);
+
+ InetSocketAddress result =
+ (InetSocketAddress) ChannelFactory.reattachHostname(HOSTNAME, candidate);
+
+ assertThat(result.getHostString()).isEqualTo("test.cluster.fake");
+ assertThat(result.getAddress()).isInstanceOf(Inet6Address.class);
+ assertThat(((Inet6Address) result.getAddress()).getScopeId()).isEqualTo(3);
+ assertThat(result.getAddress().getAddress()).isEqualTo(linkLocal);
+ assertThat(result.getPort()).isEqualTo(9042);
+ }
+
+ @Test
+ public void should_keep_the_zone_of_an_interface_scoped_ipv6_address() throws Exception {
+ // An address built from a NetworkInterface rather than from an index must keep pointing into
+ // the
+ // same zone. The numeric scope the JDK derived at construction is what the connect goes on, so
+ // carrying that over is enough; only the interface name, a toString() detail, is not.
+ Inet6Address linkLocal = firstInterfaceScopedIpv6Address();
+ assumeThat(linkLocal).as("no interface-scoped IPv6 address on this host").isNotNull();
+ InetSocketAddress candidate = new InetSocketAddress(linkLocal, 9042);
+
+ InetSocketAddress result =
+ (InetSocketAddress) ChannelFactory.reattachHostname(HOSTNAME, candidate);
+
+ assertThat(result.getHostString()).isEqualTo("test.cluster.fake");
+ assertThat(((Inet6Address) result.getAddress()).getScopeId()).isEqualTo(linkLocal.getScopeId());
+ assertThat(result.getAddress().getAddress()).isEqualTo(linkLocal.getAddress());
+ }
+
+ /** An interface-scoped IPv6 address of this host, or null if it has none. */
+ private static Inet6Address firstInterfaceScopedIpv6Address() throws Exception {
+ for (NetworkInterface nif : Collections.list(NetworkInterface.getNetworkInterfaces())) {
+ for (InetAddress address : Collections.list(nif.getInetAddresses())) {
+ if (address instanceof Inet6Address
+ && ((Inet6Address) address).getScopedInterface() != null) {
+ return (Inet6Address) address;
+ }
+ }
+ }
+ return null;
+ }
+
+ @Test
+ public void should_fail_future_when_endpoint_resolve_throws() {
+ // ChannelFactory calls EndPoint.resolve() directly on the caller thread, so a third-party
+ // implementation that throws must surface as a failed future rather than an escaping exception.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ ChannelFactory factory = newChannelFactory();
+ IllegalStateException failure = new IllegalStateException("resolve() blew up");
+
+ CompletionStage channelFuture =
+ factory.connect(
+ new ThrowingEndPoint(failure),
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE);
+
+ assertThatStage(channelFuture).isFailed(e -> assertThat(e).isSameAs(failure));
+ }
+
+ @Test
+ public void should_fail_future_when_addresses_are_interchangeable_throws() {
+ // The other implementation-supplied method connect() calls synchronously, and the one that is
+ // easy to miss: it decides whether the resolved addresses may be shuffled. Escaping here would
+ // be worse than escaping from resolve(), because ControlConnection#reconnect neither wraps its
+ // connect() call nor catches inside the whenCompleteAsync callback that drives the recursive
+ // ones -- the throwable would be swallowed and Reconnection left stuck ATTEMPT_IN_PROGRESS,
+ // with no further attempts.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ ChannelFactory factory = newChannelFactory();
+ IllegalStateException failure =
+ new IllegalStateException("addressesAreInterchangeable() blew up");
+
+ CompletionStage channelFuture =
+ factory.connect(
+ new ThrowingSpreadEndPoint(failure),
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE,
+ // Either value would do: the endpoint is consulted on every connect, because both of
+ // the booleans derived from its answer need it -- an unidentified contact point still
+ // has to know whether one address's rejection speaks for the rest.
+ false);
+
+ assertThatStage(channelFuture).isFailed(e -> assertThat(e).isSameAs(failure));
+ }
+
+ @Test
+ public void should_fail_future_when_endpoint_spread_check_throws_an_error() {
+ // The guard catches Throwable, not Exception. An endpoint supplied by someone else can fail
+ // with an Error just as readily as with an exception -- NoClassDefFoundError or
+ // ExceptionInInitializerError out of lazy class initialization in a shaded or OSGi deployment,
+ // AssertionError under -ea -- and the outcome of letting one escape is the same hang: nothing
+ // upstream completes the future, so the attempt sits with Reconnection stuck in
+ // ATTEMPT_IN_PROGRESS and no further attempt is ever scheduled.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ ChannelFactory factory = newChannelFactory();
+ Error failure = new NoClassDefFoundError("com/example/CustomEndPointSupport");
+
+ CompletionStage channelFuture =
+ factory.connect(
+ new ThrowingSpreadEndPoint(failure),
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE,
+ true);
+
+ assertThatStage(channelFuture).isFailed(e -> assertThat(e).isSameAs(failure));
+ }
+
+ @Test
+ public void should_fail_future_when_endpoint_resolve_returns_null() {
+ // EndPoint.resolve() is contractually non-null, but a broken third-party implementation must
+ // fail fast rather than NPE later inside an event-loop task, which would leave the future
+ // hanging.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ ChannelFactory factory = newChannelFactory();
+
+ CompletionStage channelFuture =
+ factory.connect(
+ new NullResolvingEndPoint(),
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE);
+
+ assertThatStage(channelFuture)
+ .isFailed(
+ e ->
+ assertThat(e)
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("returned null"));
+ }
+
+ @Test
+ public void should_fail_future_when_event_loop_group_is_rejecting_tasks()
+ throws InterruptedException {
+ // Resolution is dispatched to an I/O event loop; if the group is already shutting down, that
+ // dispatch is rejected synchronously. The rejection must fail the future rather than escape to
+ // the caller (connect() never used to throw) or leave the future hanging.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ ChannelFactory factory = newChannelFactory();
+ clientGroup.shutdownGracefully(0, 0, TimeUnit.MILLISECONDS).sync();
+
+ CompletionStage channelFuture =
+ factory.connect(
+ new DefaultEndPoint(HOSTNAME),
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE);
+
+ assertThatStage(channelFuture)
+ .isFailed(e -> assertThat(e).isInstanceOf(RejectedExecutionException.class));
+ }
+
+ /** An endpoint whose {@link EndPoint#resolve()} throws, standing in for a broken third party. */
+ private static class ThrowingEndPoint implements EndPoint {
+
+ private final RuntimeException failure;
+
+ ThrowingEndPoint(RuntimeException failure) {
+ this.failure = failure;
+ }
+
+ @NonNull
+ @Override
+ public SocketAddress resolve() {
+ throw failure;
+ }
+
+ @NonNull
+ @Override
+ public String asMetricPrefix() {
+ return "test";
+ }
+ }
+
+ /**
+ * A {@link PinnableEndPoint} whose {@code addressesAreInterchangeable()} throws, standing in for
+ * any implementation-supplied override that can fail -- {@code ClientRoutesEndPoint}'s reaches
+ * the topology monitor and catches only {@link IllegalStateException}.
+ */
+ private static class ThrowingSpreadEndPoint implements PinnableEndPoint {
+
+ private final Throwable failure;
+
+ ThrowingSpreadEndPoint(Throwable failure) {
+ this.failure = failure;
+ }
+
+ @NonNull
+ @Override
+ public SocketAddress resolve() {
+ return InetSocketAddress.createUnresolved("test.cluster.fake", 9042);
+ }
+
+ @Override
+ public boolean addressesAreInterchangeable(@NonNull SocketAddress resolvedAddress) {
+ if (failure instanceof Error) {
+ throw (Error) failure;
+ }
+ throw (RuntimeException) failure;
+ }
+
+ @NonNull
+ @Override
+ public EndPoint pinTo(@NonNull SocketAddress resolvedAddress) {
+ return this;
+ }
+
+ @NonNull
+ @Override
+ public String asMetricPrefix() {
+ return "test";
+ }
+ }
+
+ /** A broken third-party endpoint that violates {@code resolve()}'s non-null contract. */
+ private static class NullResolvingEndPoint implements EndPoint {
+
+ @NonNull
+ @Override
+ @SuppressWarnings("NullAway") // deliberately broken, that is the point of the test
+ public SocketAddress resolve() {
+ return null;
+ }
+
+ @NonNull
+ @Override
+ public String asMetricPrefix() {
+ return "test";
+ }
+ }
+}
diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryNettyResolverTest.java b/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryNettyResolverTest.java
new file mode 100644
index 00000000000..78db1aa8bc8
--- /dev/null
+++ b/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryNettyResolverTest.java
@@ -0,0 +1,593 @@
+/*
+ * 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.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+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.internal.core.metadata.DefaultEndPoint;
+import com.datastax.oss.driver.internal.core.metrics.NoopNodeMetricUpdater;
+import io.netty.bootstrap.Bootstrap;
+import io.netty.channel.DefaultEventLoopGroup;
+import io.netty.channel.local.LocalAddress;
+import io.netty.resolver.AddressResolver;
+import io.netty.resolver.AddressResolverGroup;
+import io.netty.util.concurrent.EventExecutor;
+import io.netty.util.concurrent.Future;
+import io.netty.util.concurrent.Promise;
+import java.net.InetSocketAddress;
+import java.net.SocketAddress;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.TimeUnit;
+import org.junit.Test;
+
+/**
+ * Verifies that {@link ChannelFactory} expands unresolved candidate addresses through Netty's
+ * configured {@link AddressResolverGroup}, rather than doing its own JVM DNS lookup.
+ *
+ * This is what keeps a custom resolver installed via {@link
+ * com.datastax.oss.driver.internal.core.context.NettyOptions#afterBootstrapInitialized(Bootstrap)}
+ * effective: before multi-address support, an unresolved address was handed straight to {@code
+ * Bootstrap.connect()} and Netty's resolver expanded it, so resolving anywhere else would silently
+ * bypass the user's configuration.
+ */
+public class ChannelFactoryNettyResolverTest extends ChannelFactoryTestBase {
+
+ // A local address that no server is bound to: connecting to it fails immediately.
+ private static final SocketAddress UNREACHABLE =
+ new LocalAddress(ChannelFactoryNettyResolverTest.class.getSimpleName() + "-unreachable");
+
+ /** The hostname the endpoint reports, and that only the custom resolver knows how to expand. */
+ private static final InetSocketAddress HOSTNAME =
+ InetSocketAddress.createUnresolved("test.cluster.fake", 9042);
+
+ /** What a resolver must never hand back from {@code resolveAll}, but might. */
+ private static final InetSocketAddress STILL_UNRESOLVED =
+ InetSocketAddress.createUnresolved("still.unresolved.fake", 9042);
+
+ @Test
+ public void should_expand_unresolved_address_through_the_custom_netty_resolver() {
+ // Given – a resolver that maps the hostname to an unreachable address followed by the running
+ // local server, mimicking a DNS round-robin entry whose first record is dead.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ TestAddressResolverGroup resolverGroup =
+ new TestAddressResolverGroup(Arrays.asList(UNREACHABLE, SERVER_ADDRESS.resolve()));
+ installResolver(resolverGroup);
+ ChannelFactory factory = newChannelFactory();
+ // Keeping the resolver's order is what makes success here mean anything: it puts the dead
+ // record first, so the connect can only succeed by falling back to the second address. Left to
+ // the production shuffle the dialled order is a coin flip for a two-element list, and this test
+ // would pass about half the time even with the fallback in tryNextCandidate() broken -- those
+ // runs simply dial the reachable address first and never exercise it.
+ factory.random = new KeepResolverOrder();
+
+ // When – the endpoint itself performs no resolution at all; it just yields the hostname.
+ CompletionStage channelFuture =
+ factory.connect(
+ new DefaultEndPoint(HOSTNAME),
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE);
+ // The handshake only happens once we fall back to the reachable second address.
+ completeSimpleChannelInit();
+
+ // Then – the custom resolver was consulted for the hostname, and the dead first record did
+ // not end the attempt: the connection survived it by trying the address behind it.
+ assertThatStage(channelFuture).isSuccess();
+ assertThat(resolverGroup.queried)
+ .as("the custom Netty resolver must be the one expanding the hostname")
+ .containsExactly(HOSTNAME);
+ }
+
+ @Test
+ public void should_fail_when_the_custom_resolver_cannot_resolve_the_only_candidate() {
+ // Given – a resolver that fails every lookup.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ TestAddressResolverGroup resolverGroup = new TestAddressResolverGroup(null);
+ installResolver(resolverGroup);
+ ChannelFactory factory = newChannelFactory();
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(
+ new DefaultEndPoint(HOSTNAME),
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE);
+
+ // Then – no candidate survived resolution, so the connect fails with the resolver's own cause
+ // rather than, say, an empty-candidate-list error.
+ assertThatStage(channelFuture)
+ .isFailed(e -> assertThat(e).hasMessageContaining("mock resolver failure"));
+ }
+
+ @Test
+ public void should_fail_with_a_diagnosable_error_when_every_expanded_address_is_unresolved() {
+ // Given – a resolver that "expands" the hostname to another unresolved address. A redirecting
+ // resolver can do this by rewriting the host without resolving it, and nothing downstream will
+ // resolve it either: connectToAddress() uses a bootstrap clone with disableResolver(), so Netty
+ // would raise UnresolvedAddressException from inside doConnect, naming neither the address nor
+ // the reason nothing resolved it -- for every connect of the whole session.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ installResolver(new TestAddressResolverGroup(Collections.singletonList(STILL_UNRESOLVED)));
+ ChannelFactory factory = newChannelFactory();
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(
+ new DefaultEndPoint(HOSTNAME),
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE);
+
+ // Then – the failure says which endpoint and which resolver produced it, as the pass-through
+ // paths already did (see ChannelFactory#unusableWithoutResolution).
+ assertThatStage(channelFuture)
+ .isFailed(
+ e -> {
+ assertThat(e).isInstanceOf(IllegalStateException.class);
+ assertThat(e.getMessage()).contains("test.cluster.fake");
+ assertThat(e.getMessage()).contains("TestAddressResolverGroup");
+ assertThat(e.getMessage()).contains("unresolved");
+ });
+ }
+
+ @Test
+ public void should_drop_an_unresolved_expanded_address_before_applying_the_cap() {
+ // Given – the same resolver answering with one unusable address and one live one, and a cap of
+ // a
+ // single candidate. An address that cannot be connected to must not consume a slot in that cap:
+ // dropped after the truncation instead, it would leave this connect with nothing to dial.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ when(defaultProfile.getInt(DefaultDriverOption.CONNECTION_MAX_CANDIDATE_ADDRESSES))
+ .thenReturn(1);
+ installResolver(
+ new TestAddressResolverGroup(Arrays.asList(STILL_UNRESOLVED, SERVER_ADDRESS.resolve())));
+ ChannelFactory factory = newChannelFactory();
+ // Keeping the resolver's order is what makes this an assertion about the cap rather than about
+ // luck: the unusable address is the one the truncation would otherwise have kept.
+ factory.random = new KeepResolverOrder();
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(
+ new DefaultEndPoint(HOSTNAME),
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE);
+ completeSimpleChannelInit();
+
+ // Then
+ assertThatStage(channelFuture).isSuccess();
+ }
+
+ @Test
+ public void should_not_resolve_at_all_when_the_user_disabled_the_resolver() {
+ // Given – Bootstrap.disableResolver() means config().resolver() is null. ChannelFactory must
+ // treat that as "pass the candidates through" instead of dereferencing the missing group.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ TestAddressResolverGroup resolverGroup =
+ new TestAddressResolverGroup(Collections.singletonList(UNREACHABLE));
+ doAnswer(
+ invocation -> {
+ Bootstrap bootstrap = invocation.getArgument(0);
+ bootstrap.resolver(resolverGroup).disableResolver();
+ return null;
+ })
+ .when(nettyOptions)
+ .afterBootstrapInitialized(any(Bootstrap.class));
+ ChannelFactory factory = newChannelFactory();
+
+ // When – the endpoint yields an already-usable address.
+ CompletionStage channelFuture =
+ factory.connect(
+ SERVER_ADDRESS,
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE);
+ completeSimpleChannelInit();
+
+ // Then – connection succeeds and the resolver was never even instantiated, let alone consulted.
+ assertThatStage(channelFuture).isSuccess();
+ assertThat(resolverGroup.resolverRequested).isFalse();
+ assertThat(resolverGroup.queried).isEmpty();
+ }
+
+ @Test
+ public void should_materialize_an_ip_literal_when_the_user_disabled_the_resolver() {
+ // Given – disableResolver() and an endpoint holding an unresolved IP literal, which is now the
+ // ordinary shape: contact points are kept unresolved whatever they were written as. Before
+ // that they arrived here already resolved and disableResolver() worked with them, and a
+ // literal needs no name service for that to stay true.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ TestAddressResolverGroup resolverGroup =
+ new TestAddressResolverGroup(Collections.singletonList(UNREACHABLE));
+ doAnswer(
+ invocation -> {
+ Bootstrap bootstrap = invocation.getArgument(0);
+ bootstrap.resolver(resolverGroup).disableResolver();
+ return null;
+ })
+ .when(nettyOptions)
+ .afterBootstrapInitialized(any(Bootstrap.class));
+ ChannelFactory factory = newChannelFactory();
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(
+ new DefaultEndPoint(InetSocketAddress.createUnresolved("127.0.0.1", 9042)),
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE);
+
+ // Then – it got as far as dialling the literal. The transport in this harness is Netty's local
+ // one, so an InetSocketAddress has nothing bound to it and the connect is refused by name --
+ // which is the point: the attempt failed at the socket, not at the "nothing will resolve this"
+ // diagnostic it used to die on before reaching one.
+ assertThatStage(channelFuture)
+ .isFailed(
+ e -> {
+ assertThat(e).isNotInstanceOf(IllegalStateException.class);
+ assertThat(e).hasMessageContaining("127.0.0.1");
+ });
+ assertThat(resolverGroup.resolverRequested).isFalse();
+ }
+
+ @Test
+ public void should_still_fail_a_hostname_when_the_user_disabled_the_resolver() {
+ // The other half of the same branch: a name genuinely needs a resolver, so it keeps failing --
+ // with a message that names disableResolver() as the cause, since that is what the operator
+ // has to undo.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ doAnswer(
+ invocation -> {
+ Bootstrap bootstrap = invocation.getArgument(0);
+ bootstrap
+ .resolver(new TestAddressResolverGroup(Collections.singletonList(UNREACHABLE)))
+ .disableResolver();
+ return null;
+ })
+ .when(nettyOptions)
+ .afterBootstrapInitialized(any(Bootstrap.class));
+ ChannelFactory factory = newChannelFactory();
+
+ CompletionStage channelFuture =
+ factory.connect(
+ new DefaultEndPoint(HOSTNAME),
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE);
+
+ assertThatStage(channelFuture)
+ .isFailed(
+ e -> {
+ assertThat(e).isInstanceOf(IllegalStateException.class);
+ assertThat(e.getMessage())
+ .contains("test.cluster.fake")
+ .contains("the bootstrap has name resolution disabled");
+ });
+ }
+
+ @Test
+ public void should_pass_a_declined_address_through_untouched() {
+ // Given – a resolver that declines every address, as a real one does for an address type it
+ // does
+ // not handle (DefaultNameResolver declines anything that is not an InetSocketAddress). Netty
+ // passes such an address through in Bootstrap#doResolveAndConnect0, and so must the driver.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ TestAddressResolverGroup resolverGroup =
+ new TestAddressResolverGroup(Collections.singletonList(UNREACHABLE), false, true);
+ installResolver(resolverGroup);
+ ChannelFactory factory = newChannelFactory();
+
+ // When – the endpoint yields an already-usable address.
+ CompletionStage channelFuture =
+ factory.connect(
+ SERVER_ADDRESS,
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE);
+ completeSimpleChannelInit();
+
+ // Then – it connected to the address as given; nothing was looked up or substituted.
+ assertThatStage(channelFuture).isSuccess();
+ assertThat(resolverGroup.queried).isEmpty();
+ }
+
+ @Test
+ public void should_report_a_declined_unresolved_address_as_such() {
+ // Given – the same declining resolver, but now the address needs resolving. Nothing downstream
+ // will do it (connectToAddress uses a bootstrap clone with disableResolver()), so this fails
+ // every connection attempt for the session and the message has to name the actual cause. Naming
+ // the disabled-resolver case instead would send the operator looking for a
+ // Bootstrap.disableResolver() nobody called.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ installResolver(
+ new TestAddressResolverGroup(Collections.singletonList(UNREACHABLE), false, true));
+ ChannelFactory factory = newChannelFactory();
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(
+ new DefaultEndPoint(HOSTNAME),
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE);
+
+ // Then
+ assertThatStage(channelFuture)
+ .isFailed(
+ e -> {
+ assertThat(e).isInstanceOf(IllegalStateException.class);
+ assertThat(e.getMessage())
+ .contains("test.cluster.fake")
+ .contains("the configured resolver does not support this address")
+ .doesNotContain("disableResolver");
+ });
+ }
+
+ @Test
+ public void should_pass_already_resolved_address_through_untouched() {
+ // Given – an endpoint whose address is already resolved, which is the common case: metadata
+ // nodes hold resolved addresses from the peers rows, so this is every pool refill and every
+ // reconnect. A resolver with the usual semantics reports it as resolved and there is nothing
+ // to expand.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ TestAddressResolverGroup resolverGroup =
+ new TestAddressResolverGroup(Collections.singletonList(UNREACHABLE));
+ installResolver(resolverGroup);
+ ChannelFactory factory = newChannelFactory();
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(
+ SERVER_ADDRESS,
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE);
+ completeSimpleChannelInit();
+
+ // Then – no lookup was performed: had one been, it would have redirected us to UNREACHABLE and
+ // the connection would have failed. The decision was the resolver's own, though -- see
+ // should_let_the_resolver_redirect_an_already_resolved_address.
+ assertThatStage(channelFuture).isSuccess();
+ assertThat(resolverGroup.queried).isEmpty();
+ assertThat(resolverGroup.resolverRequested)
+ .as("whether an address needs resolving must be the resolver's decision")
+ .isTrue();
+ }
+
+ @Test
+ public void should_let_the_resolver_redirect_an_already_resolved_address() {
+ // Given – a resolver that reports even an address carrying an IP as still needing resolution,
+ // and redirects it. Netty consulted the resolver for every connect, resolved address or not
+ // (Bootstrap#doResolveAndConnect0 calls isSupported()/isResolved() on it rather than testing
+ // the address itself), so short-circuiting on InetSocketAddress#isUnresolved() here would take
+ // that away for every connect to an already-resolved node -- which is nearly all of them.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ TestAddressResolverGroup resolverGroup =
+ new TestAddressResolverGroup(
+ Collections.singletonList(SERVER_ADDRESS.resolve()),
+ /* claimNothingIsResolved = */ true);
+ installResolver(resolverGroup);
+ ChannelFactory factory = newChannelFactory();
+
+ // When – the endpoint holds a resolved address that nothing is listening on.
+ InetSocketAddress resolved = new InetSocketAddress("127.0.0.1", 9042);
+ CompletionStage channelFuture =
+ factory.connect(
+ new DefaultEndPoint(resolved),
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE);
+ completeSimpleChannelInit();
+
+ // Then – the connect landed on the address the resolver substituted, which it could only do by
+ // having been asked about an address that already carried an IP. Exactly one lookup: the
+ // per-attempt bootstrap has the resolver disabled, so the substitute is connected to as-is
+ // rather than being handed back to the resolver (see the next test for why that matters).
+ assertThatStage(channelFuture).isSuccess();
+ assertThat(resolverGroup.queried)
+ .as("the resolver must get a say on an address that already carries an IP")
+ .containsExactly(resolved);
+ }
+
+ @Test
+ public void should_try_every_candidate_when_the_resolver_redirects() {
+ // Given – the same redirecting resolver as above, but answering with more than one address:
+ // a dead one first, then the running local server.
+ //
+ // The per-attempt bootstrap must not re-resolve. Bootstrap.clone() carries the resolver
+ // configuration over, and Netty's own pass calls resolve() -- *singular* -- so with a resolver
+ // that reports resolved addresses as unresolved, every candidate would be redirected again onto
+ // the resolver's first answer: the dead address, N times over. Multi-address fallback would
+ // silently do nothing, and the endpoint pinned onto the channel would name an address the
+ // channel is not connected to -- which is what the SSL engine's peer host and
+ // DefaultTopologyMonitor#savePort are then derived from.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ TestAddressResolverGroup resolverGroup =
+ new TestAddressResolverGroup(
+ Arrays.asList(UNREACHABLE, SERVER_ADDRESS.resolve()),
+ /* claimNothingIsResolved = */ true);
+ installResolver(resolverGroup);
+ ChannelFactory factory = newChannelFactory();
+
+ CompletionStage channelFuture =
+ factory.connect(
+ new DefaultEndPoint(HOSTNAME),
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE);
+ completeSimpleChannelInit();
+
+ // Then – the reachable address was actually reached. This holds whichever candidate rotate()
+ // starts from, and it is precisely what fails when the clone re-resolves: the dead address is
+ // the resolver's first answer, so both attempts would land there and the connect would fail.
+ assertThatStage(channelFuture).isSuccess();
+ assertThat(resolverGroup.queried)
+ .as("the hostname is expanded once, by ChannelFactory; the candidates are not re-resolved")
+ .containsExactly(HOSTNAME);
+ }
+
+ @Test
+ public void should_resolve_and_connect_on_the_same_event_loop() throws InterruptedException {
+ // Resolution and channel registration must share the loop picked once per connect. Taking one
+ // loop for resolution and letting the registration pick another would advance the group's
+ // round-robin chooser twice per connect, parking every channel on half the loops with the
+ // default power-of-two chooser. The base's single-thread group would make this assertion
+ // vacuous, so use two loops -- on which the split behavior was deterministic.
+ DefaultEventLoopGroup twoLoops = new DefaultEventLoopGroup(2);
+ try {
+ when(nettyOptions.ioEventLoopGroup()).thenReturn(twoLoops);
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ TestAddressResolverGroup resolverGroup =
+ new TestAddressResolverGroup(Collections.singletonList(SERVER_ADDRESS.resolve()));
+ installResolver(resolverGroup);
+ ChannelFactory factory = newChannelFactory();
+
+ CompletionStage channelFuture =
+ factory.connect(
+ new DefaultEndPoint(HOSTNAME),
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE);
+ completeSimpleChannelInit();
+
+ assertThatStage(channelFuture)
+ .isSuccess(
+ channel ->
+ assertThat((Object) channel.eventLoop())
+ .as("the channel must be registered on the loop resolution ran on")
+ .isSameAs(resolverGroup.resolverExecutor));
+ } finally {
+ twoLoops.shutdownGracefully(0, 100, TimeUnit.MILLISECONDS).sync();
+ }
+ }
+
+ @Test
+ public void should_fail_future_when_resolver_throws_synchronously() {
+ // Given – a broken custom resolver that throws instead of returning a failed future. The throw
+ // happens inside an event-loop task, where nothing else would ever complete the connect future:
+ // nothing at this stage has a timeout, so before the blanket catch in resolveCandidates() this
+ // hung the connect attempt (and with it control-connection init) forever.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ RuntimeException failure = new IllegalStateException("broken resolver");
+ installResolver(new ThrowingAddressResolverGroup(failure));
+ ChannelFactory factory = newChannelFactory();
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(
+ new DefaultEndPoint(HOSTNAME),
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE);
+
+ // Then
+ assertThatStage(channelFuture).isFailed(e -> assertThat(e).isSameAs(failure));
+ }
+
+ /** A resolver whose every method throws, standing in for a broken third-party implementation. */
+ private static class ThrowingAddressResolverGroup extends AddressResolverGroup {
+
+ private final RuntimeException failure;
+
+ ThrowingAddressResolverGroup(RuntimeException failure) {
+ this.failure = failure;
+ }
+
+ @Override
+ protected AddressResolver newResolver(EventExecutor executor) {
+ return new AddressResolver() {
+
+ @Override
+ public boolean isSupported(SocketAddress address) {
+ throw failure;
+ }
+
+ @Override
+ public boolean isResolved(SocketAddress address) {
+ throw failure;
+ }
+
+ @Override
+ public Future resolve(SocketAddress address) {
+ throw failure;
+ }
+
+ @Override
+ public Future resolve(
+ SocketAddress address, Promise promise) {
+ throw failure;
+ }
+
+ @Override
+ public Future> resolveAll(SocketAddress address) {
+ throw failure;
+ }
+
+ @Override
+ public Future> resolveAll(
+ SocketAddress address, Promise> promise) {
+ throw failure;
+ }
+
+ @Override
+ public void close() {
+ // nothing to do
+ }
+ };
+ }
+ }
+}
diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryPinnedEndPointTest.java b/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryPinnedEndPointTest.java
new file mode 100644
index 00000000000..988b1ebe4f0
--- /dev/null
+++ b/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryPinnedEndPointTest.java
@@ -0,0 +1,233 @@
+/*
+ * 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.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+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.metadata.PinnableEndPoint;
+import com.datastax.oss.driver.internal.core.metrics.NoopNodeMetricUpdater;
+import edu.umd.cs.findbugs.annotations.NonNull;
+import io.netty.bootstrap.Bootstrap;
+import io.netty.channel.local.LocalAddress;
+import java.net.InetSocketAddress;
+import java.net.SocketAddress;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.CompletionStage;
+import org.junit.Test;
+
+/**
+ * Verifies that a successfully connected {@link DriverChannel} carries an endpoint bound to the
+ * address the connection actually used, not the multi-address original.
+ *
+ * Without this, a hostname shared by several nodes would let a later reconnect land on a
+ * different node while still being treated as the original {@code host_id}: {@code
+ * DefaultTopologyMonitor#buildNodeEndPoint} stores the channel's endpoint for the control node, and
+ * {@code ControlConnection} skips identity re-resolution for nodes that already have a host id. See
+ * {@link PinnableEndPoint}.
+ */
+public class ChannelFactoryPinnedEndPointTest extends ChannelFactoryTestBase {
+
+ // A local address that no server is bound to: connecting to it fails immediately.
+ private static final SocketAddress UNREACHABLE =
+ new LocalAddress(ChannelFactoryPinnedEndPointTest.class.getSimpleName() + "-unreachable");
+
+ /** The name the endpoint reports, and that only the resolver knows how to expand. */
+ private static final InetSocketAddress HOSTNAME =
+ InetSocketAddress.createUnresolved("test.cluster.fake", 9042);
+
+ @Test
+ public void should_pin_channel_endpoint_to_the_address_that_connected() {
+ // Given – an endpoint reporting a name, which the resolver expands to a dead address and the
+ // running local server. Whichever of the two the connection ends up on, the channel must carry
+ // that one.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ SocketAddress reachable = SERVER_ADDRESS.resolve();
+ installResolver(new TestAddressResolverGroup(Arrays.asList(UNREACHABLE, reachable)));
+ ChannelFactory factory = newChannelFactory();
+ TestPinnableEndPoint endPoint = new TestPinnableEndPoint(HOSTNAME);
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(
+ endPoint, null, null, DriverChannelOptions.DEFAULT, NoopNodeMetricUpdater.INSTANCE);
+ completeSimpleChannelInit();
+
+ // Then
+ assertThatStage(channelFuture)
+ .isSuccess(
+ channel -> {
+ EndPoint channelEndPoint = channel.getEndPoint();
+ // The channel resolves to the address it is actually connected to -- the name it was
+ // built from is gone from resolve(), which is what SSL engines and authenticators
+ // need.
+ assertThat(channelEndPoint.resolve()).isEqualTo(reachable);
+ // ...while still denoting the same node, so node lookups and metric names are stable.
+ assertThat(channelEndPoint).isEqualTo(endPoint);
+ assertThat(channelEndPoint.asMetricPrefix()).isEqualTo(endPoint.asMetricPrefix());
+ });
+ }
+
+ @Test
+ public void should_leave_non_pinnable_endpoints_untouched() {
+ // A third-party EndPoint that does not implement PinnableEndPoint must reach the channel
+ // exactly
+ // as it was given, so existing implementations keep working.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ ChannelFactory factory = newChannelFactory();
+
+ CompletionStage channelFuture =
+ factory.connect(
+ SERVER_ADDRESS,
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE);
+ completeSimpleChannelInit();
+
+ assertThatStage(channelFuture)
+ .isSuccess(channel -> assertThat(channel.getEndPoint()).isSameAs(SERVER_ADDRESS));
+ }
+
+ @Test
+ public void should_not_pin_to_an_unresolved_address() {
+ // Given – Bootstrap.disableResolver(), one of the paths where resolveCandidates hands the
+ // endpoint's own address straight back. For an endpoint that reports a name, the candidate is
+ // therefore still that name.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ doAnswer(
+ invocation -> {
+ ((Bootstrap) invocation.getArgument(0)).disableResolver();
+ return null;
+ })
+ .when(nettyOptions)
+ .afterBootstrapInitialized(any(Bootstrap.class));
+ ChannelFactory factory = newChannelFactory();
+ List pinnedTo = new ArrayList<>();
+ TestPinnableEndPoint endPoint =
+ new TestPinnableEndPoint(HOSTNAME) {
+ @NonNull
+ @Override
+ public EndPoint pinTo(@NonNull SocketAddress resolvedAddress) {
+ pinnedTo.add(resolvedAddress);
+ return super.pinTo(resolvedAddress);
+ }
+ };
+
+ // When – the connect itself cannot succeed against a name nothing resolves; what matters is
+ // what happened before it was attempted.
+ CompletionStage channelFuture =
+ factory.connect(
+ endPoint, null, null, DriverChannelOptions.DEFAULT, NoopNodeMetricUpdater.INSTANCE);
+
+ // Then – pinTo() is documented to take an already-resolved address. Pinning a name would freeze
+ // the endpoint on something that still re-expands on every connect, and for endpoints that stop
+ // consulting their own source once pinned, silence that source for good.
+ assertThatStage(channelFuture).isFailed();
+ assertThat(pinnedTo).as("pinTo() must not be handed an unresolved address").isEmpty();
+ }
+
+ @Test
+ public void should_fail_future_when_pin_to_throws() {
+ // pinTo() runs in the continuation after resolution, whose exceptions CompletionStage
+ // swallows; a throwing implementation must fail the connect future rather than hang it.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ SocketAddress reachable = SERVER_ADDRESS.resolve();
+ installResolver(new TestAddressResolverGroup(Collections.singletonList(reachable)));
+ ChannelFactory factory = newChannelFactory();
+ RuntimeException failure = new IllegalStateException("pinTo blew up");
+ TestPinnableEndPoint endPoint =
+ new TestPinnableEndPoint(HOSTNAME) {
+ @NonNull
+ @Override
+ public EndPoint pinTo(@NonNull SocketAddress resolvedAddress) {
+ throw failure;
+ }
+ };
+
+ CompletionStage channelFuture =
+ factory.connect(
+ endPoint, null, null, DriverChannelOptions.DEFAULT, NoopNodeMetricUpdater.INSTANCE);
+
+ assertThatStage(channelFuture).isFailed(e -> assertThat(e).isSameAs(failure));
+ }
+
+ /**
+ * A {@link PinnableEndPoint} that can hold a pin to any {@link SocketAddress}, including the
+ * local-transport addresses these tests connect over (which {@code DefaultEndPoint} cannot).
+ * Identity is the unpinned address, so a pinned copy stays equal to the original — the contract
+ * {@link PinnableEndPoint} requires.
+ */
+ private static class TestPinnableEndPoint implements PinnableEndPoint {
+
+ private final SocketAddress address;
+ private final SocketAddress pinnedAddress;
+
+ TestPinnableEndPoint(SocketAddress address) {
+ this(address, null);
+ }
+
+ private TestPinnableEndPoint(SocketAddress address, SocketAddress pinnedAddress) {
+ this.address = address;
+ this.pinnedAddress = pinnedAddress;
+ }
+
+ @NonNull
+ @Override
+ public SocketAddress resolve() {
+ return pinnedAddress != null ? pinnedAddress : address;
+ }
+
+ @NonNull
+ @Override
+ public EndPoint pinTo(@NonNull SocketAddress resolvedAddress) {
+ return new TestPinnableEndPoint(address, resolvedAddress);
+ }
+
+ @NonNull
+ @Override
+ public String asMetricPrefix() {
+ return "test";
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ return (other instanceof TestPinnableEndPoint)
+ && address.equals(((TestPinnableEndPoint) other).address);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(address);
+ }
+ }
+}
diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryProtocolNegotiationTest.java b/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryProtocolNegotiationTest.java
index fceb8777904..f6e39d1962b 100644
--- a/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryProtocolNegotiationTest.java
+++ b/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryProtocolNegotiationTest.java
@@ -25,6 +25,7 @@
import com.datastax.oss.driver.api.core.UnsupportedProtocolVersionException;
import com.datastax.oss.driver.api.core.config.DefaultDriverOption;
import com.datastax.oss.driver.internal.core.TestResponses;
+import com.datastax.oss.driver.internal.core.metadata.DefaultEndPoint;
import com.datastax.oss.driver.internal.core.metrics.NoopNodeMetricUpdater;
import com.datastax.oss.protocol.internal.Frame;
import com.datastax.oss.protocol.internal.ProtocolConstants;
@@ -33,12 +34,21 @@
import com.datastax.oss.protocol.internal.response.Ready;
import com.tngtech.java.junit.dataprovider.DataProvider;
import com.tngtech.java.junit.dataprovider.UseDataProvider;
+import io.netty.channel.local.LocalAddress;
+import java.net.InetSocketAddress;
+import java.net.SocketAddress;
+import java.util.Arrays;
import java.util.Optional;
import java.util.concurrent.CompletionStage;
import org.junit.Test;
public class ChannelFactoryProtocolNegotiationTest extends ChannelFactoryTestBase {
+ /** A local address no server is bound to: connecting to it fails immediately. */
+ private static final SocketAddress UNREACHABLE =
+ new LocalAddress(
+ ChannelFactoryProtocolNegotiationTest.class.getSimpleName() + "-unreachable");
+
@Test
public void should_succeed_if_version_specified_and_supported_by_server() {
// Given
@@ -61,6 +71,11 @@ public void should_succeed_if_version_specified_and_supported_by_server() {
// Then
assertThatStage(channelFuture)
.isSuccess(channel -> assertThat(channel.getClusterName()).isEqualTo("mockClusterName"));
+ // Read directly, not awaited: completeCandidate() records the negotiated state *before* it
+ // publishes the channel, so the value is already there for anyone the completed future reaches
+ // -- inline dependents included. Reading it straight is what pins that ordering. Awaiting would
+ // pass either way, and used to: while the latch still ran after the publish, an inline read
+ // here could see the pre-latch null, which is how this flaked on JDK 17 in CI.
assertThat(factory.protocolVersion).isEqualTo(DefaultProtocolVersion.V4);
}
@@ -176,6 +191,11 @@ public void should_succeed_if_version_not_specified_and_server_supports_latest_s
// Then
assertThatStage(channelFuture)
.isSuccess(channel -> assertThat(channel.getClusterName()).isEqualTo("mockClusterName"));
+ // Read directly, not awaited: completeCandidate() records the negotiated state *before* it
+ // publishes the channel, so the value is already there for anyone the completed future reaches
+ // -- inline dependents included. Reading it straight is what pins that ordering. Awaiting would
+ // pass either way, and used to: while the latch still ran after the publish, an inline read
+ // here could see the pre-latch null, which is how this flaked on JDK 17 in CI.
assertThat(factory.protocolVersion).isEqualTo(DefaultProtocolVersion.V4);
}
@@ -222,6 +242,11 @@ public void should_negotiate_if_version_not_specified_and_server_supports_legacy
writeInboundFrame(requestFrame, TestResponses.clusterNameResponse("mockClusterName"));
assertThatStage(channelFuture)
.isSuccess(channel -> assertThat(channel.getClusterName()).isEqualTo("mockClusterName"));
+ // Read directly, not awaited: completeCandidate() records the negotiated state *before* it
+ // publishes the channel, so the value is already there for anyone the completed future reaches
+ // -- inline dependents included. Reading it straight is what pins that ordering. Awaiting would
+ // pass either way, and used to: while the latch still ran after the publish, an inline read
+ // here could see the pre-latch null, which is how this flaked on JDK 17 in CI.
assertThat(factory.protocolVersion).isEqualTo(DefaultProtocolVersion.V3);
}
@@ -280,6 +305,213 @@ public void should_fail_if_negotiation_finds_no_matching_version(int errorCode)
});
}
+ @Test
+ public void should_not_try_next_address_of_identified_node_when_negotiation_exhausts_versions() {
+ // Given – an *identified* node (its host id is known, so every address its name expands to is
+ // that same node) whose name expands to two candidates: the same live server twice, so
+ // whichever the rotation picks first is irrelevant. The server rejects every protocol version.
+ mockNegotiationLadderDownToV3();
+ SocketAddress serverAddress = SERVER_ADDRESS.resolve();
+ installResolver(new TestAddressResolverGroup(Arrays.asList(serverAddress, serverAddress)));
+ ChannelFactory factory = newChannelFactory();
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(
+ new DefaultEndPoint(InetSocketAddress.createUnresolved("test.cluster.fake", 9042)),
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE,
+ true);
+
+ exhaustNegotiationLadder();
+
+ // Then – the second candidate must not be attempted: for a node we have already identified, a
+ // protocol-version rejection is a property of the node, not of the address, so replaying the
+ // negotiation ladder against the remaining IPs would buy nothing. Checked before the future
+ // assertion so that on regression the stray frame is drained; leaving it unread would block the
+ // server's exchanger and hang the whole suite in tearDown() instead of failing this test.
+ assertThat(tryReadOutboundFrame(200))
+ .as("second candidate must not be attempted after negotiation exhaustion")
+ .isNull();
+ assertThatStage(channelFuture)
+ .isFailed(
+ e -> {
+ assertThat(e).isInstanceOf(UnsupportedProtocolVersionException.class);
+ assertThat(((UnsupportedProtocolVersionException) e).getAttemptedVersions())
+ .containsExactly(DefaultProtocolVersion.V4, DefaultProtocolVersion.V3);
+ assertThat(e.getSuppressed())
+ .as("no other candidate should have been tried, so nothing to suppress")
+ .isEmpty();
+ });
+ }
+
+ @Test
+ public void
+ should_try_next_address_of_unidentified_endpoint_when_negotiation_exhausts_versions() {
+ // Given – the same setup, but for an endpoint the driver has not identified yet: a contact
+ // point, before host ids have been read. Its name may well expand to addresses of *different*
+ // nodes, so a version rejection by the first says nothing about the second.
+ mockNegotiationLadderDownToV3();
+ SocketAddress serverAddress = SERVER_ADDRESS.resolve();
+ installResolver(new TestAddressResolverGroup(Arrays.asList(serverAddress, serverAddress)));
+ ChannelFactory factory = newChannelFactory();
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(
+ new DefaultEndPoint(InetSocketAddress.createUnresolved("test.cluster.fake", 9042)),
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE,
+ false);
+
+ // The first candidate exhausts the ladder...
+ exhaustNegotiationLadder();
+ // ...and the second is tried all the same, replaying the ladder from the top. Before this,
+ // resolve-contact-points=true made each address a separate node and ControlConnection advanced
+ // to the next one on exactly this error; collapsing a name into one node must not lose that.
+ exhaustNegotiationLadder();
+
+ // Then
+ assertThat(tryReadOutboundFrame(200))
+ .as("the name expands to two addresses, so there is no third attempt")
+ .isNull();
+ assertThatStage(channelFuture)
+ .isFailed(
+ e -> {
+ assertThat(e).isInstanceOf(UnsupportedProtocolVersionException.class);
+ assertThat(((UnsupportedProtocolVersionException) e).getAttemptedVersions())
+ .as("each candidate negotiates on its own, so this is the last one's ladder")
+ .containsExactly(DefaultProtocolVersion.V4, DefaultProtocolVersion.V3);
+ assertThat(e.getSuppressed())
+ .as("the first candidate's failure must still be reported")
+ .hasSize(1);
+ assertThat(e.getSuppressed()[0])
+ .isInstanceOf(UnsupportedProtocolVersionException.class);
+ });
+ }
+
+ @Test
+ public void should_surface_the_node_wide_rejection_when_an_earlier_address_failed_on_transport() {
+ // Given – an identified node whose name expands to a dead address first and to the live server
+ // second, the server rejecting every protocol version. Every address of an identified node is
+ // that same node, so the rejection is a property of the node rather than of the address.
+ mockNegotiationLadderDownToV3();
+ installResolver(
+ new TestAddressResolverGroup(Arrays.asList(UNREACHABLE, SERVER_ADDRESS.resolve())));
+ ChannelFactory factory = newChannelFactory();
+ // The order matters here, and only here: it is what makes the pass end with a *mixed* set of
+ // failures instead of the version rejection on its own.
+ factory.random = new KeepResolverOrder();
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(
+ new DefaultEndPoint(InetSocketAddress.createUnresolved("test.cluster.fake", 9042)),
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE,
+ true);
+
+ // The dead address sends nothing, so the ladder below is the second candidate's.
+ exhaustNegotiationLadder();
+
+ // Then – the version rejection is what the caller must see, even though the pass also produced
+ // a
+ // transport failure that ChannelPool#handleError does not classify and would otherwise be
+ // preferred (see ChannelFactory#surfacedFailure: a fatal failure needs every address to agree).
+ // A node-wide failure is the exception to that rule: demoting it would turn the forced-down
+ // node
+ // that a single-address connect has always produced into a plain reconnect.
+ assertThatStage(channelFuture)
+ .isFailed(
+ e -> {
+ assertThat(e).isInstanceOf(UnsupportedProtocolVersionException.class);
+ assertThat(((UnsupportedProtocolVersionException) e).getAttemptedVersions())
+ .containsExactly(DefaultProtocolVersion.V4, DefaultProtocolVersion.V3);
+ assertThat(e.getSuppressed())
+ .as("the earlier address's transport failure should still be attached")
+ .hasSize(1);
+ });
+ }
+
+ /** Negotiation starts at V4 and has exactly one downgrade available, to V3. */
+ private void mockNegotiationLadderDownToV3() {
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ when(protocolVersionRegistry.downgrade(DefaultProtocolVersion.V4))
+ .thenReturn(Optional.of(DefaultProtocolVersion.V3));
+ when(protocolVersionRegistry.downgrade(DefaultProtocolVersion.V3)).thenReturn(Optional.empty());
+ }
+
+ /**
+ * Plays the server side of a full negotiation ladder against one candidate address: V4 rejected,
+ * downgrade retry with V3 rejected, i.e. no version left to try on that address.
+ */
+ private void exhaustNegotiationLadder() {
+ Frame requestFrame = readOutboundFrame();
+ assertThat(requestFrame.message).isInstanceOf(Options.class);
+ writeInboundFrame(requestFrame, TestResponses.supportedResponse("mock_key", "mock_value"));
+
+ requestFrame = readOutboundFrame();
+ assertThat(requestFrame.protocolVersion).isEqualTo(DefaultProtocolVersion.V4.getCode());
+ writeInboundFrame(
+ requestFrame,
+ new Error(
+ ProtocolConstants.ErrorCode.PROTOCOL_ERROR, "Invalid or unsupported protocol version"));
+
+ requestFrame = readOutboundFrame();
+ assertThat(requestFrame.message).isInstanceOf(Options.class);
+ writeInboundFrame(requestFrame, TestResponses.supportedResponse("mock_key", "mock_value"));
+
+ requestFrame = readOutboundFrame();
+ assertThat(requestFrame.protocolVersion).isEqualTo(DefaultProtocolVersion.V3.getCode());
+ writeInboundFrame(
+ requestFrame,
+ new Error(
+ ProtocolConstants.ErrorCode.PROTOCOL_ERROR, "Invalid or unsupported protocol version"));
+ }
+
+ @Test
+ public void should_fail_future_when_downgrade_lookup_throws_in_connect_listener() {
+ // Given – a version registry that throws when the factory looks up the downgrade. The lookup
+ // runs inside the Netty connect listener, which swallows throwables: without the blanket catch
+ // in connectToAddress() the connect future would never complete and the attempt would hang.
+ when(defaultProfile.isDefined(DefaultDriverOption.PROTOCOL_VERSION)).thenReturn(false);
+ when(protocolVersionRegistry.highestNonBeta()).thenReturn(DefaultProtocolVersion.V4);
+ RuntimeException failure = new IllegalStateException("registry broken");
+ when(protocolVersionRegistry.downgrade(DefaultProtocolVersion.V4)).thenThrow(failure);
+ ChannelFactory factory = newChannelFactory();
+
+ // When
+ CompletionStage channelFuture =
+ factory.connect(
+ SERVER_ADDRESS,
+ null,
+ null,
+ DriverChannelOptions.DEFAULT,
+ NoopNodeMetricUpdater.INSTANCE);
+
+ Frame requestFrame = readOutboundFrame();
+ assertThat(requestFrame.message).isInstanceOf(Options.class);
+ writeInboundFrame(requestFrame, TestResponses.supportedResponse("mock_key", "mock_value"));
+
+ requestFrame = readOutboundFrame();
+ assertThat(requestFrame.protocolVersion).isEqualTo(DefaultProtocolVersion.V4.getCode());
+ // Server does not support v4, which is what sends the factory to the downgrade lookup
+ writeInboundFrame(
+ requestFrame,
+ new Error(
+ ProtocolConstants.ErrorCode.PROTOCOL_ERROR, "Invalid or unsupported protocol version"));
+
+ // Then
+ assertThatStage(channelFuture).isFailed(e -> assertThat(e).isSameAs(failure));
+ }
+
/**
* Depending on the Cassandra version, an "unsupported protocol" response can use different error
* codes, so we test all of them.
diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryTestBase.java b/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryTestBase.java
index ed6668a6c83..c1904c8f914 100644
--- a/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryTestBase.java
+++ b/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryTestBase.java
@@ -20,6 +20,8 @@
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.when;
import com.datastax.oss.driver.api.core.ProtocolVersion;
@@ -42,6 +44,7 @@
import com.datastax.oss.protocol.internal.request.Startup;
import com.datastax.oss.protocol.internal.response.Ready;
import com.tngtech.java.junit.dataprovider.DataProviderRunner;
+import io.netty.bootstrap.Bootstrap;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
@@ -53,9 +56,12 @@
import io.netty.channel.DefaultEventLoopGroup;
import io.netty.channel.local.LocalChannel;
import io.netty.channel.local.LocalServerChannel;
+import io.netty.resolver.AddressResolverGroup;
+import java.net.SocketAddress;
import java.time.Duration;
import java.util.Collections;
import java.util.Optional;
+import java.util.Random;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Exchanger;
import java.util.concurrent.TimeUnit;
@@ -123,6 +129,9 @@ public void setup() throws InterruptedException {
when(defaultProfile.getDuration(DefaultDriverOption.CONNECTION_SET_KEYSPACE_TIMEOUT))
.thenReturn(Duration.ofMillis(TIMEOUT_MILLIS));
when(defaultProfile.getInt(DefaultDriverOption.CONNECTION_MAX_REQUESTS)).thenReturn(1);
+ // The reference.conf default; individual tests may lower it to exercise the cap.
+ when(defaultProfile.getInt(DefaultDriverOption.CONNECTION_MAX_CANDIDATE_ADDRESSES))
+ .thenReturn(5);
when(defaultProfile.getDuration(DefaultDriverOption.HEARTBEAT_INTERVAL))
.thenReturn(Duration.ofSeconds(30));
when(defaultProfile.getDuration(DefaultDriverOption.CONNECTION_CONNECT_TIMEOUT))
@@ -188,6 +197,55 @@ protected Frame readOutboundFrame() {
return null; // never reached
}
+ /**
+ * Like {@link #readOutboundFrame()}, but returns {@code null} instead of failing the test when no
+ * frame arrives within {@code timeoutMillis}.
+ *
+ * Use this to assert that the client did not send another request. Unlike asserting via
+ * a failing read, it also drains a frame that does arrive: the server-side exchange in {@link
+ * ServerInitializer} has no timeout, so a stray unread frame would block the server event loop
+ * and hang the whole suite in {@link #tearDown()} instead of failing just the test.
+ */
+ protected Frame tryReadOutboundFrame(long timeoutMillis) {
+ try {
+ return requestFrameExchanger.exchange(null, timeoutMillis, MILLISECONDS);
+ } catch (InterruptedException e) {
+ fail("unexpected interruption while waiting for outbound frame", e);
+ return null; // never reached
+ } catch (TimeoutException e) {
+ return null;
+ }
+ }
+
+ /**
+ * A {@link Random} that turns {@link java.util.Collections#shuffle} into a no-op, so a test whose
+ * scenario depends on which address is tried first can rely on the order the resolver returned:
+ * shuffle swaps element {@code i-1} with {@code nextInt(i)}, and returning {@code i-1} swaps
+ * every element with itself.
+ *
+ *
Assign it to {@link ChannelFactory#random}, the injection point that exists for this.
+ */
+ static final class KeepResolverOrder extends Random {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public int nextInt(int bound) {
+ return bound - 1;
+ }
+ }
+
+ /** Installs {@code group} the way a user would, through the {@code NettyOptions} hook. */
+ protected void installResolver(AddressResolverGroup group) {
+ doAnswer(
+ invocation -> {
+ Bootstrap bootstrap = invocation.getArgument(0);
+ bootstrap.resolver(group);
+ return null;
+ })
+ .when(nettyOptions)
+ .afterBootstrapInitialized(any(Bootstrap.class));
+ }
+
protected void writeInboundFrame(Frame requestFrame, Message response) {
writeInboundFrame(requestFrame, response, requestFrame.protocolVersion);
}
diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ProtocolInitHandlerTest.java b/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ProtocolInitHandlerTest.java
index 682caac198d..322af30dc84 100644
--- a/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ProtocolInitHandlerTest.java
+++ b/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ProtocolInitHandlerTest.java
@@ -36,7 +36,6 @@
import com.datastax.oss.driver.api.core.config.DefaultDriverOption;
import com.datastax.oss.driver.api.core.config.DriverConfig;
import com.datastax.oss.driver.api.core.config.DriverExecutionProfile;
-import com.datastax.oss.driver.api.core.connection.ConnectionInitException;
import com.datastax.oss.driver.api.core.metadata.EndPoint;
import com.datastax.oss.driver.internal.core.DefaultProtocolVersionRegistry;
import com.datastax.oss.driver.internal.core.ProtocolVersionRegistry;
@@ -50,11 +49,9 @@
import com.datastax.oss.driver.shaded.guava.common.collect.ImmutableMap;
import com.datastax.oss.protocol.internal.Frame;
import com.datastax.oss.protocol.internal.ProtocolConstants;
-import com.datastax.oss.protocol.internal.ProtocolConstants.ErrorCode;
import com.datastax.oss.protocol.internal.request.AuthResponse;
import com.datastax.oss.protocol.internal.request.Options;
import com.datastax.oss.protocol.internal.request.Query;
-import com.datastax.oss.protocol.internal.request.Register;
import com.datastax.oss.protocol.internal.request.Startup;
import com.datastax.oss.protocol.internal.response.AuthChallenge;
import com.datastax.oss.protocol.internal.response.AuthSuccess;
@@ -159,6 +156,52 @@ public void should_initialize() {
assertThat(connectFuture).isSuccess();
}
+ /**
+ * Drives initialization as far as the cluster name check, which is where node identification
+ * branches off.
+ *
+ * @return the connect future, so that the caller can assert on its outcome.
+ */
+ private ChannelFuture initUpToClusterName(DriverChannelOptions options) {
+ channel
+ .pipeline()
+ .addLast(
+ ChannelFactory.INIT_HANDLER_NAME,
+ new ProtocolInitHandler(
+ internalDriverContext,
+ DefaultProtocolVersion.V4,
+ null,
+ END_POINT,
+ options,
+ heartbeatHandler,
+ false));
+
+ ChannelFuture connectFuture = channel.connect(new InetSocketAddress("localhost", 9042));
+
+ Frame requestFrame = readOutboundFrame();
+ assertThat(requestFrame.message).isInstanceOf(Startup.class);
+ writeInboundFrame(buildInboundFrame(requestFrame, new Ready()));
+
+ requestFrame = readOutboundFrame();
+ assertThat(((Query) requestFrame.message).query)
+ .isEqualTo("SELECT cluster_name FROM system.local WHERE key='local'");
+ writeInboundFrame(requestFrame, TestResponses.clusterNameResponse("someClusterName"));
+
+ return connectFuture;
+ }
+
+ @Test
+ public void should_complete_init_after_cluster_name_check() {
+ // Given — no keyspace to set. The node-identity read and the REGISTER request both happen
+ // after initialization (through the connect hook and ChannelFactory respectively), so init
+ // itself ends at the cluster-name check: byte for byte the pre-multi-address exchange.
+ ChannelFuture connectFuture = initUpToClusterName(DriverChannelOptions.DEFAULT);
+
+ // Then
+ assertThat(connectFuture).isSuccess();
+ assertNoOutboundFrame();
+ }
+
// Mirrors the real reporter, which only ever sees the control connection.
private void stubConfigReporter() {
when(internalDriverContext.getDriverConfigReporter())
@@ -601,7 +644,10 @@ public void should_initialize_with_keyspace() {
}
@Test
- public void should_initialize_with_events() {
+ public void should_not_send_register_during_init_even_when_events_are_requested() {
+ // REGISTER moved out of initialization: ChannelFactory sends it after the connect hook has
+ // accepted the channel, so a candidate about to be rejected never registers for events. Init
+ // itself must therefore end without a Register frame even when the options ask for events.
List