endpoints) {
+ Objects.requireNonNull(endpoints, "endpoints must not be null");
+ if (endpoints.isEmpty()) {
+ throw new IllegalArgumentException("endpoints must not be empty");
+ }
+ this.endpoints.clear();
+ for (ClientRoutesEndpoint endpoint : endpoints) {
+ addEndpoint(endpoint);
+ }
+ return this;
+ }
+
+ /**
+ * Sets the name of the system table to query for client routes.
+ *
+ * This is primarily useful for testing. If not set, the driver will use the default table
+ * name from the configuration ({@code system.client_routes}).
+ *
+ * @param tableName the table name to use.
+ * @return this builder.
+ */
+ @VisibleForTesting
+ @NonNull
+ public Builder withTableName(@Nullable String tableName) {
+ this.tableName = tableName;
+ return this;
+ }
+
+ /**
+ * Sets the DNS cache duration in milliseconds.
+ *
+ *
This controls how long resolved DNS entries are cached before being re-resolved. A shorter
+ * duration is appropriate for dynamic environments where DNS mappings change frequently (e.g.,
+ * during rolling updates), while a longer duration can reduce DNS lookup overhead in stable
+ * environments.
+ *
+ *
Default: 500ms
+ *
+ * @param durationMillis the cache duration in milliseconds (must be non-negative).
+ * @return this builder.
+ * @throws IllegalArgumentException if the duration is negative.
+ */
+ @NonNull
+ public Builder withDnsCacheDuration(long durationMillis) {
+ if (durationMillis < 0) {
+ throw new IllegalArgumentException("DNS cache duration must be non-negative");
+ }
+ this.dnsCacheDurationMillis = durationMillis;
+ return this;
+ }
+
+ /**
+ * Builds the {@link ClientRoutesConfig} with the configured endpoints and table name.
+ *
+ * @return the new configuration instance.
+ * @throws IllegalArgumentException if no endpoints have been added.
+ */
+ @NonNull
+ public ClientRoutesConfig build() {
+ return new ClientRoutesConfig(endpoints, tableName, dnsCacheDurationMillis);
+ }
+ }
+}
diff --git a/core/src/main/java/com/datastax/oss/driver/api/core/config/ClientRoutesEndpoint.java b/core/src/main/java/com/datastax/oss/driver/api/core/config/ClientRoutesEndpoint.java
new file mode 100644
index 00000000000..58571b92ea5
--- /dev/null
+++ b/core/src/main/java/com/datastax/oss/driver/api/core/config/ClientRoutesEndpoint.java
@@ -0,0 +1,108 @@
+/*
+ * 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.api.core.config;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+import edu.umd.cs.findbugs.annotations.Nullable;
+import java.util.Objects;
+import net.jcip.annotations.Immutable;
+
+/**
+ * Represents a client routes endpoint for PrivateLink-style deployments.
+ *
+ *
Each endpoint corresponds to a connection ID in the {@code system.client_routes} table, with
+ * an optional connection address that can be used as a seed host for initial connection.
+ */
+@Immutable
+public class ClientRoutesEndpoint {
+
+ private final String connectionId;
+ private final String connectionAddr;
+
+ /**
+ * Creates a new endpoint with the given connection ID and no connection address.
+ *
+ * @param connectionId the connection ID (must not be null).
+ */
+ public ClientRoutesEndpoint(@NonNull String connectionId) {
+ this(connectionId, null);
+ }
+
+ /**
+ * Creates a new endpoint with the given connection ID and connection address.
+ *
+ * @param connectionId the connection ID (must not be null).
+ * @param connectionAddr the hostname of the seed contact point used for the initial connection
+ * (may be null). This is a plain hostname — not a {@code host:port} pair; the port
+ * is discovered from the {@code system.client_routes} table.
+ */
+ public ClientRoutesEndpoint(@NonNull String connectionId, @Nullable String connectionAddr) {
+ this.connectionId = Objects.requireNonNull(connectionId, "connectionId must not be null");
+ this.connectionAddr = connectionAddr;
+ }
+
+ /** Returns the connection ID for this endpoint. */
+ @NonNull
+ public String getConnectionId() {
+ return connectionId;
+ }
+
+ /**
+ * Returns the hostname of the seed contact point for this endpoint, or null if not specified.
+ *
+ *
This is a plain hostname — not a {@code host:port} pair. The port for the initial
+ * connection is discovered from the {@code system.client_routes} table after the control
+ * connection is established.
+ *
+ *
When provided and no explicit contact points are given to the session builder, this hostname
+ * will be used as a seed for the initial connection.
+ */
+ @Nullable
+ public String getConnectionAddr() {
+ return connectionAddr;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof ClientRoutesEndpoint)) {
+ return false;
+ }
+ ClientRoutesEndpoint that = (ClientRoutesEndpoint) o;
+ return connectionId.equals(that.connectionId)
+ && Objects.equals(connectionAddr, that.connectionAddr);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(connectionId, connectionAddr);
+ }
+
+ @Override
+ public String toString() {
+ return "ClientRoutesEndpoint{"
+ + "connectionId="
+ + connectionId
+ + ", connectionAddr='"
+ + connectionAddr
+ + '\''
+ + '}';
+ }
+}
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 9e0119903df..7052e8f34a8 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
@@ -448,7 +448,6 @@ public enum DefaultDriverOption implements DriverOption {
*
Value-type: {@link String}
*/
ADDRESS_TRANSLATOR_CLASS("advanced.address-translator.class"),
-
/**
* The native protocol version to use.
*
@@ -1073,7 +1072,43 @@ public enum DefaultDriverOption implements DriverOption {
*
Value-type: string
*/
LOAD_BALANCING_DEFAULT_LWT_REQUEST_ROUTING_METHOD(
- "advanced.load-balancing-policy.default-lwt-request-routing-method");
+ "advanced.load-balancing-policy.default-lwt-request-routing-method"),
+
+ /**
+ * The list of client-routes endpoints for cloud private-endpoint (PrivateLink) deployments.
+ *
+ *
Each element is a HOCON object with the following fields:
+ *
+ *
+ * - {@code connection-id} (string, required) – opaque string that identifies the cloud
+ * private-endpoint connection in the {@code system.client_routes} table.
+ *
- {@code connection-addr} (string, optional) – hostname of the seed contact point used for
+ * the initial connection. This is a plain hostname, not a {@code host:port} pair;
+ * the port is read from the {@code system.client_routes} table.
+ *
+ *
+ * This option is read as a raw HOCON list-of-objects; it cannot be read via the flat {@link
+ * com.datastax.oss.driver.api.core.config.DriverExecutionProfile} typed getters. Parsing is
+ * performed directly from the underlying Typesafe {@code Config} object inside {@code
+ * DefaultDriverContext}.
+ *
+ *
Value type: list of HOCON objects
+ */
+ CLIENT_ROUTES_ENDPOINTS("advanced.client-routes.endpoints"),
+
+ /**
+ * The name of the system table to query for client-routes mappings.
+ *
+ *
Value type: {@link String}
+ */
+ CLIENT_ROUTES_TABLE_NAME("advanced.client-routes.table-name"),
+
+ /**
+ * How long resolved DNS entries are cached for client-routes endpoints.
+ *
+ *
Value-type: {@link java.time.Duration Duration}
+ */
+ CLIENT_ROUTES_DNS_CACHE_DURATION("advanced.client-routes.dns-cache-duration");
private final String path;
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 28559ea8556..238a9e337e7 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
@@ -396,6 +396,10 @@ protected static void fillWithDriverDefaults(OptionsMap map) {
map.put(
TypedDriverOption.LOAD_BALANCING_DEFAULT_LWT_REQUEST_ROUTING_METHOD,
"PRESERVE_REPLICA_ORDER");
+ // CLIENT_ROUTES_ENDPOINTS is intentionally omitted: it is a list-of-objects (compound HOCON
+ // values) with no sensible scalar default, analogous to how CONFIG_RELOAD_INTERVAL is omitted.
+ map.put(TypedDriverOption.CLIENT_ROUTES_TABLE_NAME, "system.client_routes");
+ map.put(TypedDriverOption.CLIENT_ROUTES_DNS_CACHE_DURATION, Duration.ofMillis(500));
}
@Immutable
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 818468ee9d5..fad6e39d8ed 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
@@ -939,6 +939,22 @@ public String toString() {
DefaultDriverOption.LOAD_BALANCING_DEFAULT_LWT_REQUEST_ROUTING_METHOD,
GenericType.STRING);
+ /**
+ * The system table to query for client-routes endpoint mappings.
+ *
+ *
Note: {@link DefaultDriverOption#CLIENT_ROUTES_ENDPOINTS} intentionally has no typed
+ * equivalent here because its HOCON value is a list of compound objects (not a flat scalar type
+ * supported by the {@code DriverExecutionProfile} API); it is excluded from the {@code
+ * TypedDriverOptionTest} consistency check accordingly.
+ */
+ public static final TypedDriverOption CLIENT_ROUTES_TABLE_NAME =
+ new TypedDriverOption<>(DefaultDriverOption.CLIENT_ROUTES_TABLE_NAME, GenericType.STRING);
+
+ /** How long resolved DNS entries are cached for client-routes endpoints. */
+ public static final TypedDriverOption CLIENT_ROUTES_DNS_CACHE_DURATION =
+ new TypedDriverOption<>(
+ DefaultDriverOption.CLIENT_ROUTES_DNS_CACHE_DURATION, GenericType.DURATION);
+
private static Iterable> introspectBuiltInValues() {
try {
ImmutableList.Builder> result = ImmutableList.builder();
diff --git a/core/src/main/java/com/datastax/oss/driver/api/core/session/ProgrammaticArguments.java b/core/src/main/java/com/datastax/oss/driver/api/core/session/ProgrammaticArguments.java
index 4e08bd5434c..0373831da41 100644
--- a/core/src/main/java/com/datastax/oss/driver/api/core/session/ProgrammaticArguments.java
+++ b/core/src/main/java/com/datastax/oss/driver/api/core/session/ProgrammaticArguments.java
@@ -18,6 +18,7 @@
package com.datastax.oss.driver.api.core.session;
import com.datastax.oss.driver.api.core.auth.AuthProvider;
+import com.datastax.oss.driver.api.core.config.ClientRoutesConfig;
import com.datastax.oss.driver.api.core.loadbalancing.NodeDistanceEvaluator;
import com.datastax.oss.driver.api.core.metadata.Node;
import com.datastax.oss.driver.api.core.metadata.NodeStateListener;
@@ -71,6 +72,7 @@ public static Builder builder() {
private final String startupApplicationVersion;
private final MutableCodecRegistry codecRegistry;
private final Object metricRegistry;
+ private final ClientRoutesConfig clientRoutesConfig;
private ProgrammaticArguments(
@NonNull List> typeCodecs,
@@ -88,7 +90,8 @@ private ProgrammaticArguments(
@Nullable String startupApplicationName,
@Nullable String startupApplicationVersion,
@Nullable MutableCodecRegistry codecRegistry,
- @Nullable Object metricRegistry) {
+ @Nullable Object metricRegistry,
+ @Nullable ClientRoutesConfig clientRoutesConfig) {
this.typeCodecs = typeCodecs;
this.nodeStateListener = nodeStateListener;
@@ -106,6 +109,7 @@ private ProgrammaticArguments(
this.startupApplicationVersion = startupApplicationVersion;
this.codecRegistry = codecRegistry;
this.metricRegistry = metricRegistry;
+ this.clientRoutesConfig = clientRoutesConfig;
}
@NonNull
@@ -190,6 +194,11 @@ public Object getMetricRegistry() {
return metricRegistry;
}
+ @Nullable
+ public ClientRoutesConfig getClientRoutesConfig() {
+ return clientRoutesConfig;
+ }
+
public static class Builder {
private final ImmutableList.Builder> typeCodecsBuilder = ImmutableList.builder();
@@ -210,6 +219,7 @@ public static class Builder {
private String startupApplicationVersion;
private MutableCodecRegistry codecRegistry;
private Object metricRegistry;
+ private ClientRoutesConfig clientRoutesConfig;
@NonNull
public Builder addTypeCodecs(@NonNull TypeCodec>... typeCodecs) {
@@ -410,6 +420,12 @@ public Builder withMetricRegistry(@Nullable Object metricRegistry) {
return this;
}
+ @NonNull
+ public Builder withClientRoutesConfig(@Nullable ClientRoutesConfig clientRoutesConfig) {
+ this.clientRoutesConfig = clientRoutesConfig;
+ return this;
+ }
+
@NonNull
public ProgrammaticArguments build() {
return new ProgrammaticArguments(
@@ -428,7 +444,8 @@ public ProgrammaticArguments build() {
startupApplicationName,
startupApplicationVersion,
codecRegistry,
- metricRegistry);
+ metricRegistry,
+ clientRoutesConfig);
}
}
}
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 9402c77229f..7312beab62b 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
@@ -28,6 +28,7 @@
import com.datastax.oss.driver.api.core.auth.AuthProvider;
import com.datastax.oss.driver.api.core.auth.PlainTextAuthProviderBase;
import com.datastax.oss.driver.api.core.auth.ProgrammaticPlainTextAuthProvider;
+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.config.DriverConfig;
import com.datastax.oss.driver.api.core.config.DriverConfigLoader;
@@ -98,6 +99,7 @@ public abstract class SessionBuilder {
protected Set programmaticContactPoints = new HashSet<>();
protected CqlIdentifier keyspace;
protected Callable cloudConfigInputStream;
+ protected ClientRoutesConfig clientRoutesConfig;
protected ProgrammaticArguments.Builder programmaticArgumentsBuilder =
ProgrammaticArguments.builder();
@@ -735,6 +737,43 @@ public SelfT withCloudProxyAddress(@Nullable InetSocketAddress cloudProxyAddress
return self;
}
+ /**
+ * Configures this session to use client routes for cloud private-endpoint deployments.
+ *
+ * Client routes enable the driver to discover and connect to nodes through a load balancer
+ * (such as AWS PrivateLink, Azure Private Link, or GCP Private Service Connect) by reading
+ * endpoint mappings from the {@code system.client_routes} table. Each endpoint is identified by a
+ * connection ID and maps to specific node addresses.
+ *
+ *
This configuration is mutually exclusive with a user-provided {@link
+ * com.datastax.oss.driver.api.core.addresstranslation.AddressTranslator}. If both are specified,
+ * an {@link IllegalStateException} is thrown when the session is built.
+ *
+ *
Example usage:
+ *
+ *
{@code
+ * ClientRoutesConfig config = ClientRoutesConfig.builder()
+ * .addEndpoint(new ClientRoutesEndpoint(
+ * UUID.fromString("12345678-1234-1234-1234-123456789012"),
+ * "my-cluster-endpoint.example.com:9042"))
+ * .build();
+ *
+ * CqlSession session = CqlSession.builder()
+ * .withClientRoutesConfig(config)
+ * .build();
+ * }
+ *
+ * @param clientRoutesConfig the client routes configuration to use, or {@code null} to disable
+ * client routes.
+ * @see ClientRoutesConfig
+ */
+ @NonNull
+ public SelfT withClientRoutesConfig(@Nullable ClientRoutesConfig clientRoutesConfig) {
+ this.clientRoutesConfig = clientRoutesConfig;
+ this.programmaticArgumentsBuilder.withClientRoutesConfig(clientRoutesConfig);
+ return self;
+ }
+
/**
* A unique identifier for the created session.
*
@@ -829,6 +868,7 @@ public CompletionStage buildAsync() {
CompletableFutures.propagateCancellation(wrapStage, buildStage);
return wrapStage;
}
+
/**
* Convenience method to call {@link #buildAsync()} and block on the result.
*
@@ -896,6 +936,38 @@ protected final CompletionStage buildDefaultSessionAsync() {
withSslEngineFactory(cloudConfig.getSslEngineFactory());
withCloudProxyAddress(cloudConfig.getProxyAddress());
programmaticArguments = programmaticArgumentsBuilder.build();
+
+ // Check for mutual exclusivity with client routes (programmatic or file-based)
+ boolean fileBasedClientRoutes =
+ defaultConfig.isDefined(DefaultDriverOption.CLIENT_ROUTES_ENDPOINTS);
+ if (clientRoutesConfig != null || fileBasedClientRoutes) {
+ throw new IllegalStateException(
+ "Both a secure connect bundle and client routes configuration were provided. "
+ + "They are mutually exclusive. Please use either a secure connect bundle OR client routes configuration, but not both.");
+ }
+ }
+
+ // Determine whether client routes are active (programmatic or config-file).
+ boolean fileBasedClientRoutes =
+ defaultConfig.isDefined(DefaultDriverOption.CLIENT_ROUTES_ENDPOINTS);
+
+ // Handle client routes mutual exclusivity with a custom AddressTranslator.
+ if (clientRoutesConfig != null || fileBasedClientRoutes) {
+ if (defaultConfig.isDefined(DefaultDriverOption.ADDRESS_TRANSLATOR_CLASS)) {
+ String translatorClass =
+ defaultConfig.getString(DefaultDriverOption.ADDRESS_TRANSLATOR_CLASS);
+ // PassThroughAddressTranslator is the default and is compatible with client routes.
+ if (!"PassThroughAddressTranslator".equals(translatorClass)
+ && !"com.datastax.oss.driver.internal.core.addresstranslation.PassThroughAddressTranslator"
+ .equals(translatorClass)) {
+ throw new IllegalStateException(
+ String.format(
+ "Both client routes configuration and a custom AddressTranslator ('%s') were "
+ + "provided. They are mutually exclusive. Please use either client routes "
+ + "OR a custom AddressTranslator, but not both.",
+ translatorClass));
+ }
+ }
}
boolean resolveAddresses =
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 4fb9782f566..a96e6a6a5cc 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
@@ -30,8 +30,10 @@
* using its native transport port.
*
* The translator can be used for scenarios when all nodes are behind some kind of proxy, and it
- * is not tailored for one concrete use case. One can use this, for example, for AWS PrivateLink as
- * all nodes would be exposed to consumer - behind one hostname pointing to AWS Endpoint.
+ * is not tailored for one concrete use case. One can use this, for example, for cloud private
+ * endpoint services (such as AWS PrivateLink, Azure Private Link, or GCP Private Service Connect)
+ * where all nodes are exposed to the consumer behind one hostname pointing to a single load
+ * balancer endpoint.
*/
public class FixedHostNameAddressTranslator implements AddressTranslator {
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 04809b400d9..7f3f2610f71 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
@@ -65,6 +65,7 @@
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelPipeline;
import java.nio.ByteBuffer;
+import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -170,10 +171,14 @@ private class InitRequest extends ChannelHandlerRequest {
private Message request;
private Authenticator authenticator;
private ByteBuffer authResponseToken;
+ // Mutable copy of the event types to register; may be narrowed if the server rejects
+ // unsupported types (e.g. CLIENT_ROUTES_CHANGE on older ScyllaDB versions).
+ private final List registerEventTypes;
InitRequest(ChannelHandlerContext ctx) {
super(ctx, timeoutMillis);
this.step = querySupportedOptions ? Step.OPTIONS : Step.STARTUP;
+ this.registerEventTypes = new ArrayList<>(options.eventTypes);
}
@Override
@@ -200,7 +205,7 @@ Message getRequest() {
case AUTH_RESPONSE:
return request = new AuthResponse(authResponseToken);
case REGISTER:
- return request = new Register(options.eventTypes);
+ return request = new Register(registerEventTypes);
default:
throw new AssertionError("unhandled step: " + step);
}
@@ -323,7 +328,7 @@ void onResponse(Message response) {
if (options.keyspace != null) {
step = Step.SET_KEYSPACE;
send();
- } else if (!options.eventTypes.isEmpty()) {
+ } else if (!registerEventTypes.isEmpty()) {
step = Step.REGISTER;
send();
} else {
@@ -331,7 +336,7 @@ void onResponse(Message response) {
}
}
} else if (step == Step.SET_KEYSPACE && response instanceof SetKeyspace) {
- if (!options.eventTypes.isEmpty()) {
+ if (!registerEventTypes.isEmpty()) {
step = Step.REGISTER;
send();
} else {
@@ -359,6 +364,34 @@ 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.
+ //
+ // This is expected on ScyllaDB versions that do not implement the client_routes feature
+ // at the protocol level (Enterprise < 2026.1; not yet available on OSS). The driver
+ // registers for this event only when ClientRoutesConfig is set, so this branch fires
+ // on any pre-2026.1 Enterprise cluster that the user has (mis)configured with client
+ // routes.
+ //
+ // Behavior: strip CLIENT_ROUTES_CHANGE and retry REGISTER with the remaining event
+ // types (SCHEMA_CHANGE, STATUS_CHANGE, TOPOLOGY_CHANGE). The session connects
+ // successfully; client routes table queries may still work if the server exposes a
+ // compatible table, but live push-updates via this event will be absent.
+ LOG.warn(
+ "[{}] Server does not support {} event (requires ScyllaDB Enterprise ≥ 2026.1);"
+ + " retrying REGISTER without it — live client-route updates will be disabled",
+ logPrefix,
+ ProtocolConstants.EventType.CLIENT_ROUTES_CHANGE);
+ registerEventTypes.remove(ProtocolConstants.EventType.CLIENT_ROUTES_CHANGE);
+ if (registerEventTypes.isEmpty()) {
+ // No event types left to register — proceed without event registration
+ setConnectSuccess();
+ } else {
+ // Retry REGISTER with the remaining event types
+ send();
+ }
} else {
failOnUnexpected(error);
}
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/CachingDnsResolver.java b/core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/CachingDnsResolver.java
new file mode 100644
index 00000000000..b51c6fd4fcd
--- /dev/null
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/CachingDnsResolver.java
@@ -0,0 +1,198 @@
+/*
+ * 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.clientroutes;
+
+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.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.atomic.AtomicInteger;
+import net.jcip.annotations.ThreadSafe;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@ThreadSafe
+public class CachingDnsResolver implements DnsResolver {
+ private static final Logger LOG = LoggerFactory.getLogger(CachingDnsResolver.class);
+
+ private final long cacheDurationNanos;
+ /**
+ * Tracks one {@link Semaphore} per hostname that is currently being resolved, together with a
+ * reference count of how many threads are using it. The waiter count is incremented atomically
+ * inside {@link ConcurrentHashMap#compute} before any thread blocks, and decremented (also inside
+ * {@code compute}) after {@link Semaphore#release()}. Because {@code compute} holds the map's
+ * internal bucket lock for that key, the decrement-and-conditional-remove is one indivisible
+ * operation — closing the race that a plain {@code remove(key, value)} leaves open.
+ */
+ private final ConcurrentHashMap semaphores = new ConcurrentHashMap<>();
+
+ private final ConcurrentHashMap cache = new ConcurrentHashMap<>();
+ private final ConcurrentHashMap lastKnownGood = new ConcurrentHashMap<>();
+ private final ThrowingFunction resolverFn;
+
+ public CachingDnsResolver(long cacheDurationMillis) {
+ this(cacheDurationMillis, InetAddress::getByName);
+ }
+
+ CachingDnsResolver(long cacheDurationMillis, ThrowingFunction resolverFn) {
+ this.cacheDurationNanos = cacheDurationMillis * 1_000_000L;
+ this.resolverFn = resolverFn;
+ }
+
+ @Override
+ @NonNull
+ public InetAddress resolve(@NonNull String hostname) throws UnknownHostException {
+ // Fast path: unlocked read — avoids semaphore overhead on a warm cache.
+ CacheEntry entry = cachedEntry(hostname);
+ if (entry != null) {
+ return entry.address;
+ }
+
+ // Atomically get-or-create the entry and increment the waiter count in one compute() call.
+ // This ensures the count is already > 0 before any other thread can observe the entry,
+ // preventing a premature removal by a concurrently finishing thread.
+ SemaphoreEntry semEntry =
+ semaphores.compute(
+ hostname,
+ (k, existing) -> {
+ if (existing == null) {
+ existing = new SemaphoreEntry();
+ }
+ existing.waiters.incrementAndGet();
+ return existing;
+ });
+
+ Semaphore semaphore = semEntry.semaphore;
+ if (!semaphore.tryAcquire()) {
+ // Another thread is already resolving this hostname. Block until it finishes,
+ // then re-check the cache (the other thread will have populated it).
+ try {
+ semaphore.acquire();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ // Decrement the waiter count we incremented above before bailing out.
+ semaphores.compute(
+ hostname, (k, v) -> (v != null && v.waiters.decrementAndGet() == 0) ? null : v);
+ throw new UnknownHostException(
+ "Interrupted while waiting for DNS resolution of " + hostname);
+ }
+ }
+ try {
+ // Contended path: the resolver that held the semaphore just finished — cache hit expected.
+ entry = cachedEntry(hostname);
+ if (entry != null) {
+ return entry.address;
+ }
+ // Cache still empty (e.g. the other thread failed); fall through to resolve ourselves.
+ return doResolve(hostname);
+ } finally {
+ semaphore.release();
+ // Atomically decrement the waiter count and remove the map entry when it reaches zero.
+ // Using compute() here is essential: it holds the bucket lock while decrementing *and*
+ // deciding whether to remove, so no other thread can slip in between the two operations.
+ semaphores.compute(
+ hostname, (k, v) -> (v != null && v.waiters.decrementAndGet() == 0) ? null : v);
+ }
+ }
+
+ /** Returns a non-expired {@link CacheEntry} for {@code hostname}, or {@code null}. */
+ @Nullable
+ private CacheEntry cachedEntry(String hostname) {
+ CacheEntry entry = cache.get(hostname);
+ return (entry != null && System.nanoTime() < entry.expiryNanos) ? entry : null;
+ }
+
+ /**
+ * Performs a real DNS lookup, stores the result in the cache and {@code lastKnownGood}, and
+ * returns the resolved address. Falls back to the last known good address on failure.
+ */
+ private InetAddress doResolve(String hostname) throws UnknownHostException {
+ InetAddress address;
+ try {
+ address = resolverFn.apply(hostname);
+ } catch (UnknownHostException e) {
+ InetAddress fallback = lastKnownGood.get(hostname);
+ if (fallback != null) {
+ LOG.warn(
+ "DNS resolution failed for {}, using last known good address {}", hostname, fallback);
+ return fallback;
+ }
+ throw e;
+ }
+ cache.put(hostname, new CacheEntry(address, System.nanoTime() + cacheDurationNanos));
+ lastKnownGood.put(hostname, address);
+ return address;
+ }
+
+ @Override
+ public void evict(@NonNull String hostname) {
+ cache.remove(hostname);
+ lastKnownGood.remove(hostname);
+ }
+
+ @Override
+ public void clearCache() {
+ cache.clear();
+ // Semaphore entries are self-cleaning: the waiter count reaches zero and the entry is removed
+ // atomically inside compute() at the end of every resolution. Any entry that remains here
+ // belongs to a resolution currently in flight and must not be discarded.
+ //
+ // lastKnownGood is intentionally NOT cleared here. clearCache() is only called at session
+ // close; it is never called during a route-map refresh (CLIENT_ROUTES_CHANGE events or
+ // control-connection reconnects). Retaining last-known-good means the fallback address
+ // survives a close/reopen cycle and remains available if the first re-resolution fails.
+ }
+
+ static class CacheEntry {
+ final InetAddress address;
+ final long expiryNanos;
+
+ CacheEntry(InetAddress address, long expiryNanos) {
+ this.address = address;
+ this.expiryNanos = expiryNanos;
+ }
+ }
+
+ @FunctionalInterface
+ interface ThrowingFunction {
+ R apply(T t) throws UnknownHostException;
+ }
+
+ /**
+ * Wraps a {@link Semaphore} with a reference count ({@code waiters}) that tracks how many threads
+ * are currently using this entry. The count is managed exclusively through {@link
+ * ConcurrentHashMap#compute}, which serialises increments and decrements on the same key.
+ */
+ static class SemaphoreEntry {
+ final Semaphore semaphore = new Semaphore(1);
+ final AtomicInteger waiters = new AtomicInteger(0);
+ }
+
+ /**
+ * Returns the number of hostnames that currently have an active semaphore entry (i.e. have at
+ * least one resolution in flight). Visible for testing only — callers must not depend on the
+ * internal synchronisation mechanism or the map type.
+ */
+ @VisibleForTesting
+ int semaphoreCount() {
+ return semaphores.size();
+ }
+}
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/ClientRouteInfo.java b/core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/ClientRouteInfo.java
new file mode 100644
index 00000000000..4cc9c773fd7
--- /dev/null
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/ClientRouteInfo.java
@@ -0,0 +1,122 @@
+/*
+ * 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.
+ */
+
+/*
+ * Copyright (C) 2025 ScyllaDB
+ *
+ * Modified by ScyllaDB
+ */
+package com.datastax.oss.driver.internal.core.clientroutes;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+import edu.umd.cs.findbugs.annotations.Nullable;
+import java.util.Objects;
+import java.util.UUID;
+import net.jcip.annotations.Immutable;
+
+/**
+ * Represents a single row from the system.client_routes table.
+ *
+ * Each row maps a connection_id + host_id pair to an address that must be DNS-resolved before
+ * use.
+ */
+@Immutable
+public class ClientRouteInfo {
+
+ private final String connectionId;
+ private final UUID hostId;
+ private final String address;
+ private final Integer nativeTransportPort;
+ private final Integer nativeTransportPortSsl;
+
+ public ClientRouteInfo(
+ @NonNull String connectionId,
+ @NonNull UUID hostId,
+ @NonNull String address,
+ @Nullable Integer nativeTransportPort,
+ @Nullable Integer nativeTransportPortSsl) {
+ this.connectionId = Objects.requireNonNull(connectionId, "connectionId must not be null");
+ this.hostId = Objects.requireNonNull(hostId, "hostId must not be null");
+ this.address = Objects.requireNonNull(address, "address must not be null");
+ this.nativeTransportPort = nativeTransportPort;
+ this.nativeTransportPortSsl = nativeTransportPortSsl;
+ }
+
+ @NonNull
+ public String getConnectionId() {
+ return connectionId;
+ }
+
+ @NonNull
+ public UUID getHostId() {
+ return hostId;
+ }
+
+ @NonNull
+ public String getAddress() {
+ return address;
+ }
+
+ @Nullable
+ public Integer getNativeTransportPort() {
+ return nativeTransportPort;
+ }
+
+ @Nullable
+ public Integer getNativeTransportPortSsl() {
+ return nativeTransportPortSsl;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof ClientRouteInfo)) {
+ return false;
+ }
+ ClientRouteInfo that = (ClientRouteInfo) o;
+ return connectionId.equals(that.connectionId)
+ && hostId.equals(that.hostId)
+ && address.equals(that.address)
+ && Objects.equals(nativeTransportPort, that.nativeTransportPort)
+ && Objects.equals(nativeTransportPortSsl, that.nativeTransportPortSsl);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(connectionId, hostId, address, nativeTransportPort, nativeTransportPortSsl);
+ }
+
+ @Override
+ public String toString() {
+ return "ClientRouteInfo{"
+ + "connectionId="
+ + connectionId
+ + ", hostId="
+ + hostId
+ + ", address='"
+ + address
+ + '\''
+ + ", nativeTransportPort="
+ + nativeTransportPort
+ + ", nativeTransportPortSsl="
+ + nativeTransportPortSsl
+ + '}';
+ }
+}
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/DnsResolver.java b/core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/DnsResolver.java
new file mode 100644
index 00000000000..0316c9364cf
--- /dev/null
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/DnsResolver.java
@@ -0,0 +1,79 @@
+/*
+ * 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.clientroutes;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+
+/**
+ * Interface for DNS resolution with caching support.
+ *
+ *
Implementations should provide caching to avoid DNS storms during application startup and
+ * should handle resolution failures gracefully (e.g., by returning the last known good IP).
+ *
+ *
This interface is part of the internal API and is not intended for public use.
+ */
+public interface DnsResolver {
+
+ /**
+ * Resolves a hostname to an IP address, potentially using a cache.
+ *
+ *
Implementations should:
+ *
+ *
+ * - Cache successful resolutions for a configurable duration (default: 500ms per
+ * Description.md)
+ *
- Return cached results when available and not expired
+ *
- On failure, return the last known good IP if available
+ *
- Limit concurrency to avoid DNS storms (default: 1 concurrent resolution per hostname)
+ *
+ *
+ * @param hostname the hostname to resolve (must not be null)
+ * @return the resolved IP address
+ * @throws UnknownHostException if the hostname cannot be resolved and no cached value is
+ * available
+ */
+ @NonNull
+ InetAddress resolve(@NonNull String hostname) throws UnknownHostException;
+
+ /**
+ * Evicts a single hostname from the DNS cache, forcing a fresh lookup on the next {@link
+ * #resolve} call. The last-known-good address for the hostname is also removed so that a stale
+ * fallback is not returned if the new lookup fails.
+ *
+ * This is called when a route's hostname changes (detected during a merge), so that the next
+ * connection attempt to that host resolves the updated DNS name rather than the cached one.
+ *
+ * @param hostname the hostname whose cache entry should be invalidated
+ */
+ void evict(@NonNull String hostname);
+
+ /**
+ * Clears all cached DNS entries, but intentionally retains last-known-good addresses so that the
+ * fallback mechanism continues to work after a cache flush.
+ *
+ *
This method is called exactly once, by {@code ClientRoutesHandler.close()}, when the session
+ * is shut down. It is not called during route-map refreshes (triggered by {@code
+ * CLIENT_ROUTES_CHANGE} events or control-connection reconnects); those refreshes re-query the
+ * routes table but let the TTL-based DNS cache expire naturally.
+ *
+ *
This method is not intended for manual or administrative use outside of session close.
+ */
+ void clearCache();
+}
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/ResolvedClientRoute.java b/core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/ResolvedClientRoute.java
new file mode 100644
index 00000000000..517ce3722ff
--- /dev/null
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/ResolvedClientRoute.java
@@ -0,0 +1,79 @@
+/*
+ * 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.clientroutes;
+
+import edu.umd.cs.findbugs.annotations.NonNull;
+import edu.umd.cs.findbugs.annotations.Nullable;
+import java.util.Objects;
+import java.util.UUID;
+import net.jcip.annotations.Immutable;
+
+@Immutable
+public class ResolvedClientRoute {
+
+ private final UUID hostId;
+ private final String hostname;
+ private final Integer nativeTransportPort;
+ private final Integer nativeTransportPortSsl;
+
+ public ResolvedClientRoute(
+ @NonNull UUID hostId,
+ @NonNull String hostname,
+ @Nullable Integer nativeTransportPort,
+ @Nullable Integer nativeTransportPortSsl) {
+ this.hostId = Objects.requireNonNull(hostId, "hostId must not be null");
+ this.hostname = Objects.requireNonNull(hostname, "hostname must not be null");
+ this.nativeTransportPort = nativeTransportPort;
+ this.nativeTransportPortSsl = nativeTransportPortSsl;
+ }
+
+ @NonNull
+ public UUID getHostId() {
+ return hostId;
+ }
+
+ @NonNull
+ public String getHostname() {
+ return hostname;
+ }
+
+ @Nullable
+ public Integer getNativeTransportPort() {
+ return nativeTransportPort;
+ }
+
+ @Nullable
+ public Integer getNativeTransportPortSsl() {
+ return nativeTransportPortSsl;
+ }
+
+ @Override
+ public String toString() {
+ return "ResolvedClientRoute{"
+ + "hostId="
+ + hostId
+ + ", hostname='"
+ + hostname
+ + '\''
+ + ", nativeTransportPort="
+ + nativeTransportPort
+ + ", nativeTransportPortSsl="
+ + nativeTransportPortSsl
+ + '}';
+ }
+}
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/config/typesafe/TypesafeDriverConfig.java b/core/src/main/java/com/datastax/oss/driver/internal/core/config/typesafe/TypesafeDriverConfig.java
index e1d8c779f2c..2337bf82bc2 100644
--- a/core/src/main/java/com/datastax/oss/driver/internal/core/config/typesafe/TypesafeDriverConfig.java
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/config/typesafe/TypesafeDriverConfig.java
@@ -31,6 +31,7 @@
import com.typesafe.config.ConfigValue;
import com.typesafe.config.ConfigValueFactory;
import edu.umd.cs.findbugs.annotations.NonNull;
+import edu.umd.cs.findbugs.annotations.Nullable;
import java.net.URL;
import java.util.Map;
import java.util.Optional;
@@ -165,6 +166,24 @@ public DriverExecutionProfile getProfile(@NonNull String profileName) {
return profiles;
}
+ /**
+ * Returns the raw Typesafe {@link Config} backing the given profile, or {@code null} if the
+ * profile is not a Typesafe-based profile.
+ *
+ *
This method is public so that other internal components in different packages (e.g., {@code
+ * DefaultDriverContext}) can perform Typesafe-specific operations such as {@code getConfigList()}
+ * that are not available through the {@link DriverExecutionProfile} API. It is intentionally
+ * restricted to the {@code internal} package hierarchy and should not be considered part of the
+ * public driver API.
+ */
+ @Nullable
+ public static Config getRawConfig(@NonNull DriverExecutionProfile profile) {
+ if (profile instanceof TypesafeDriverExecutionProfile) {
+ return ((TypesafeDriverExecutionProfile) profile).getEffectiveOptions();
+ }
+ return null;
+ }
+
/**
* Replace the given options, only if the original values came from {@code
* reference.conf}: if the option was set explicitly in {@code application.conf}, then the
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/context/DefaultDriverContext.java b/core/src/main/java/com/datastax/oss/driver/internal/core/context/DefaultDriverContext.java
index cc725994d7c..ad2b76e190d 100644
--- a/core/src/main/java/com/datastax/oss/driver/internal/core/context/DefaultDriverContext.java
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/context/DefaultDriverContext.java
@@ -28,6 +28,8 @@
import com.datastax.oss.driver.api.core.ProtocolVersion;
import com.datastax.oss.driver.api.core.addresstranslation.AddressTranslator;
import com.datastax.oss.driver.api.core.auth.AuthProvider;
+import com.datastax.oss.driver.api.core.config.ClientRoutesConfig;
+import com.datastax.oss.driver.api.core.config.ClientRoutesEndpoint;
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.DriverConfigLoader;
@@ -55,7 +57,9 @@
import com.datastax.oss.driver.internal.core.channel.ChannelFactory;
import com.datastax.oss.driver.internal.core.channel.DefaultWriteCoalescer;
import com.datastax.oss.driver.internal.core.channel.WriteCoalescer;
+import com.datastax.oss.driver.internal.core.config.typesafe.TypesafeDriverConfig;
import com.datastax.oss.driver.internal.core.control.ControlConnection;
+import com.datastax.oss.driver.internal.core.metadata.ClientRoutesTopologyMonitor;
import com.datastax.oss.driver.internal.core.metadata.CloudTopologyMonitor;
import com.datastax.oss.driver.internal.core.metadata.DefaultTopologyMonitor;
import com.datastax.oss.driver.internal.core.metadata.LoadBalancingPolicyWrapper;
@@ -102,6 +106,9 @@
import com.datastax.oss.protocol.internal.ProtocolV5ClientCodecs;
import com.datastax.oss.protocol.internal.ProtocolV6ClientCodecs;
import com.datastax.oss.protocol.internal.SegmentCodec;
+import com.typesafe.config.Config;
+import com.typesafe.config.ConfigList;
+import com.typesafe.config.ConfigObject;
import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.Nullable;
import io.netty.buffer.ByteBuf;
@@ -197,6 +204,7 @@ public class DefaultDriverContext implements InternalDriverContext {
"loadBalancingPolicyWrapper", this::buildLoadBalancingPolicyWrapper, cycleDetector);
private final LazyReference controlConnectionRef =
new LazyReference<>("controlConnection", this::buildControlConnection, cycleDetector);
+ private final LazyReference clientRoutesHandlerRef;
private final LazyReference requestProcessorRegistryRef =
new LazyReference<>(
"requestProcessorRegistry", this::buildRequestProcessorRegistry, cycleDetector);
@@ -239,6 +247,7 @@ public class DefaultDriverContext implements InternalDriverContext {
private final Map nodeDistanceEvaluatorsFromBuilder;
private final ClassLoader classLoader;
private final InetSocketAddress cloudProxyAddress;
+ private final ClientRoutesConfig clientRoutesConfigFromBuilder;
private final LazyReference requestLogFormatterRef =
new LazyReference<>("requestLogFormatter", this::buildRequestLogFormatter, cycleDetector);
private final UUID startupClientId;
@@ -294,6 +303,12 @@ public DefaultDriverContext(
this.nodeDistanceEvaluatorsFromBuilder = programmaticArguments.getNodeDistanceEvaluators();
this.classLoader = programmaticArguments.getClassLoader();
this.cloudProxyAddress = programmaticArguments.getCloudProxyAddress();
+ this.clientRoutesConfigFromBuilder = programmaticArguments.getClientRoutesConfig();
+ this.clientRoutesHandlerRef =
+ new LazyReference<>(
+ "clientRoutesHandler",
+ () -> buildClientRoutesHandler(clientRoutesConfigFromBuilder),
+ cycleDetector);
this.startupClientId = programmaticArguments.getStartupClientId();
this.startupApplicationName = programmaticArguments.getStartupApplicationName();
this.startupApplicationVersion = programmaticArguments.getStartupApplicationVersion();
@@ -418,6 +433,123 @@ protected AddressTranslator buildAddressTranslator() {
DefaultDriverOption.ADDRESS_TRANSLATOR_CLASS)));
}
+ protected ClientRoutesTopologyMonitor buildClientRoutesHandler(
+ ClientRoutesConfig clientRoutesConfigFromBuilder) {
+ ClientRoutesConfig configFromFile = buildClientRoutesConfigFromFile();
+ if (clientRoutesConfigFromBuilder != null) {
+ if (configFromFile != null) {
+ LOG.warn(
+ "[{}] Both programmatic ClientRoutesConfig and '{}' were provided. "
+ + "The programmatic configuration takes precedence.",
+ getSessionName(),
+ DefaultDriverOption.CLIENT_ROUTES_ENDPOINTS.getPath());
+ }
+ return new ClientRoutesTopologyMonitor(this, clientRoutesConfigFromBuilder);
+ }
+ if (configFromFile != null) {
+ return new ClientRoutesTopologyMonitor(this, configFromFile);
+ }
+
+ return null;
+ }
+
+ /**
+ * Reads the {@code advanced.client-routes} section from the HOCON configuration and builds a
+ * {@link ClientRoutesConfig}, or returns {@code null} if {@code endpoints} is not defined.
+ *
+ * The {@code endpoints} value is a HOCON list of objects; it cannot be read through the flat
+ * {@link com.datastax.oss.driver.api.core.config.DriverExecutionProfile} typed API, so we access
+ * the underlying Typesafe {@link Config} directly via {@link TypesafeDriverConfig#getRawConfig}.
+ */
+ @Nullable
+ // Package-private to allow unit-testing without a live cluster.
+ ClientRoutesConfig buildClientRoutesConfigFromFile() {
+ DriverExecutionProfile defaultProfile = config.getDefaultProfile();
+ if (!defaultProfile.isDefined(DefaultDriverOption.CLIENT_ROUTES_ENDPOINTS)) {
+ return null;
+ }
+
+ Config rawConfig = TypesafeDriverConfig.getRawConfig(defaultProfile);
+ if (rawConfig == null) {
+ // Non-Typesafe config backend: endpoints option is marked as defined but we cannot parse
+ // the compound-object list. Warn and skip.
+ LOG.warn(
+ "[{}] '{}' is defined but the underlying config implementation is not Typesafe-based; "
+ + "config-file client routes cannot be parsed. Use the programmatic API instead.",
+ getSessionName(),
+ DefaultDriverOption.CLIENT_ROUTES_ENDPOINTS.getPath());
+ return null;
+ }
+
+ String endpointsPath = DefaultDriverOption.CLIENT_ROUTES_ENDPOINTS.getPath();
+ ConfigList endpointsList;
+ try {
+ endpointsList = rawConfig.getList(endpointsPath);
+ } catch (Exception e) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Failed to read '%s' from configuration: %s. "
+ + "Expected a list of objects, each with a required 'connection-id' field "
+ + "and an optional 'connection-addr' field (plain hostname, no port), for example: "
+ + "%s = [{ connection-id = \"\", connection-addr = \"host.example.com\" }]",
+ endpointsPath, e.getMessage(), endpointsPath),
+ e);
+ }
+
+ if (endpointsList.isEmpty()) {
+ throw new IllegalArgumentException(
+ String.format(
+ "'%s' is set but contains no entries; at least one endpoint is required. "
+ + "Each entry must be a HOCON object with a 'connection-id' field (UUID string) "
+ + "and an optional 'connection-addr' field (plain hostname, no port), for example: "
+ + "%s = [{ connection-id = \"\", connection-addr = \"host.example.com\" }]",
+ endpointsPath, endpointsPath));
+ }
+
+ ClientRoutesConfig.Builder builder = ClientRoutesConfig.builder();
+
+ for (int i = 0; i < endpointsList.size(); i++) {
+ if (!(endpointsList.get(i) instanceof ConfigObject)) {
+ throw new IllegalArgumentException(
+ String.format(
+ "'%s[%d]' must be a HOCON object but got a %s. "
+ + "Each entry must have a required 'connection-id' field (UUID string) "
+ + "and an optional 'connection-addr' field (plain hostname, no port), for example: "
+ + "{ connection-id = \"\", connection-addr = \"host.example.com\" }. "
+ + "Got: %s",
+ endpointsPath, i, endpointsList.get(i).valueType(), endpointsList.get(i)));
+ }
+ Config entry = ((ConfigObject) endpointsList.get(i)).toConfig();
+
+ if (!entry.hasPath("connection-id")) {
+ throw new IllegalArgumentException(
+ String.format(
+ "'%s[%d]' is missing the required 'connection-id' field. "
+ + "Each entry must be a HOCON object with a 'connection-id' field (UUID string) "
+ + "and an optional 'connection-addr' field (plain hostname, no port), for example: "
+ + "{ connection-id = \"\", connection-addr = \"host.example.com\" }",
+ endpointsPath, i));
+ }
+ String connectionId = entry.getString("connection-id");
+ String connectionAddr =
+ entry.hasPath("connection-addr") ? entry.getString("connection-addr") : null;
+ builder.addEndpoint(new ClientRoutesEndpoint(connectionId, connectionAddr));
+ }
+
+ // Read scalar options through the standard profile API
+ if (defaultProfile.isDefined(DefaultDriverOption.CLIENT_ROUTES_TABLE_NAME)) {
+ builder.withTableName(defaultProfile.getString(DefaultDriverOption.CLIENT_ROUTES_TABLE_NAME));
+ }
+ if (defaultProfile.isDefined(DefaultDriverOption.CLIENT_ROUTES_DNS_CACHE_DURATION)) {
+ builder.withDnsCacheDuration(
+ defaultProfile
+ .getDuration(DefaultDriverOption.CLIENT_ROUTES_DNS_CACHE_DURATION)
+ .toMillis());
+ }
+
+ return builder.build();
+ }
+
protected Optional buildSslEngineFactory(SslEngineFactory factoryFromBuilder) {
return (factoryFromBuilder != null)
? Optional.of(factoryFromBuilder)
@@ -492,10 +624,13 @@ protected ChannelFactory buildChannelFactory() {
}
protected TopologyMonitor buildTopologyMonitor() {
- if (cloudProxyAddress == null) {
- return new DefaultTopologyMonitor(this);
+ if (cloudProxyAddress != null) {
+ return new CloudTopologyMonitor(this, cloudProxyAddress);
+ }
+ if (clientRoutesConfigFromBuilder != null) {
+ return new ClientRoutesTopologyMonitor(this, clientRoutesConfigFromBuilder);
}
- return new CloudTopologyMonitor(this, cloudProxyAddress);
+ return new DefaultTopologyMonitor(this);
}
protected MetadataManager buildMetadataManager() {
@@ -905,6 +1040,12 @@ public ControlConnection getControlConnection() {
return controlConnectionRef.get();
}
+ @Nullable
+ @Override
+ public ClientRoutesTopologyMonitor getClientRoutesHandler() {
+ return clientRoutesHandlerRef.get();
+ }
+
@NonNull
@Override
public RequestProcessorRegistry getRequestProcessorRegistry() {
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/context/InternalDriverContext.java b/core/src/main/java/com/datastax/oss/driver/internal/core/context/InternalDriverContext.java
index 81349b0c665..236fce62b8a 100644
--- a/core/src/main/java/com/datastax/oss/driver/internal/core/context/InternalDriverContext.java
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/context/InternalDriverContext.java
@@ -26,6 +26,7 @@
import com.datastax.oss.driver.internal.core.channel.ChannelFactory;
import com.datastax.oss.driver.internal.core.channel.WriteCoalescer;
import com.datastax.oss.driver.internal.core.control.ControlConnection;
+import com.datastax.oss.driver.internal.core.metadata.ClientRoutesTopologyMonitor;
import com.datastax.oss.driver.internal.core.metadata.LoadBalancingPolicyWrapper;
import com.datastax.oss.driver.internal.core.metadata.MetadataManager;
import com.datastax.oss.driver.internal.core.metadata.TopologyMonitor;
@@ -108,6 +109,15 @@ public interface InternalDriverContext extends DriverContext {
@NonNull
ControlConnection getControlConnection();
+ /**
+ * Returns the client routes handler if configured, or null if client routes are not enabled.
+ *
+ * Client routes are enabled when a {@link
+ * com.datastax.oss.driver.api.core.config.ClientRoutesConfig} is provided to the session builder.
+ */
+ @Nullable
+ ClientRoutesTopologyMonitor getClientRoutesHandler();
+
@NonNull
RequestProcessorRegistry getRequestProcessorRegistry();
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 5e5e5fa84ee..fd3653f51d9 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
@@ -31,6 +31,7 @@
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.ClientRoutesChangeEvent;
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;
@@ -190,6 +191,9 @@ public void onEvent(Message eventMessage) {
case ProtocolConstants.EventType.SCHEMA_CHANGE:
processSchemaChange(event);
break;
+ case ProtocolConstants.EventType.CLIENT_ROUTES_CHANGE:
+ processClientRoutesChange(event);
+ break;
default:
LOG.warn("[{}] Unsupported event type: {}", logPrefix, event.type);
}
@@ -242,6 +246,15 @@ private void processSchemaChange(Event event) {
});
}
+ private void processClientRoutesChange(Event event) {
+ com.datastax.oss.protocol.internal.response.event.ClientRoutesChangeEvent crce =
+ (com.datastax.oss.protocol.internal.response.event.ClientRoutesChangeEvent) event;
+ LOG.debug("[{}] Received CLIENT_ROUTES_CHANGE event: {}", logPrefix, crce);
+ context
+ .getEventBus()
+ .fire(new ClientRoutesChangeEvent(crce.changeType, crce.connectionIds, crce.hostIds));
+ }
+
private class SingleThreaded {
private final InternalDriverContext context;
private final DriverConfig config;
@@ -292,7 +305,9 @@ private void init(
}
initWasCalled = true;
try {
- ImmutableList eventTypes = buildEventTypes(listenToClusterEvents);
+ boolean listenClientRoutesEvents = context.getClientRoutesHandler() != null;
+ ImmutableList eventTypes =
+ buildEventTypes(listenToClusterEvents, listenClientRoutesEvents);
LOG.debug("[{}] Initializing with event types {}", logPrefix, eventTypes);
channelOptions =
DriverChannelOptions.builder()
@@ -467,6 +482,8 @@ private void onSuccessfulReconnect() {
// Otherwise, perform a full refresh (we don't know how long we were disconnected)
if (!isFirstConnection) {
+ context.getEventBus().fire(ControlConnectionReconnectEvent.INSTANCE);
+
context
.getMetadataManager()
.refreshNodes()
@@ -595,7 +612,7 @@ private boolean isAuthFailure(Throwable error) {
if (error instanceof AllNodesFailedException) {
Collection> errors =
((AllNodesFailedException) error).getAllErrors().values();
- if (errors.size() == 0) {
+ if (errors.isEmpty()) {
return false;
}
for (List nodeErrors : errors) {
@@ -609,7 +626,8 @@ private boolean isAuthFailure(Throwable error) {
return true;
}
- private static ImmutableList buildEventTypes(boolean listenClusterEvents) {
+ private static ImmutableList buildEventTypes(
+ boolean listenClusterEvents, boolean listenClientRoutesEvents) {
ImmutableList.Builder builder = ImmutableList.builder();
builder.add(ProtocolConstants.EventType.SCHEMA_CHANGE);
if (listenClusterEvents) {
@@ -617,6 +635,9 @@ private static ImmutableList buildEventTypes(boolean listenClusterEvents
.add(ProtocolConstants.EventType.STATUS_CHANGE)
.add(ProtocolConstants.EventType.TOPOLOGY_CHANGE);
}
+ if (listenClientRoutesEvents) {
+ builder.add(ProtocolConstants.EventType.CLIENT_ROUTES_CHANGE);
+ }
return builder.build();
}
}
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/control/ControlConnectionReconnectEvent.java b/core/src/main/java/com/datastax/oss/driver/internal/core/control/ControlConnectionReconnectEvent.java
new file mode 100644
index 00000000000..a3b2d9b8e83
--- /dev/null
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/control/ControlConnectionReconnectEvent.java
@@ -0,0 +1,40 @@
+/*
+ * 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.control;
+
+import net.jcip.annotations.Immutable;
+
+/**
+ * Fired on the internal event bus when the control connection successfully reconnects after a
+ * disconnection. Components that need to refresh their state after a reconnection (e.g. {@link
+ * com.datastax.oss.driver.internal.core.metadata.ClientRoutesTopologyMonitor}) can listen for this
+ * event.
+ */
+@Immutable
+public class ControlConnectionReconnectEvent {
+
+ public static final ControlConnectionReconnectEvent INSTANCE =
+ new ControlConnectionReconnectEvent();
+
+ private ControlConnectionReconnectEvent() {}
+
+ @Override
+ public String toString() {
+ return "ControlConnectionReconnectEvent";
+ }
+}
diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesChangeEvent.java b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesChangeEvent.java
new file mode 100644
index 00000000000..d1c93b45d41
--- /dev/null
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesChangeEvent.java
@@ -0,0 +1,64 @@
+/*
+ * 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 edu.umd.cs.findbugs.annotations.NonNull;
+import java.util.List;
+import net.jcip.annotations.Immutable;
+
+/**
+ * Fired on the internal event bus when a {@code CLIENT_ROUTES_CHANGE} protocol event is received on
+ * the control connection. The {@link ClientRoutesTopologyMonitor} listens for this event and
+ * triggers a refresh of the client routes cache.
+ *
+ * Carries the data from the protocol event:
+ *
+ *
+ * - {@link #changeType} — the type of change (e.g. {@code "UPDATED"})
+ *
- {@link #connectionIds} — opaque string identifiers of the affected connections
+ *
- {@link #hostIds} — UUIDs of the affected hosts
+ *
+ */
+@Immutable
+public class ClientRoutesChangeEvent {
+
+ public final String changeType;
+ public final List connectionIds;
+ public final List hostIds;
+
+ public ClientRoutesChangeEvent(
+ @NonNull String changeType,
+ @NonNull List connectionIds,
+ @NonNull List hostIds) {
+ this.changeType = changeType;
+ this.connectionIds = connectionIds;
+ this.hostIds = hostIds;
+ }
+
+ @Override
+ public String toString() {
+ return "ClientRoutesChangeEvent("
+ + "changeType='"
+ + changeType
+ + "', connectionIds="
+ + connectionIds
+ + ", hostIds="
+ + hostIds
+ + ")";
+ }
+}
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
new file mode 100644
index 00000000000..bd32a3ffe01
--- /dev/null
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesEndPoint.java
@@ -0,0 +1,106 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.datastax.oss.driver.internal.core.metadata;
+
+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.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.util.Objects;
+import java.util.UUID;
+
+public class ClientRoutesEndPoint implements EndPoint {
+
+ private final UUID hostID;
+ private final ClientRoutesTopologyMonitor topologyMonitor;
+ private final String metricPrefix;
+
+ /**
+ * @param topologyMonitor the topology monitor used to resolve the endpoint address on demand.
+ * @param hostID the host UUID identifying this node in the cluster.
+ * @param broadcastInetAddress the node's broadcast address (from system.peers or system.local),
+ * used to build a stable metric prefix. May be {@code null} if the address could not be
+ * determined, in which case the hostID is used as the metric prefix instead.
+ */
+ public ClientRoutesEndPoint(
+ @NonNull ClientRoutesTopologyMonitor topologyMonitor,
+ @NonNull UUID hostID,
+ @Nullable InetAddress broadcastInetAddress) {
+ this.topologyMonitor =
+ Objects.requireNonNull(topologyMonitor, "Topology monitor cannot be null");
+ this.hostID = Objects.requireNonNull(hostID, "HOST uuid cannot be null");
+ this.metricPrefix = buildMetricPrefix(broadcastInetAddress, hostID);
+ }
+
+ @NonNull
+ public UUID getHostID() {
+ return hostID;
+ }
+
+ @NonNull
+ @Override
+ public InetSocketAddress resolve() {
+ try {
+ return topologyMonitor.resolve(hostID);
+ } catch (Exception e) {
+ throw new IllegalArgumentException(e);
+ }
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (other == this) {
+ return true;
+ } else if (other instanceof ClientRoutesEndPoint) {
+ ClientRoutesEndPoint that = (ClientRoutesEndPoint) other;
+ return this.hostID.equals(that.hostID);
+ } else {
+ return false;
+ }
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(hostID);
+ }
+
+ @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 hostID.toString();
+ }
+
+ @NonNull
+ @Override
+ public String asMetricPrefix() {
+ return metricPrefix;
+ }
+
+ private static String buildMetricPrefix(@Nullable InetAddress address, @NonNull UUID hostID) {
+ if (address == null) {
+ return hostID.toString();
+ }
+ // getHostAddress() returns clean IP without leading slash:
+ // IPv4: "127.0.0.1" IPv6: "0:0:0:0:0:0:0:1"
+ // Replace dots for IPv4; colons are kept for IPv6 (consistent with DefaultEndPoint)
+ return address.getHostAddress().replace('.', '_') + '_' + hostID;
+ }
+}
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
new file mode 100644
index 00000000000..6703fb911f7
--- /dev/null
+++ b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesTopologyMonitor.java
@@ -0,0 +1,404 @@
+/*
+ * 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.config.ClientRoutesConfig;
+import com.datastax.oss.driver.api.core.config.ClientRoutesEndpoint;
+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.adminrequest.AdminRequestHandler;
+import com.datastax.oss.driver.internal.core.adminrequest.AdminRow;
+import com.datastax.oss.driver.internal.core.channel.DriverChannel;
+import com.datastax.oss.driver.internal.core.clientroutes.CachingDnsResolver;
+import com.datastax.oss.driver.internal.core.clientroutes.DnsResolver;
+import com.datastax.oss.driver.internal.core.clientroutes.ResolvedClientRoute;
+import com.datastax.oss.driver.internal.core.context.InternalDriverContext;
+import com.datastax.oss.driver.internal.core.control.ControlConnectionReconnectEvent;
+import edu.umd.cs.findbugs.annotations.NonNull;
+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.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+import net.jcip.annotations.ThreadSafe;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@ThreadSafe
+public class ClientRoutesTopologyMonitor extends DefaultTopologyMonitor {
+ private static final Logger LOG = LoggerFactory.getLogger(ClientRoutesTopologyMonitor.class);
+
+ private static final String SELECT_ROUTES_COLUMNS =
+ "SELECT connection_id, host_id, address, port, tls_port FROM %s";
+
+ final InternalDriverContext context;
+
+ private final ClientRoutesConfig config;
+ private final String logPrefix;
+ final AtomicReference