From c70f53c51b53d3d797be7d2cfc4ee23af8849274 Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Wed, 18 Feb 2026 13:07:37 +0100 Subject: [PATCH 1/9] build: update `native-protocol` version to `1.5.2.2` in pom.xml --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 4e1ac550503..1b8ea4d948e 100644 --- a/pom.xml +++ b/pom.xml @@ -171,7 +171,7 @@ com.scylladb native-protocol - 1.5.2.1 + 1.5.2.2 io.netty From 53b44626b6f5268a80aea4b7a1c7ade126bee8b2 Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Tue, 17 Feb 2026 19:56:56 +0100 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20PrivateLink=20support=20Phase=201?= =?UTF-8?q?=20-=20Add=20ClientRoutesConfig=20and=20ClientRoutesEndpoint=20?= =?UTF-8?q?for=20PrivateLink=20deployments=20=E2=9A=99=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: update client routes configuration details for mutual exclusivity with AddressTranslator and cloud bundles Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../api/core/config/ClientRoutesConfig.java | 191 ++++++++++++++++++ .../api/core/config/ClientRoutesEndpoint.java | 103 ++++++++++ .../api/core/config/DefaultDriverOption.java | 11 + .../driver/api/core/config/OptionsMap.java | 1 + .../api/core/config/TypedDriverOption.java | 3 + .../core/session/ProgrammaticArguments.java | 21 +- .../api/core/session/SessionBuilder.java | 80 ++++++++ .../internal/core/util/AddressParser.java | 129 ++++++++++++ core/src/main/resources/reference.conf | 25 +++ .../core/config/ClientRoutesConfigTest.java | 123 +++++++++++ .../ClientRoutesSessionBuilderTest.java | 64 ++++++ .../internal/core/util/AddressParserTest.java | 157 ++++++++++++++ manual/core/address_resolution/README.md | 46 +++++ 13 files changed, 952 insertions(+), 2 deletions(-) create mode 100644 core/src/main/java/com/datastax/oss/driver/api/core/config/ClientRoutesConfig.java create mode 100644 core/src/main/java/com/datastax/oss/driver/api/core/config/ClientRoutesEndpoint.java create mode 100644 core/src/main/java/com/datastax/oss/driver/internal/core/util/AddressParser.java create mode 100644 core/src/test/java/com/datastax/oss/driver/api/core/config/ClientRoutesConfigTest.java create mode 100644 core/src/test/java/com/datastax/oss/driver/api/core/session/ClientRoutesSessionBuilderTest.java create mode 100644 core/src/test/java/com/datastax/oss/driver/internal/core/util/AddressParserTest.java diff --git a/core/src/main/java/com/datastax/oss/driver/api/core/config/ClientRoutesConfig.java b/core/src/main/java/com/datastax/oss/driver/api/core/config/ClientRoutesConfig.java new file mode 100644 index 00000000000..b224b523751 --- /dev/null +++ b/core/src/main/java/com/datastax/oss/driver/api/core/config/ClientRoutesConfig.java @@ -0,0 +1,191 @@ +/* + * 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 com.datastax.oss.driver.api.core.session.SessionBuilder; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import net.jcip.annotations.Immutable; + +/** + * Configuration for client routes, used in PrivateLink-style deployments. + * + *

Client routes enable the driver to discover and connect to nodes through a load balancer (such + * as AWS PrivateLink) 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 client routes are + * configured, the driver will use its internal client routes handler for address translation. + * + *

Example usage: + * + *

{@code
+ * ClientRoutesConfig config = ClientRoutesConfig.builder()
+ *     .addEndpoint(new ClientRoutesEndpoint(
+ *         UUID.fromString("12345678-1234-1234-1234-123456789012"),
+ *         "my-privatelink.us-east-1.aws.scylladb.com:9042"))
+ *     .build();
+ *
+ * CqlSession session = CqlSession.builder()
+ *     .withClientRoutesConfig(config)
+ *     .build();
+ * }
+ * + * @see SessionBuilder#withClientRoutesConfig(ClientRoutesConfig) + * @see ClientRoutesEndpoint + */ +@Immutable +public class ClientRoutesConfig { + + private final List endpoints; + private final String tableName; + + private ClientRoutesConfig(List endpoints, String tableName) { + if (endpoints == null || endpoints.isEmpty()) { + throw new IllegalArgumentException("At least one endpoint must be specified"); + } + this.endpoints = Collections.unmodifiableList(new ArrayList<>(endpoints)); + this.tableName = tableName; + } + + /** + * Returns the list of configured endpoints. + * + * @return an immutable list of endpoints. + */ + @NonNull + public List getEndpoints() { + return endpoints; + } + + /** + * Returns the name of the system table to query for client routes. + * + * @return the table name, or null to use the default ({@code system.client_routes}). + */ + @Nullable + public String getTableName() { + return tableName; + } + + /** + * Creates a new builder for constructing a {@link ClientRoutesConfig}. + * + * @return a new builder instance. + */ + @NonNull + public static Builder builder() { + return new Builder(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ClientRoutesConfig)) { + return false; + } + ClientRoutesConfig that = (ClientRoutesConfig) o; + return endpoints.equals(that.endpoints) && Objects.equals(tableName, that.tableName); + } + + @Override + public int hashCode() { + return Objects.hash(endpoints, tableName); + } + + @Override + public String toString() { + return "ClientRoutesConfig{" + + "endpoints=" + + endpoints + + ", tableName='" + + tableName + + '\'' + + '}'; + } + + /** Builder for {@link ClientRoutesConfig}. */ + public static class Builder { + private final List endpoints = new ArrayList<>(); + private String tableName; + + /** + * Adds an endpoint to the configuration. + * + * @param endpoint the endpoint to add (must not be null). + * @return this builder. + */ + @NonNull + public Builder addEndpoint(@NonNull ClientRoutesEndpoint endpoint) { + this.endpoints.add(Objects.requireNonNull(endpoint, "endpoint must not be null")); + return this; + } + + /** + * Sets the endpoints for the configuration, replacing any previously added endpoints. + * + * @param endpoints the endpoints to set (must not be null or empty). + * @return this builder. + */ + @NonNull + public Builder withEndpoints(@NonNull List 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. + */ + @NonNull + public Builder withTableName(@Nullable String tableName) { + this.tableName = tableName; + 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); + } + } +} 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..516d6fd9268 --- /dev/null +++ b/core/src/main/java/com/datastax/oss/driver/api/core/config/ClientRoutesEndpoint.java @@ -0,0 +1,103 @@ +/* + * 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 java.util.UUID; +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 UUID 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 UUID 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 connection address to use as a seed host (may be null). + */ + public ClientRoutesEndpoint(@NonNull UUID 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 UUID getConnectionId() { + return connectionId; + } + + /** + * Returns the connection address for this endpoint, or null if not specified. + * + *

When provided and no explicit contact points are given to the session builder, this address + * will be used as a seed host 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..68209b1f240 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 @@ -449,6 +449,17 @@ public enum DefaultDriverOption implements DriverOption { */ ADDRESS_TRANSLATOR_CLASS("advanced.address-translator.class"), + /** + * The name of the system table to query for client routes information. + * + *

This is used when client routes are configured programmatically via {@link + * com.datastax.oss.driver.api.core.session.SessionBuilder#withClientRoutesConfig}. The default + * value is {@code system.client_routes}. + * + *

Value-type: {@link String} + */ + CLIENT_ROUTES_TABLE_NAME("advanced.client-routes.table-name"), + /** * The native protocol version to use. * 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..3a999146c9b 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,7 @@ protected static void fillWithDriverDefaults(OptionsMap map) { map.put( TypedDriverOption.LOAD_BALANCING_DEFAULT_LWT_REQUEST_ROUTING_METHOD, "PRESERVE_REPLICA_ORDER"); + map.put(TypedDriverOption.CLIENT_ROUTES_TABLE_NAME, "system.client_routes"); } @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..7ea10faf841 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,9 @@ public String toString() { DefaultDriverOption.LOAD_BALANCING_DEFAULT_LWT_REQUEST_ROUTING_METHOD, GenericType.STRING); + public static final TypedDriverOption CLIENT_ROUTES_TABLE_NAME = + new TypedDriverOption<>(DefaultDriverOption.CLIENT_ROUTES_TABLE_NAME, GenericType.STRING); + 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..e3d7bdbda7e 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,8 @@ 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.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; @@ -53,6 +55,7 @@ import com.datastax.oss.driver.internal.core.context.InternalDriverContext; import com.datastax.oss.driver.internal.core.metadata.DefaultEndPoint; import com.datastax.oss.driver.internal.core.session.DefaultSession; +import com.datastax.oss.driver.internal.core.util.AddressParser; import com.datastax.oss.driver.internal.core.util.concurrent.BlockingOperation; import com.datastax.oss.driver.internal.core.util.concurrent.CompletableFutures; import edu.umd.cs.findbugs.annotations.NonNull; @@ -98,6 +101,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 +739,44 @@ public SelfT withCloudProxyAddress(@Nullable InetSocketAddress cloudProxyAddress return self; } + /** + * Configures this session to use client routes for PrivateLink-style deployments. + * + *

Client routes enable the driver to discover and connect to nodes through a load balancer + * (such as AWS PrivateLink) 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, + * a warning will be logged and the client routes configuration will take precedence. If you need + * custom address translation behavior with client routes, consider implementing that logic within + * your client routes endpoint mapping instead. + * + *

Example usage: + * + *

{@code
+   * ClientRoutesConfig config = ClientRoutesConfig.builder()
+   *     .addEndpoint(new ClientRoutesEndpoint(
+   *         UUID.fromString("12345678-1234-1234-1234-123456789012"),
+   *         "my-privatelink.us-east-1.aws.scylladb.com:9042"))
+   *     .build();
+   *
+   * CqlSession session = CqlSession.builder()
+   *     .withClientRoutesConfig(config)
+   *     .build();
+   * }
+ * + * @param clientRoutesConfig the client routes configuration to use, or 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 +871,7 @@ public CompletionStage buildAsync() { CompletableFutures.propagateCancellation(wrapStage, buildStage); return wrapStage; } + /** * Convenience method to call {@link #buildAsync()} and block on the result. * @@ -896,6 +939,43 @@ protected final CompletionStage buildDefaultSessionAsync() { withSslEngineFactory(cloudConfig.getSslEngineFactory()); withCloudProxyAddress(cloudConfig.getProxyAddress()); programmaticArguments = programmaticArgumentsBuilder.build(); + + // Check for mutual exclusivity with client routes + if (clientRoutesConfig != null) { + 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."); + } + } + + // Handle client routes configuration + if (clientRoutesConfig != null) { + // Check for mutual exclusivity with address translator + if (defaultConfig.isDefined(DefaultDriverOption.ADDRESS_TRANSLATOR_CLASS)) { + String translatorClass = + defaultConfig.getString(DefaultDriverOption.ADDRESS_TRANSLATOR_CLASS); + // PassThroughAddressTranslator is the default, so it's compatible + 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)); + } + } + + // Use connection addresses as seed hosts if no explicit contact points provided + if (programmaticContactPoints.isEmpty() && configContactPoints.isEmpty()) { + for (ClientRoutesEndpoint endpoint : clientRoutesConfig.getEndpoints()) { + if (endpoint.getConnectionAddr() != null) { + String addr = endpoint.getConnectionAddr().trim(); + InetSocketAddress socketAddress = + AddressParser.parseContactPoint(addr, endpoint.getConnectionId()); + programmaticContactPoints.add(new DefaultEndPoint(socketAddress)); + } + } + } } boolean resolveAddresses = diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/util/AddressParser.java b/core/src/main/java/com/datastax/oss/driver/internal/core/util/AddressParser.java new file mode 100644 index 00000000000..d5e9c20948a --- /dev/null +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/util/AddressParser.java @@ -0,0 +1,129 @@ +/* + * 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.util; + +import java.net.InetSocketAddress; +import java.util.UUID; + +/** + * Utility class for parsing network addresses. This is used internally for parsing contact points + * and client routes endpoints. + * + *

This class is part of the internal API and is not intended for public use. Classes under + * {@code com.datastax.oss.driver.internal.*} are not user-facing and may change without notice. + */ +public class AddressParser { + + private static final int DEFAULT_PORT = 9042; + + /** + * Parses a contact point address string into an InetSocketAddress. Supports IPv4, IPv6, and + * hostname formats with optional port. + * + *

Accepted formats: + * + *

    + *
  • hostname:port (e.g., "localhost:9042") + *
  • hostname (defaults to port 9042) + *
  • ipv4:port (e.g., "192.168.1.1:9042") + *
  • [ipv6]:port (e.g., "[::1]:9042", "[2001:db8::1]:9042") + *
  • [ipv6] (defaults to port 9042) + *
+ * + * @param address the address string to parse (must not be null) + * @param connectionId the connection ID for error messages (can be null for generic parsing) + * @return an InetSocketAddress + * @throws IllegalArgumentException if the address is null, empty, or has an invalid format + */ + public static InetSocketAddress parseContactPoint(String address, UUID connectionId) { + if (address == null) { + throw new IllegalArgumentException( + formatErrorMessage(null, connectionId, "Address must not be null")); + } + if (address.isEmpty()) { + throw new IllegalArgumentException( + formatErrorMessage(address, connectionId, "Address must not be empty")); + } + + try { + // Add scheme to make it a valid URI for parsing + // URI class handles IPv6 brackets, hostname, and port correctly + String uriString = address.contains("://") ? address : "cql://" + address; + java.net.URI uri = new java.net.URI(uriString); + + String host = uri.getHost(); + int port = uri.getPort(); + + // Validate we got a host + if (host == null || host.isEmpty()) { + throw new IllegalArgumentException( + formatErrorMessage( + address, + connectionId, + "Invalid address format. Expected format: 'host:port' or '[ipv6]:port'")); + } + + // Use default port if not specified + if (port == -1) { + port = DEFAULT_PORT; + } + + // Validate port range + if (port < 1 || port > 65535) { + throw new IllegalArgumentException( + formatErrorMessage( + address, + connectionId, + String.format("Invalid port %d. Port must be between 1 and 65535.", port))); + } + + return InetSocketAddress.createUnresolved(host, port); + + } catch (java.net.URISyntaxException e) { + throw new IllegalArgumentException( + formatErrorMessage( + address, + connectionId, + "Invalid address format. Expected format: 'host:port' or '[ipv6]:port'. " + + e.getMessage()), + e); + } + } + + /** + * Formats an error message for address parsing failures, including the address and connection ID. + * + * @param address the address that failed to parse (can be null) + * @param connectionId the connection ID associated with this address (can be null) + * @param message the specific error message to include + * @return a formatted error message string + */ + private static String formatErrorMessage(String address, UUID connectionId, String message) { + String addressStr = (address == null) ? "null" : "'" + address + "'"; + if (connectionId != null) { + return String.format( + "Failed to parse address %s (connection ID: %s). %s", addressStr, connectionId, message); + } else { + return String.format("Failed to parse address %s. %s", addressStr, message); + } + } + + private AddressParser() { + // Utility class, no instances + } +} diff --git a/core/src/main/resources/reference.conf b/core/src/main/resources/reference.conf index 40d56d67341..7a83352fcdc 100644 --- a/core/src/main/resources/reference.conf +++ b/core/src/main/resources/reference.conf @@ -1110,6 +1110,31 @@ datastax-java-driver { # advertised-hostname = mycustomhostname } + # Client routes configuration for PrivateLink-style deployments. + # + # Client routes enable the driver to discover and connect to nodes through a load balancer + # (such as AWS PrivateLink) by reading endpoint mappings from the system.client_routes table. + # Each endpoint is identified by a connection ID and maps to specific node addresses. + # + # Note: Client routes endpoints are configured programmatically via + # SessionBuilder.withClientRoutesConfig(). This configuration section only provides the + # system table name option. + # + # Client routes are mutually exclusive with: + # - A custom AddressTranslator: If both are configured, client routes take precedence + # and the AddressTranslator is effectively ignored (a warning is logged). + # - Cloud secure connect bundles: If both are configured, the cloud bundle takes precedence + # and client routes are ignored (a warning is logged). + # + # Required: no (programmatic configuration only) + # Modifiable at runtime: no + # Overridable in a profile: no + advanced.client-routes { + # The name of the system table to query for client routes information. + # This is typically only changed for testing purposes. + table-name = "system.client_routes" + } + # Whether to resolve the addresses passed to `basic.contact-points`. # # If this is true, addresses are created with `InetSocketAddress(String, int)`: the host name will diff --git a/core/src/test/java/com/datastax/oss/driver/api/core/config/ClientRoutesConfigTest.java b/core/src/test/java/com/datastax/oss/driver/api/core/config/ClientRoutesConfigTest.java new file mode 100644 index 00000000000..71bc64666b9 --- /dev/null +++ b/core/src/test/java/com/datastax/oss/driver/api/core/config/ClientRoutesConfigTest.java @@ -0,0 +1,123 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.UUID; +import org.junit.Test; + +public class ClientRoutesConfigTest { + + @Test + public void should_build_config_with_single_endpoint() { + UUID connectionId = UUID.randomUUID(); + String connectionAddr = "my-privatelink.us-east-1.aws.scylladb.com:9042"; + + ClientRoutesConfig config = + ClientRoutesConfig.builder() + .addEndpoint(new ClientRoutesEndpoint(connectionId, connectionAddr)) + .build(); + + assertThat(config.getEndpoints()).hasSize(1); + assertThat(config.getEndpoints().get(0).getConnectionId()).isEqualTo(connectionId); + assertThat(config.getEndpoints().get(0).getConnectionAddr()).isEqualTo(connectionAddr); + assertThat(config.getTableName()).isNull(); + } + + @Test + public void should_build_config_with_multiple_endpoints() { + UUID connectionId1 = UUID.randomUUID(); + UUID connectionId2 = UUID.randomUUID(); + + ClientRoutesConfig config = + ClientRoutesConfig.builder() + .addEndpoint(new ClientRoutesEndpoint(connectionId1, "host1:9042")) + .addEndpoint(new ClientRoutesEndpoint(connectionId2, "host2:9042")) + .build(); + + assertThat(config.getEndpoints()).hasSize(2); + assertThat(config.getEndpoints().get(0).getConnectionId()).isEqualTo(connectionId1); + assertThat(config.getEndpoints().get(1).getConnectionId()).isEqualTo(connectionId2); + } + + @Test + public void should_build_config_with_custom_table_name() { + ClientRoutesConfig config = + ClientRoutesConfig.builder() + .addEndpoint(new ClientRoutesEndpoint(UUID.randomUUID())) + .withTableName("custom.client_routes_test") + .build(); + + assertThat(config.getTableName()).isEqualTo("custom.client_routes_test"); + } + + @Test + public void should_fail_when_no_endpoints_provided() { + assertThatThrownBy(() -> ClientRoutesConfig.builder().build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("At least one endpoint must be specified"); + } + + @Test + public void should_create_endpoint_without_connection_address() { + UUID connectionId = UUID.randomUUID(); + ClientRoutesEndpoint endpoint = new ClientRoutesEndpoint(connectionId); + + assertThat(endpoint.getConnectionId()).isEqualTo(connectionId); + assertThat(endpoint.getConnectionAddr()).isNull(); + } + + @Test + public void should_create_endpoint_with_connection_address() { + UUID connectionId = UUID.randomUUID(); + String connectionAddr = "host:9042"; + ClientRoutesEndpoint endpoint = new ClientRoutesEndpoint(connectionId, connectionAddr); + + assertThat(endpoint.getConnectionId()).isEqualTo(connectionId); + assertThat(endpoint.getConnectionAddr()).isEqualTo(connectionAddr); + } + + @Test + public void should_fail_when_connection_id_is_null() { + assertThatThrownBy(() -> new ClientRoutesEndpoint(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("connectionId must not be null"); + } + + @Test + public void should_replace_endpoints_with_withEndpoints() { + UUID connectionId1 = UUID.randomUUID(); + UUID connectionId2 = UUID.randomUUID(); + UUID connectionId3 = UUID.randomUUID(); + + ClientRoutesConfig config = + ClientRoutesConfig.builder() + .addEndpoint(new ClientRoutesEndpoint(connectionId1)) + .withEndpoints( + java.util.Arrays.asList( + new ClientRoutesEndpoint(connectionId2), + new ClientRoutesEndpoint(connectionId3))) + .build(); + + assertThat(config.getEndpoints()).hasSize(2); + assertThat(config.getEndpoints().get(0).getConnectionId()).isEqualTo(connectionId2); + assertThat(config.getEndpoints().get(1).getConnectionId()).isEqualTo(connectionId3); + } +} diff --git a/core/src/test/java/com/datastax/oss/driver/api/core/session/ClientRoutesSessionBuilderTest.java b/core/src/test/java/com/datastax/oss/driver/api/core/session/ClientRoutesSessionBuilderTest.java new file mode 100644 index 00000000000..70a617e6dce --- /dev/null +++ b/core/src/test/java/com/datastax/oss/driver/api/core/session/ClientRoutesSessionBuilderTest.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.api.core.session; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.config.ClientRoutesConfig; +import com.datastax.oss.driver.api.core.config.ClientRoutesEndpoint; +import java.util.UUID; +import org.junit.Test; + +public class ClientRoutesSessionBuilderTest { + + @Test + public void should_set_client_routes_config_programmatically() { + UUID connectionId = UUID.randomUUID(); + ClientRoutesConfig config = + ClientRoutesConfig.builder() + .addEndpoint(new ClientRoutesEndpoint(connectionId, "host:9042")) + .build(); + + TestSessionBuilder builder = new TestSessionBuilder(); + builder.withClientRoutesConfig(config); + + assertThat(builder.clientRoutesConfig).isEqualTo(config); + assertThat(builder.programmaticArgumentsBuilder.build().getClientRoutesConfig()) + .isEqualTo(config); + } + + @Test + public void should_allow_null_client_routes_config() { + TestSessionBuilder builder = new TestSessionBuilder(); + builder.withClientRoutesConfig(null); + + assertThat(builder.clientRoutesConfig).isNull(); + assertThat(builder.programmaticArgumentsBuilder.build().getClientRoutesConfig()).isNull(); + } + + /** Test subclass to access protected fields. */ + private static class TestSessionBuilder extends SessionBuilder { + @Override + protected CqlSession wrap(CqlSession defaultSession) { + // Return a mock instead of manually implementing all methods + return mock(CqlSession.class); + } + } +} diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/util/AddressParserTest.java b/core/src/test/java/com/datastax/oss/driver/internal/core/util/AddressParserTest.java new file mode 100644 index 00000000000..6a23951636e --- /dev/null +++ b/core/src/test/java/com/datastax/oss/driver/internal/core/util/AddressParserTest.java @@ -0,0 +1,157 @@ +/* + * 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.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.InetSocketAddress; +import java.util.UUID; +import org.junit.Test; + +/** Tests for address parsing logic used in contact points and client routes configuration. */ +public class AddressParserTest { + + private final UUID connectionId = UUID.randomUUID(); + + @Test + public void should_reject_null_address() { + assertThatThrownBy(() -> AddressParser.parseContactPoint(null, connectionId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Failed to parse address null") + .hasMessageContaining("Address must not be null"); + } + + @Test + public void should_reject_empty_address() { + assertThatThrownBy(() -> AddressParser.parseContactPoint("", connectionId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Failed to parse address ''") + .hasMessageContaining("Address must not be empty"); + } + + @Test + public void should_reject_invalid_port_not_a_number() { + assertThatThrownBy(() -> AddressParser.parseContactPoint("host:abc", connectionId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Failed to parse address 'host:abc'") + .hasMessageContaining(connectionId.toString()); + } + + @Test + public void should_reject_port_out_of_range_too_high() { + assertThatThrownBy(() -> AddressParser.parseContactPoint("host:99999", connectionId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid port 99999") + .hasMessageContaining("must be between 1 and 65535"); + } + + @Test + public void should_reject_port_out_of_range_zero() { + assertThatThrownBy(() -> AddressParser.parseContactPoint("host:0", connectionId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid port 0") + .hasMessageContaining("must be between 1 and 65535"); + } + + @Test + public void should_accept_valid_ipv4_with_port() { + InetSocketAddress addr1 = AddressParser.parseContactPoint("host:9042", connectionId); + assertThat(addr1.getHostString()).isEqualTo("host"); + assertThat(addr1.getPort()).isEqualTo(9042); + + InetSocketAddress addr2 = AddressParser.parseContactPoint("192.168.1.1:9042", connectionId); + assertThat(addr2.getHostString()).isEqualTo("192.168.1.1"); + assertThat(addr2.getPort()).isEqualTo(9042); + + InetSocketAddress addr3 = AddressParser.parseContactPoint("host:1", connectionId); + assertThat(addr3.getPort()).isEqualTo(1); + + InetSocketAddress addr4 = AddressParser.parseContactPoint("host:65535", connectionId); + assertThat(addr4.getPort()).isEqualTo(65535); + } + + @Test + public void should_accept_valid_ipv6_with_port() { + InetSocketAddress addr1 = AddressParser.parseContactPoint("[::1]:9042", connectionId); + // Java expands ::1 to its canonical form + assertThat(addr1.getHostString()).matches("(\\[::1]|\\[0:0:0:0:0:0:0:1])"); + assertThat(addr1.getPort()).isEqualTo(9042); + + InetSocketAddress addr2 = AddressParser.parseContactPoint("[2001:db8::1]:9042", connectionId); + assertThat(addr2.getHostString()).contains("2001"); + assertThat(addr2.getHostString()).contains("db8"); + assertThat(addr2.getPort()).isEqualTo(9042); + + InetSocketAddress addr3 = AddressParser.parseContactPoint("[fe80::1]:19042", connectionId); + assertThat(addr3.getHostString()).contains("fe80"); + assertThat(addr3.getPort()).isEqualTo(19042); + } + + @Test + public void should_accept_valid_ipv6_without_port() { + // Should use default port 9042 + InetSocketAddress addr1 = AddressParser.parseContactPoint("[::1]", connectionId); + // Java expands ::1 to its canonical form + assertThat(addr1.getHostString()).matches("(\\[::1]|\\[0:0:0:0:0:0:0:1])"); + assertThat(addr1.getPort()).isEqualTo(9042); + + InetSocketAddress addr2 = AddressParser.parseContactPoint("[2001:db8::1]", connectionId); + assertThat(addr2.getHostString()).contains("2001"); + assertThat(addr2.getHostString()).contains("db8"); + assertThat(addr2.getPort()).isEqualTo(9042); + } + + @Test + public void should_reject_bare_ipv6_without_brackets() { + // URI parser will reject bare IPv6 addresses + assertThatThrownBy(() -> AddressParser.parseContactPoint("::1", connectionId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Failed to parse address '::1'"); + + assertThatThrownBy(() -> AddressParser.parseContactPoint("2001:db8::1", connectionId)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Failed to parse address"); + } + + @Test + public void should_handle_address_without_port() { + // Should use default port 9042 + InetSocketAddress addr1 = AddressParser.parseContactPoint("host", connectionId); + assertThat(addr1.getHostString()).isEqualTo("host"); + assertThat(addr1.getPort()).isEqualTo(9042); + + InetSocketAddress addr2 = + AddressParser.parseContactPoint("my-cluster.scylladb.com", connectionId); + assertThat(addr2.getHostString()).isEqualTo("my-cluster.scylladb.com"); + assertThat(addr2.getPort()).isEqualTo(9042); + + InetSocketAddress addr3 = AddressParser.parseContactPoint("192.168.1.1", connectionId); + assertThat(addr3.getHostString()).isEqualTo("192.168.1.1"); + assertThat(addr3.getPort()).isEqualTo(9042); + } + + @Test + public void should_handle_null_connection_id() { + // When connection ID is null, error messages should still be clear + assertThatThrownBy(() -> AddressParser.parseContactPoint("host:99999", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Failed to parse address 'host:99999'") + .hasMessageContaining("Invalid port 99999"); + } +} diff --git a/manual/core/address_resolution/README.md b/manual/core/address_resolution/README.md index 84efb4a796c..265f220ba79 100644 --- a/manual/core/address_resolution/README.md +++ b/manual/core/address_resolution/README.md @@ -118,6 +118,52 @@ datastax-java-driver.advanced.address-translator.class = com.mycompany.MyAddress Note: the contact points provided while creating the `CqlSession` are not translated, only addresses retrieved from or sent by Cassandra nodes are. +### Client Routes (PrivateLink deployments) + +For cloud deployments using PrivateLink or similar private endpoint technologies (such as ScyllaDB Cloud), nodes are +accessed through private DNS endpoints rather than direct IP addresses. The driver provides a client routes feature +to handle this topology. + +Client routes configuration is done programmatically and is **mutually exclusive** with: +- A custom `AddressTranslator` (if both are provided, client routes takes precedence) +- Cloud secure connect bundles (if both are provided, the cloud bundle takes precedence) + +Example configuration: + +```java +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.config.ClientRoutesConfig; +import com.datastax.oss.driver.api.core.config.ClientRoutesEndpoint; +import java.util.UUID; + +// Configure endpoints with connection IDs and addresses +ClientRoutesConfig config = ClientRoutesConfig.builder() + .addEndpoint(new ClientRoutesEndpoint( + UUID.fromString("12345678-1234-1234-1234-123456789012"), + "my-cluster.us-east-1.aws.scylladb.com:9042")) + .build(); + +// Build session - endpoints are automatically used as seed hosts +CqlSession session = CqlSession.builder() + .withClientRoutesConfig(config) + .withLocalDatacenter("datacenter1") + .build(); +``` + +When client routes are configured: +* The driver will use endpoint addresses as seed hosts if no explicit contact points are provided +* Custom `AddressTranslator` configuration is not allowed (only the default `PassThroughAddressTranslator`) +* Connection IDs map to the `system.client_routes` table entries + +The system table name can be customized in the [configuration](../configuration/) (primarily for testing): + +``` +datastax-java-driver.advanced.client-routes.table-name = "system.client_routes" +``` + +**Note:** As of the current version, the client routes configuration API is available, but the full handler implementation +(DNS resolution, address translation, event handling) is still under development. + ### EC2 multi-region If you deploy both Cassandra and client applications on Amazon EC2, and your cluster spans multiple regions, you'll have From 1dfb6aa3905290af9bd723cd78fe7c791b2ff8ee Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Mon, 23 Feb 2026 20:37:56 +0100 Subject: [PATCH 3/9] feat: add configurable DNS cache duration to ClientRoutesConfig --- .../api/core/config/ClientRoutesConfig.java | 58 +++++++++++++++++-- .../core/config/ClientRoutesConfigTest.java | 44 ++++++++++++++ 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/com/datastax/oss/driver/api/core/config/ClientRoutesConfig.java b/core/src/main/java/com/datastax/oss/driver/api/core/config/ClientRoutesConfig.java index b224b523751..d80798a47b3 100644 --- a/core/src/main/java/com/datastax/oss/driver/api/core/config/ClientRoutesConfig.java +++ b/core/src/main/java/com/datastax/oss/driver/api/core/config/ClientRoutesConfig.java @@ -44,6 +44,7 @@ * .addEndpoint(new ClientRoutesEndpoint( * UUID.fromString("12345678-1234-1234-1234-123456789012"), * "my-privatelink.us-east-1.aws.scylladb.com:9042")) + * .withDnsCacheDuration(1000L) // Cache DNS for 1 second (default: 500ms) * .build(); * * CqlSession session = CqlSession.builder() @@ -57,15 +58,23 @@ @Immutable public class ClientRoutesConfig { + private static final long DEFAULT_DNS_CACHE_DURATION_MILLIS = 500L; + private final List endpoints; private final String tableName; + private final long dnsCacheDurationMillis; - private ClientRoutesConfig(List endpoints, String tableName) { + private ClientRoutesConfig( + List endpoints, String tableName, long dnsCacheDurationMillis) { if (endpoints == null || endpoints.isEmpty()) { throw new IllegalArgumentException("At least one endpoint must be specified"); } + if (dnsCacheDurationMillis < 0) { + throw new IllegalArgumentException("DNS cache duration must be non-negative"); + } this.endpoints = Collections.unmodifiableList(new ArrayList<>(endpoints)); this.tableName = tableName; + this.dnsCacheDurationMillis = dnsCacheDurationMillis; } /** @@ -88,6 +97,19 @@ public String getTableName() { return tableName; } + /** + * Returns 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, while a + * longer duration can reduce DNS lookup overhead in stable environments. + * + * @return the DNS cache duration in milliseconds (default: 500ms). + */ + public long getDnsCacheDurationMillis() { + return dnsCacheDurationMillis; + } + /** * Creates a new builder for constructing a {@link ClientRoutesConfig}. * @@ -107,12 +129,14 @@ public boolean equals(Object o) { return false; } ClientRoutesConfig that = (ClientRoutesConfig) o; - return endpoints.equals(that.endpoints) && Objects.equals(tableName, that.tableName); + return dnsCacheDurationMillis == that.dnsCacheDurationMillis + && endpoints.equals(that.endpoints) + && Objects.equals(tableName, that.tableName); } @Override public int hashCode() { - return Objects.hash(endpoints, tableName); + return Objects.hash(endpoints, tableName, dnsCacheDurationMillis); } @Override @@ -123,6 +147,8 @@ public String toString() { + ", tableName='" + tableName + '\'' + + ", dnsCacheDurationMillis=" + + dnsCacheDurationMillis + '}'; } @@ -130,6 +156,7 @@ public String toString() { public static class Builder { private final List endpoints = new ArrayList<>(); private String tableName; + private long dnsCacheDurationMillis = DEFAULT_DNS_CACHE_DURATION_MILLIS; /** * Adds an endpoint to the configuration. @@ -177,6 +204,29 @@ public Builder withTableName(@Nullable String 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. * @@ -185,7 +235,7 @@ public Builder withTableName(@Nullable String tableName) { */ @NonNull public ClientRoutesConfig build() { - return new ClientRoutesConfig(endpoints, tableName); + return new ClientRoutesConfig(endpoints, tableName, dnsCacheDurationMillis); } } } diff --git a/core/src/test/java/com/datastax/oss/driver/api/core/config/ClientRoutesConfigTest.java b/core/src/test/java/com/datastax/oss/driver/api/core/config/ClientRoutesConfigTest.java index 71bc64666b9..d7e822385ec 100644 --- a/core/src/test/java/com/datastax/oss/driver/api/core/config/ClientRoutesConfigTest.java +++ b/core/src/test/java/com/datastax/oss/driver/api/core/config/ClientRoutesConfigTest.java @@ -120,4 +120,48 @@ public void should_replace_endpoints_with_withEndpoints() { assertThat(config.getEndpoints().get(0).getConnectionId()).isEqualTo(connectionId2); assertThat(config.getEndpoints().get(1).getConnectionId()).isEqualTo(connectionId3); } + + @Test + public void should_use_default_dns_cache_duration() { + ClientRoutesConfig config = + ClientRoutesConfig.builder() + .addEndpoint(new ClientRoutesEndpoint(UUID.randomUUID())) + .build(); + + assertThat(config.getDnsCacheDurationMillis()).isEqualTo(500L); + } + + @Test + public void should_build_config_with_custom_dns_cache_duration() { + ClientRoutesConfig config = + ClientRoutesConfig.builder() + .addEndpoint(new ClientRoutesEndpoint(UUID.randomUUID())) + .withDnsCacheDuration(1000L) + .build(); + + assertThat(config.getDnsCacheDurationMillis()).isEqualTo(1000L); + } + + @Test + public void should_allow_zero_dns_cache_duration() { + ClientRoutesConfig config = + ClientRoutesConfig.builder() + .addEndpoint(new ClientRoutesEndpoint(UUID.randomUUID())) + .withDnsCacheDuration(0L) + .build(); + + assertThat(config.getDnsCacheDurationMillis()).isEqualTo(0L); + } + + @Test + public void should_fail_when_dns_cache_duration_is_negative() { + assertThatThrownBy( + () -> + ClientRoutesConfig.builder() + .addEndpoint(new ClientRoutesEndpoint(UUID.randomUUID())) + .withDnsCacheDuration(-1L) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("DNS cache duration must be non-negative"); + } } From 85dcec51593b8707596244a526eb75d9042099e5 Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Mon, 23 Feb 2026 22:20:24 +0100 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20PrivateLink=20support=20Phase=202?= =?UTF-8?q?=20-=20Core=20infrastructure=20=F0=9F=8F=97=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Protocol layer: CLIENT_ROUTES_CHANGE event registration - API: AddressTranslator V2 with Host ID, datacenter, and rack parameters - Infrastructure: ClientRoutesHandler, ClientRoutesAddressTranslator (stubs) - Integration: DefaultDriverContext, DefaultSession, DefaultTopologyMonitor - Tests: ClientRoutesConfigTest, ClientRoutesSessionBuilderTest Phase 2 establishes API. Phase 3 implements queries and lifecycle. Related: DRIVER-88 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../addresstranslation/AddressTranslator.java | 28 ++++ .../api/core/config/ClientRoutesConfig.java | 5 +- .../api/core/config/DefaultDriverOption.java | 12 -- .../driver/api/core/config/OptionsMap.java | 1 - .../api/core/config/TypedDriverOption.java | 3 - .../api/core/session/SessionBuilder.java | 13 ++ .../core/clientroutes/ClientRouteInfo.java | 122 ++++++++++++++++++ .../ClientRoutesAddressTranslator.java | 98 ++++++++++++++ .../clientroutes/ClientRoutesHandler.java | 122 ++++++++++++++++++ .../clientroutes/ResolvedClientRoute.java | 98 ++++++++++++++ .../core/context/DefaultDriverContext.java | 32 +++++ .../core/context/InternalDriverContext.java | 10 ++ .../core/control/ControlConnection.java | 42 +++++- .../core/metadata/DefaultTopologyMonitor.java | 19 ++- .../internal/core/session/DefaultSession.java | 10 ++ core/src/main/resources/reference.conf | 26 ---- .../core/config/ClientRoutesConfigTest.java | 2 +- .../metadata/DefaultTopologyMonitorTest.java | 30 ++++- .../internal/core/util/AddressParserTest.java | 12 +- manual/core/address_resolution/README.md | 2 +- 20 files changed, 629 insertions(+), 58 deletions(-) create mode 100644 core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/ClientRouteInfo.java create mode 100644 core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/ClientRoutesAddressTranslator.java create mode 100644 core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/ClientRoutesHandler.java create mode 100644 core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/ResolvedClientRoute.java diff --git a/core/src/main/java/com/datastax/oss/driver/api/core/addresstranslation/AddressTranslator.java b/core/src/main/java/com/datastax/oss/driver/api/core/addresstranslation/AddressTranslator.java index 47ce62f1461..dbef705b196 100644 --- a/core/src/main/java/com/datastax/oss/driver/api/core/addresstranslation/AddressTranslator.java +++ b/core/src/main/java/com/datastax/oss/driver/api/core/addresstranslation/AddressTranslator.java @@ -18,7 +18,9 @@ package com.datastax.oss.driver.api.core.addresstranslation; import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; import java.net.InetSocketAddress; +import java.util.UUID; /** * Translates IP addresses received from Cassandra nodes into locally queriable addresses. @@ -46,6 +48,32 @@ public interface AddressTranslator extends AutoCloseable { @NonNull InetSocketAddress translate(@NonNull InetSocketAddress address); + /** + * Translates an address reported by a Cassandra node into the address that the driver will use to + * connect, with additional node metadata for context. + * + *

This method is called during node discovery and allows implementations to use the node's + * host ID, datacenter, and rack to make translation decisions. For example, the client routes + * handler uses the host ID to look up the appropriate endpoint mapping. + * + *

The default implementation delegates to {@link #translate(InetSocketAddress)}, ignoring the + * additional parameters. This ensures backward compatibility with existing implementations. + * + * @param address the broadcast RPC address of the node + * @param hostId the unique identifier of the node (may be null for contact points) + * @param datacenter the datacenter of the node (may be null if not yet known) + * @param rack the rack of the node (may be null if not yet known) + * @return the translated address + */ + @NonNull + default InetSocketAddress translate( + @NonNull InetSocketAddress address, + @Nullable UUID hostId, + @Nullable String datacenter, + @Nullable String rack) { + return translate(address); + } + /** Called when the cluster that this translator is associated with closes. */ @Override void close(); diff --git a/core/src/main/java/com/datastax/oss/driver/api/core/config/ClientRoutesConfig.java b/core/src/main/java/com/datastax/oss/driver/api/core/config/ClientRoutesConfig.java index d80798a47b3..536b2bc1600 100644 --- a/core/src/main/java/com/datastax/oss/driver/api/core/config/ClientRoutesConfig.java +++ b/core/src/main/java/com/datastax/oss/driver/api/core/config/ClientRoutesConfig.java @@ -18,6 +18,7 @@ package com.datastax.oss.driver.api.core.config; import com.datastax.oss.driver.api.core.session.SessionBuilder; +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.util.ArrayList; @@ -58,6 +59,7 @@ @Immutable public class ClientRoutesConfig { + private static final String DEFAULT_TABLE_NAME = "system.client_routes"; private static final long DEFAULT_DNS_CACHE_DURATION_MILLIS = 500L; private final List endpoints; @@ -155,7 +157,7 @@ public String toString() { /** Builder for {@link ClientRoutesConfig}. */ public static class Builder { private final List endpoints = new ArrayList<>(); - private String tableName; + private String tableName = DEFAULT_TABLE_NAME; private long dnsCacheDurationMillis = DEFAULT_DNS_CACHE_DURATION_MILLIS; /** @@ -198,6 +200,7 @@ public Builder withEndpoints(@NonNull List endpoints) { * @param tableName the table name to use. * @return this builder. */ + @VisibleForTesting @NonNull public Builder withTableName(@Nullable String tableName) { this.tableName = tableName; 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 68209b1f240..f3bc916eb17 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,18 +448,6 @@ public enum DefaultDriverOption implements DriverOption { *

Value-type: {@link String} */ ADDRESS_TRANSLATOR_CLASS("advanced.address-translator.class"), - - /** - * The name of the system table to query for client routes information. - * - *

This is used when client routes are configured programmatically via {@link - * com.datastax.oss.driver.api.core.session.SessionBuilder#withClientRoutesConfig}. The default - * value is {@code system.client_routes}. - * - *

Value-type: {@link String} - */ - CLIENT_ROUTES_TABLE_NAME("advanced.client-routes.table-name"), - /** * The native protocol version to use. * 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 3a999146c9b..28559ea8556 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,7 +396,6 @@ protected static void fillWithDriverDefaults(OptionsMap map) { map.put( TypedDriverOption.LOAD_BALANCING_DEFAULT_LWT_REQUEST_ROUTING_METHOD, "PRESERVE_REPLICA_ORDER"); - map.put(TypedDriverOption.CLIENT_ROUTES_TABLE_NAME, "system.client_routes"); } @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 7ea10faf841..818468ee9d5 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,9 +939,6 @@ public String toString() { DefaultDriverOption.LOAD_BALANCING_DEFAULT_LWT_REQUEST_ROUTING_METHOD, GenericType.STRING); - public static final TypedDriverOption CLIENT_ROUTES_TABLE_NAME = - new TypedDriverOption<>(DefaultDriverOption.CLIENT_ROUTES_TABLE_NAME, GenericType.STRING); - private static Iterable> introspectBuiltInValues() { try { ImmutableList.Builder> result = ImmutableList.builder(); 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 e3d7bdbda7e..7d315697957 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 @@ -975,6 +975,19 @@ protected final CompletionStage buildDefaultSessionAsync() { programmaticContactPoints.add(new DefaultEndPoint(socketAddress)); } } + // If no contact points could be derived from client routes, fail fast with a clear + // message + if (programmaticContactPoints.isEmpty()) { + LOG.error( + "Client routes configuration was provided, but no usable endpoints were found: " + + "all endpoints have null connectionAddr and no explicit contact points are configured. " + + "Please specify a connectionAddr for at least one client routes endpoint or configure " + + "contact points explicitly."); + throw new IllegalStateException( + "Invalid client routes configuration: no usable endpoints. " + + "At least one endpoint must define a non-null connectionAddr or explicit contact points " + + "must be configured when using client routes."); + } } } 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..c566e8101dd --- /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 UUID connectionId; + private final UUID hostId; + private final String address; + private final Integer nativeTransportPort; + private final Integer nativeTransportPortSsl; + + public ClientRouteInfo( + @NonNull UUID 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 UUID 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/ClientRoutesAddressTranslator.java b/core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/ClientRoutesAddressTranslator.java new file mode 100644 index 00000000000..a861a186231 --- /dev/null +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/ClientRoutesAddressTranslator.java @@ -0,0 +1,98 @@ +/* + * 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.api.core.addresstranslation.AddressTranslator; +import com.datastax.oss.driver.internal.core.context.InternalDriverContext; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import java.net.InetSocketAddress; +import java.util.UUID; +import net.jcip.annotations.ThreadSafe; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@ThreadSafe +public class ClientRoutesAddressTranslator implements AddressTranslator { + private static final Logger LOG = LoggerFactory.getLogger(ClientRoutesAddressTranslator.class); + private final InternalDriverContext context; + private final String logPrefix; + + public ClientRoutesAddressTranslator(@NonNull InternalDriverContext context) { + this.context = context; + this.logPrefix = context.getSessionName(); + } + + @NonNull + @Override + public InetSocketAddress translate(@NonNull InetSocketAddress address) { + // Contact points bypass translation + return address; + } + + @NonNull + @Override + public InetSocketAddress translate( + @NonNull InetSocketAddress address, + @Nullable UUID hostId, + @Nullable String datacenter, + @Nullable String rack) { + // Contact points (hostId == null) bypass translation + if (hostId == null) { + return address; + } + + // Lazily get the handler from context to avoid initialization order issues + ClientRoutesHandler handler = context.getClientRoutesHandler(); + if (handler == null) { + // This shouldn't happen if ClientRoutesAddressTranslator is only used when configured, + // but handle gracefully just in case + LOG.warn("[{}] ClientRoutesHandler is null, using original address", logPrefix); + return address; + } + + boolean useSsl = context.getSslEngineFactory().isPresent(); + InetSocketAddress translated = handler.translate(hostId, useSsl); + if (translated != null) { + LOG.debug( + "[{}] Translated {}:{} (host_id={}) to {}:{}", + logPrefix, + address.getHostString(), + address.getPort(), + hostId, + translated.getHostString(), + translated.getPort()); + return translated; + } + // Fallback to original address if no route found + LOG.debug( + "[{}] No client route found for host_id={}, using original address {}:{}", + logPrefix, + hostId, + address.getHostString(), + address.getPort()); + return address; + } + + @Override + public void close() { + // Handler lifecycle is managed by DefaultDriverContext, not by this translator. + // Multiple components may reference the handler, so we don't close it here. + // The handler will be closed when the session closes through the context's cleanup. + } +} diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/ClientRoutesHandler.java b/core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/ClientRoutesHandler.java new file mode 100644 index 00000000000..3d287ef1f4e --- /dev/null +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/ClientRoutesHandler.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. + */ +package com.datastax.oss.driver.internal.core.clientroutes; + +import com.datastax.oss.driver.api.core.config.ClientRoutesConfig; +import com.datastax.oss.driver.internal.core.context.InternalDriverContext; +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.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; +import net.jcip.annotations.ThreadSafe; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@ThreadSafe +public class ClientRoutesHandler implements AutoCloseable { + private static final Logger LOG = LoggerFactory.getLogger(ClientRoutesHandler.class); + + @SuppressWarnings("UnusedVariable") // Will be used for querying system.client_routes + private final InternalDriverContext context; + + private final ClientRoutesConfig config; + private final String logPrefix; + private final AtomicReference> resolvedRoutesRef; + private final Map dnsCache; + private final long dnsCacheDurationNanos; + private volatile boolean closed = false; + + public ClientRoutesHandler( + @NonNull InternalDriverContext context, @NonNull ClientRoutesConfig config) { + this.context = context; + this.config = config; + this.logPrefix = context.getSessionName(); + this.resolvedRoutesRef = new AtomicReference<>(new ConcurrentHashMap<>()); + this.dnsCache = new ConcurrentHashMap<>(); + this.dnsCacheDurationNanos = config.getDnsCacheDurationMillis() * 1_000_000L; + } + + public CompletionStage init() { + LOG.debug( + "[{}] Initializing ClientRoutesHandler with {} endpoints", + logPrefix, + config.getEndpoints().size()); + return queryAndResolveRoutes(); + } + + public CompletionStage refresh() { + LOG.debug("[{}] Refreshing client routes", logPrefix); + return queryAndResolveRoutes(); + } + + private CompletionStage queryAndResolveRoutes() { + // TODO: Query system.client_routes table + // For now, return completed future + return CompletableFuture.completedFuture(null); + } + + @Nullable + public InetSocketAddress translate(@NonNull UUID hostId, boolean useSsl) { + if (closed) { + return null; + } + Map routes = resolvedRoutesRef.get(); + ResolvedClientRoute route = routes.get(hostId); + if (route == null) { + LOG.debug("[{}] No client route found for host_id={}", logPrefix, hostId); + return null; + } + return route.toSocketAddress(useSsl); + } + + @SuppressWarnings("UnusedMethod") // Will be used when implementing system.client_routes query + private InetAddress resolveDns(String hostname) throws UnknownHostException { + DnsCacheEntry cached = dnsCache.get(hostname); + long now = System.nanoTime(); + if (cached != null && (now - cached.resolvedAtNanos) < dnsCacheDurationNanos) { + return cached.address; + } + InetAddress resolved = InetAddress.getByName(hostname); + dnsCache.put(hostname, new DnsCacheEntry(resolved, now)); + return resolved; + } + + @Override + public void close() { + closed = true; + dnsCache.clear(); + LOG.debug("[{}] ClientRoutesHandler closed", logPrefix); + } + + private static class DnsCacheEntry { + final InetAddress address; + final long resolvedAtNanos; + + DnsCacheEntry(InetAddress address, long resolvedAtNanos) { + this.address = address; + this.resolvedAtNanos = resolvedAtNanos; + } + } +} 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..995b5d6b322 --- /dev/null +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/ResolvedClientRoute.java @@ -0,0 +1,98 @@ +/* + * 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.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.Objects; +import java.util.UUID; +import net.jcip.annotations.Immutable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Immutable +public class ResolvedClientRoute { + private static final Logger LOG = LoggerFactory.getLogger(ResolvedClientRoute.class); + + private final UUID hostId; + private final InetAddress resolvedAddress; + private final int nativeTransportPort; + private final Integer nativeTransportPortSsl; + private final long resolvedAtNanos; + + public ResolvedClientRoute( + @NonNull UUID hostId, + @NonNull InetAddress resolvedAddress, + int nativeTransportPort, + @Nullable Integer nativeTransportPortSsl, + long resolvedAtNanos) { + this.hostId = Objects.requireNonNull(hostId); + this.resolvedAddress = Objects.requireNonNull(resolvedAddress); + this.nativeTransportPort = nativeTransportPort; + this.nativeTransportPortSsl = nativeTransportPortSsl; + this.resolvedAtNanos = resolvedAtNanos; + } + + @NonNull + public UUID getHostId() { + return hostId; + } + + @NonNull + public InetAddress getResolvedAddress() { + return resolvedAddress; + } + + public int getNativeTransportPort() { + return nativeTransportPort; + } + + @Nullable + public Integer getNativeTransportPortSsl() { + return nativeTransportPortSsl; + } + + public long getResolvedAtNanos() { + return resolvedAtNanos; + } + + @NonNull + public InetSocketAddress toSocketAddress(boolean useSsl) { + int port; + if (useSsl) { + if (nativeTransportPortSsl != null) { + port = nativeTransportPortSsl; + } else { + // SSL requested but not configured for this route - fall back to non-SSL port + LOG.warn( + "SSL requested for host_id={} ({}:{}) but tls_port is not configured in client routes. " + + "Falling back to non-SSL port {}. This may indicate a configuration issue.", + hostId, + resolvedAddress.getHostAddress(), + nativeTransportPort, + nativeTransportPort); + port = nativeTransportPort; + } + } else { + port = nativeTransportPort; + } + return new InetSocketAddress(resolvedAddress, port); + } +} 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..c68cd2df0c7 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,7 @@ 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.DefaultDriverOption; import com.datastax.oss.driver.api.core.config.DriverConfig; import com.datastax.oss.driver.api.core.config.DriverConfigLoader; @@ -55,6 +56,8 @@ 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.clientroutes.ClientRoutesAddressTranslator; +import com.datastax.oss.driver.internal.core.clientroutes.ClientRoutesHandler; import com.datastax.oss.driver.internal.core.control.ControlConnection; import com.datastax.oss.driver.internal.core.metadata.CloudTopologyMonitor; import com.datastax.oss.driver.internal.core.metadata.DefaultTopologyMonitor; @@ -197,6 +200,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 +243,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 +299,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(); @@ -405,6 +416,14 @@ protected ReconnectionPolicy buildReconnectionPolicy() { } protected AddressTranslator buildAddressTranslator() { + // If client routes are configured, use ClientRoutesAddressTranslator + // We check the config directly instead of getting the handler to avoid triggering + // its initialization during context construction (handler may need other context components) + if (clientRoutesConfigFromBuilder != null) { + return new ClientRoutesAddressTranslator(this); + } + + // Otherwise use the configured translator return Reflection.buildFromConfig( this, DefaultDriverOption.ADDRESS_TRANSLATOR_CLASS, @@ -418,6 +437,13 @@ protected AddressTranslator buildAddressTranslator() { DefaultDriverOption.ADDRESS_TRANSLATOR_CLASS))); } + protected ClientRoutesHandler buildClientRoutesHandler( + ClientRoutesConfig clientRoutesConfigFromBuilder) { + return (clientRoutesConfigFromBuilder != null) + ? new ClientRoutesHandler(this, clientRoutesConfigFromBuilder) + : null; + } + protected Optional buildSslEngineFactory(SslEngineFactory factoryFromBuilder) { return (factoryFromBuilder != null) ? Optional.of(factoryFromBuilder) @@ -905,6 +931,12 @@ public ControlConnection getControlConnection() { return controlConnectionRef.get(); } + @Nullable + @Override + public ClientRoutesHandler 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..f26709f58e7 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 @@ -25,6 +25,7 @@ import com.datastax.oss.driver.internal.core.ProtocolVersionRegistry; 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.clientroutes.ClientRoutesHandler; import com.datastax.oss.driver.internal.core.control.ControlConnection; import com.datastax.oss.driver.internal.core.metadata.LoadBalancingPolicyWrapper; import com.datastax.oss.driver.internal.core.metadata.MetadataManager; @@ -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 + ClientRoutesHandler 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..f2cb757cfe7 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 @@ -30,6 +30,7 @@ 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.clientroutes.ClientRoutesHandler; import com.datastax.oss.driver.internal.core.context.InternalDriverContext; import com.datastax.oss.driver.internal.core.metadata.DefaultTopologyMonitor; import com.datastax.oss.driver.internal.core.metadata.DistanceEvent; @@ -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,34 @@ private void processSchemaChange(Event event) { }); } + private void processClientRoutesChange(Event event) { + LOG.debug("[{}] Received CLIENT_ROUTES_CHANGE event: {}", logPrefix, event); + + ClientRoutesHandler handler = context.getClientRoutesHandler(); + if (handler != null) { + // Trigger async refresh of client routes + handler + .refresh() + .whenComplete( + (v, error) -> { + if (error != null) { + LOG.warn( + "[{}] Failed to refresh client routes after CLIENT_ROUTES_CHANGE event", + logPrefix, + error); + } else { + LOG.debug("[{}] Successfully refreshed client routes", logPrefix); + } + }); + } else { + // Debug level since registration is conditional - null handler during shutdown is normal + LOG.debug( + "[{}] Received CLIENT_ROUTES_CHANGE event but client routes handler is not available " + + "(likely during shutdown)", + logPrefix); + } + } + private class SingleThreaded { private final InternalDriverContext context; private final DriverConfig config; @@ -292,7 +324,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() @@ -609,7 +643,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 +652,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/metadata/DefaultTopologyMonitor.java b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/DefaultTopologyMonitor.java index 4236b8349dd..47d649f80e3 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 @@ -429,7 +429,24 @@ protected EndPoint buildNodeEndPoint( broadcastRpcAddress, "broadcastRpcAddress cannot be null for a peer row"); // Deployments that use a custom EndPoint implementation will need their own TopologyMonitor. // One simple approach is to extend this class and override this method. - return new DefaultEndPoint(context.getAddressTranslator().translate(broadcastRpcAddress)); + + // Pass node metadata to translator for client routes support + UUID hostId = row.getUuid("host_id"); + String datacenter = row.getString("data_center"); + String rack = row.getString("rack"); + + // Warn if host_id is missing and client routes are configured, as client routes require it + if (hostId == null && context.getClientRoutesHandler() != null) { + LOG.warn( + "[{}] host_id is null for peer {} but client routes are configured. " + + "This may indicate corrupted system tables or a configuration issue.", + logPrefix, + broadcastRpcAddress); + } + + InetSocketAddress translatedAddress = + context.getAddressTranslator().translate(broadcastRpcAddress, hostId, datacenter, rack); + return new DefaultEndPoint(translatedAddress); } else { // 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 diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/session/DefaultSession.java b/core/src/main/java/com/datastax/oss/driver/internal/core/session/DefaultSession.java index c9fee86f2c1..b129b85625c 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/session/DefaultSession.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/session/DefaultSession.java @@ -39,6 +39,7 @@ import com.datastax.oss.driver.api.core.session.Request; import com.datastax.oss.driver.api.core.type.reflect.GenericType; import com.datastax.oss.driver.internal.core.channel.DriverChannel; +import com.datastax.oss.driver.internal.core.clientroutes.ClientRoutesHandler; import com.datastax.oss.driver.internal.core.context.InternalDriverContext; import com.datastax.oss.driver.internal.core.context.LifecycleListener; import com.datastax.oss.driver.internal.core.metadata.DefaultNode; @@ -678,6 +679,15 @@ private void closePolicies() { // Assume the policy had failed to initialize, and we don't need to close it => ignore } } + // Add ClientRoutesHandler if configured + try { + ClientRoutesHandler handler = context.getClientRoutesHandler(); + if (handler != null) { + policies.add(handler); + } + } catch (Throwable t) { + // ignore + } try { context.getAuthProvider().ifPresent(policies::add); } catch (Throwable t) { diff --git a/core/src/main/resources/reference.conf b/core/src/main/resources/reference.conf index 7a83352fcdc..9b7f7ffb1e0 100644 --- a/core/src/main/resources/reference.conf +++ b/core/src/main/resources/reference.conf @@ -1109,32 +1109,6 @@ datastax-java-driver { # This property has to be set only in case you use FixedHostNameAddressTranslator. # advertised-hostname = mycustomhostname } - - # Client routes configuration for PrivateLink-style deployments. - # - # Client routes enable the driver to discover and connect to nodes through a load balancer - # (such as AWS PrivateLink) by reading endpoint mappings from the system.client_routes table. - # Each endpoint is identified by a connection ID and maps to specific node addresses. - # - # Note: Client routes endpoints are configured programmatically via - # SessionBuilder.withClientRoutesConfig(). This configuration section only provides the - # system table name option. - # - # Client routes are mutually exclusive with: - # - A custom AddressTranslator: If both are configured, client routes take precedence - # and the AddressTranslator is effectively ignored (a warning is logged). - # - Cloud secure connect bundles: If both are configured, the cloud bundle takes precedence - # and client routes are ignored (a warning is logged). - # - # Required: no (programmatic configuration only) - # Modifiable at runtime: no - # Overridable in a profile: no - advanced.client-routes { - # The name of the system table to query for client routes information. - # This is typically only changed for testing purposes. - table-name = "system.client_routes" - } - # Whether to resolve the addresses passed to `basic.contact-points`. # # If this is true, addresses are created with `InetSocketAddress(String, int)`: the host name will diff --git a/core/src/test/java/com/datastax/oss/driver/api/core/config/ClientRoutesConfigTest.java b/core/src/test/java/com/datastax/oss/driver/api/core/config/ClientRoutesConfigTest.java index d7e822385ec..7e45a50f334 100644 --- a/core/src/test/java/com/datastax/oss/driver/api/core/config/ClientRoutesConfigTest.java +++ b/core/src/test/java/com/datastax/oss/driver/api/core/config/ClientRoutesConfigTest.java @@ -38,7 +38,7 @@ public void should_build_config_with_single_endpoint() { assertThat(config.getEndpoints()).hasSize(1); assertThat(config.getEndpoints().get(0).getConnectionId()).isEqualTo(connectionId); assertThat(config.getEndpoints().get(0).getConnectionAddr()).isEqualTo(connectionAddr); - assertThat(config.getTableName()).isNull(); + assertThat(config.getTableName()).isEqualTo("system.client_routes"); } @Test diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/metadata/DefaultTopologyMonitorTest.java b/core/src/test/java/com/datastax/oss/driver/internal/core/metadata/DefaultTopologyMonitorTest.java index 354bae82068..22fe64db158 100644 --- a/core/src/test/java/com/datastax/oss/driver/internal/core/metadata/DefaultTopologyMonitorTest.java +++ b/core/src/test/java/com/datastax/oss/driver/internal/core/metadata/DefaultTopologyMonitorTest.java @@ -232,11 +232,16 @@ public void should_refresh_node_from_peers_if_broadcast_address_is_not_present() }); // The rpc_address in each row should have been tried, only the last row should have been // converted + // Note: getUuid("host_id") is called once in findInPeers for comparison verify(peer3).getUuid("host_id"); verify(peer3, never()).getString(anyString()); - verify(peer2, times(2)).getUuid("host_id"); - verify(peer2).getString("data_center"); + // Note: getUuid("host_id") called twice (once in findInPeers, once in buildNodeEndPoint) + // getString("data_center") called twice (once in nodeInfoBuilder, once in buildNodeEndPoint) + // getString("rack") called twice (once in nodeInfoBuilder, once in buildNodeEndPoint) + verify(peer2, times(3)).getUuid("host_id"); + verify(peer2, times(2)).getString("data_center"); + verify(peer2, times(2)).getString("rack"); } @Test @@ -265,8 +270,13 @@ public void should_refresh_node_from_peers_if_broadcast_address_is_not_present_V verify(peer3).getUuid("host_id"); verify(peer3, never()).getString(anyString()); - verify(peer2, times(2)).getUuid("host_id"); - verify(peer2).getString("data_center"); + // Note: getUuid("host_id") called three times total (once in findInPeers, once in + // nodeInfoBuilder, once in buildNodeEndPoint) + // getString("data_center") called twice (once in nodeInfoBuilder, once in buildNodeEndPoint) + // getString("rack") called twice (once in nodeInfoBuilder, once in buildNodeEndPoint) + verify(peer2, times(3)).getUuid("host_id"); + verify(peer2, times(2)).getString("data_center"); + verify(peer2, times(2)).getString("rack"); } @Test @@ -298,8 +308,12 @@ public void should_get_new_node_from_peers() { verify(peer2).getInetAddress("rpc_address"); verify(peer2, never()).getString(anyString()); + // Note: getString calls increased due to buildNodeEndPoint calling getString("data_center"), + // getString("rack") + // in addition to nodeInfoBuilder calling them verify(peer1).getInetAddress("rpc_address"); - verify(peer1).getString("data_center"); + verify(peer1, times(2)).getString("data_center"); + verify(peer1, times(2)).getString("rack"); } @Test @@ -331,8 +345,12 @@ public void should_get_new_node_from_peers_v2() { verify(peer2).getInetAddress("native_address"); verify(peer2, never()).getString(anyString()); + // Note: getString calls increased due to buildNodeEndPoint calling getString("data_center"), + // getString("rack") + // in addition to nodeInfoBuilder calling them verify(peer1).getInetAddress("native_address"); - verify(peer1).getString("data_center"); + verify(peer1, times(2)).getString("data_center"); + verify(peer1, times(2)).getString("rack"); } @Test diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/util/AddressParserTest.java b/core/src/test/java/com/datastax/oss/driver/internal/core/util/AddressParserTest.java index 6a23951636e..c6ce32eb8cd 100644 --- a/core/src/test/java/com/datastax/oss/driver/internal/core/util/AddressParserTest.java +++ b/core/src/test/java/com/datastax/oss/driver/internal/core/util/AddressParserTest.java @@ -89,8 +89,10 @@ public void should_accept_valid_ipv4_with_port() { @Test public void should_accept_valid_ipv6_with_port() { InetSocketAddress addr1 = AddressParser.parseContactPoint("[::1]:9042", connectionId); - // Java expands ::1 to its canonical form - assertThat(addr1.getHostString()).matches("(\\[::1]|\\[0:0:0:0:0:0:0:1])"); + // URI.getHost() preserves brackets for IPv6, so getHostString() returns [::1] + // Java may expand ::1 to its canonical form 0:0:0:0:0:0:0:1 + // Pattern matches optional brackets around the IPv6 address + assertThat(addr1.getHostString()).matches("\\[?(::1|0:0:0:0:0:0:0:1)]?"); assertThat(addr1.getPort()).isEqualTo(9042); InetSocketAddress addr2 = AddressParser.parseContactPoint("[2001:db8::1]:9042", connectionId); @@ -107,8 +109,10 @@ public void should_accept_valid_ipv6_with_port() { public void should_accept_valid_ipv6_without_port() { // Should use default port 9042 InetSocketAddress addr1 = AddressParser.parseContactPoint("[::1]", connectionId); - // Java expands ::1 to its canonical form - assertThat(addr1.getHostString()).matches("(\\[::1]|\\[0:0:0:0:0:0:0:1])"); + // Java URI.getHost() preserves brackets for IPv6, so getHostString() returns [::1] + // Java may expand ::1 to its canonical form 0:0:0:0:0:0:0:1 + // Pattern matches optional brackets around the IPv6 address + assertThat(addr1.getHostString()).matches("\\[?(::1|0:0:0:0:0:0:0:1)]?"); assertThat(addr1.getPort()).isEqualTo(9042); InetSocketAddress addr2 = AddressParser.parseContactPoint("[2001:db8::1]", connectionId); diff --git a/manual/core/address_resolution/README.md b/manual/core/address_resolution/README.md index 265f220ba79..a7d12263e9e 100644 --- a/manual/core/address_resolution/README.md +++ b/manual/core/address_resolution/README.md @@ -152,7 +152,7 @@ CqlSession session = CqlSession.builder() When client routes are configured: * The driver will use endpoint addresses as seed hosts if no explicit contact points are provided -* Custom `AddressTranslator` configuration is not allowed (only the default `PassThroughAddressTranslator`) +* Custom `AddressTranslator` configuration will be overridden by the client routes handler (a warning is logged); the default `PassThroughAddressTranslator` is used internally * Connection IDs map to the `system.client_routes` table entries The system table name can be customized in the [configuration](../configuration/) (primarily for testing): From f0e53bae7ec13e74aa31b641159a2217820da20a Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Tue, 24 Feb 2026 14:36:52 +0100 Subject: [PATCH 5/9] feat: implement DNS resolver with caching support for client routes --- .../clientroutes/ClientRoutesHandler.java | 71 ++++++++++------ .../core/clientroutes/DnsResolver.java | 61 ++++++++++++++ .../clientroutes/ResolvedClientRoute.java | 80 ++++++++++++++----- 3 files changed, 166 insertions(+), 46 deletions(-) create mode 100644 core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/DnsResolver.java diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/ClientRoutesHandler.java b/core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/ClientRoutesHandler.java index 3d287ef1f4e..843f4467164 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/ClientRoutesHandler.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/ClientRoutesHandler.java @@ -44,8 +44,7 @@ public class ClientRoutesHandler implements AutoCloseable { private final ClientRoutesConfig config; private final String logPrefix; private final AtomicReference> resolvedRoutesRef; - private final Map dnsCache; - private final long dnsCacheDurationNanos; + private final DnsResolver dnsResolver; private volatile boolean closed = false; public ClientRoutesHandler( @@ -54,8 +53,34 @@ public ClientRoutesHandler( this.config = config; this.logPrefix = context.getSessionName(); this.resolvedRoutesRef = new AtomicReference<>(new ConcurrentHashMap<>()); - this.dnsCache = new ConcurrentHashMap<>(); - this.dnsCacheDurationNanos = config.getDnsCacheDurationMillis() * 1_000_000L; + // TODO Phase 3: Implement CachingDnsResolver with configurable cache duration + // For now, create a placeholder that will be implemented in Phase 2 + this.dnsResolver = createDnsResolver(config.getDnsCacheDurationMillis()); + } + + /** + * Creates a DNS resolver with the specified cache duration. + * + *

Phase 3 TODO: Implement CachingDnsResolver with: - Configurable cache TTL + * (dnsCacheDurationMillis) - Concurrency limit (default: 1 per hostname) - Fallback to last known + * good IP on resolution failure - Cache eviction based on TTL + */ + @SuppressWarnings( + "UnusedVariable") // dnsCacheDurationMillis will be used in the Phase 3 implementation + private DnsResolver createDnsResolver(long dnsCacheDurationMillis) { + // Placeholder for Phase 3 implementation + return new DnsResolver() { + @NonNull + @Override + public InetAddress resolve(@NonNull String hostname) throws UnknownHostException { + return InetAddress.getByName(hostname); + } + + @Override + public void clearCache() { + // No-op for now + } + }; } public CompletionStage init() { @@ -88,35 +113,29 @@ public InetSocketAddress translate(@NonNull UUID hostId, boolean useSsl) { LOG.debug("[{}] No client route found for host_id={}", logPrefix, hostId); return null; } - return route.toSocketAddress(useSsl); - } - @SuppressWarnings("UnusedMethod") // Will be used when implementing system.client_routes query - private InetAddress resolveDns(String hostname) throws UnknownHostException { - DnsCacheEntry cached = dnsCache.get(hostname); - long now = System.nanoTime(); - if (cached != null && (now - cached.resolvedAtNanos) < dnsCacheDurationNanos) { - return cached.address; + try { + // DNS resolution happens here through the cached resolver + return route.toSocketAddress(useSsl, dnsResolver); + } catch (UnknownHostException e) { + LOG.warn( + "[{}] Failed to resolve hostname {} for host_id={}", + logPrefix, + route.getHostname(), + hostId, + e); + return null; + } catch (IllegalStateException e) { + LOG.warn( + "[{}] Invalid route configuration for host_id={}: {}", logPrefix, hostId, e.getMessage()); + return null; } - InetAddress resolved = InetAddress.getByName(hostname); - dnsCache.put(hostname, new DnsCacheEntry(resolved, now)); - return resolved; } @Override public void close() { closed = true; - dnsCache.clear(); + dnsResolver.clearCache(); LOG.debug("[{}] ClientRoutesHandler closed", logPrefix); } - - private static class DnsCacheEntry { - final InetAddress address; - final long resolvedAtNanos; - - DnsCacheEntry(InetAddress address, long resolvedAtNanos) { - this.address = address; - this.resolvedAtNanos = resolvedAtNanos; - } - } } 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..d098528d710 --- /dev/null +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/DnsResolver.java @@ -0,0 +1,61 @@ +/* + * 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; + + /** + * Clears all cached DNS entries. + * + *

This is primarily useful for testing or when forcing a fresh DNS lookup is required. + */ + 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 index 995b5d6b322..c258badb188 100644 --- 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 @@ -32,22 +32,19 @@ public class ResolvedClientRoute { private static final Logger LOG = LoggerFactory.getLogger(ResolvedClientRoute.class); private final UUID hostId; - private final InetAddress resolvedAddress; - private final int nativeTransportPort; + private final String hostname; + private final Integer nativeTransportPort; private final Integer nativeTransportPortSsl; - private final long resolvedAtNanos; public ResolvedClientRoute( @NonNull UUID hostId, - @NonNull InetAddress resolvedAddress, - int nativeTransportPort, - @Nullable Integer nativeTransportPortSsl, - long resolvedAtNanos) { - this.hostId = Objects.requireNonNull(hostId); - this.resolvedAddress = Objects.requireNonNull(resolvedAddress); + @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; - this.resolvedAtNanos = resolvedAtNanos; } @NonNull @@ -56,11 +53,12 @@ public UUID getHostId() { } @NonNull - public InetAddress getResolvedAddress() { - return resolvedAddress; + public String getHostname() { + return hostname; } - public int getNativeTransportPort() { + @Nullable + public Integer getNativeTransportPort() { return nativeTransportPort; } @@ -69,13 +67,27 @@ public Integer getNativeTransportPortSsl() { return nativeTransportPortSsl; } - public long getResolvedAtNanos() { - return resolvedAtNanos; - } - + /** + * Converts this route to an InetSocketAddress, resolving DNS through the provided resolver. + * + *

The DNS resolver handles caching, so this method can be called on every connection attempt + * without causing a DNS storm. DNS resolution happens at connection time, not at route discovery + * time, which ensures the driver uses fresh DNS entries even when system.client_routes updates + * happen between metadata refreshes. + * + * @param useSsl whether to use the SSL port + * @param dnsResolver the DNS resolver to use for hostname resolution + * @return an InetSocketAddress with the resolved IP and selected port + * @throws IllegalStateException if no port is configured for this route + * @throws java.net.UnknownHostException if the hostname cannot be resolved + */ @NonNull - public InetSocketAddress toSocketAddress(boolean useSsl) { - int port; + public InetSocketAddress toSocketAddress(boolean useSsl, @NonNull DnsResolver dnsResolver) + throws java.net.UnknownHostException { + Objects.requireNonNull(dnsResolver, "dnsResolver must not be null"); + + // Select port based on SSL configuration + Integer port; if (useSsl) { if (nativeTransportPortSsl != null) { port = nativeTransportPortSsl; @@ -85,7 +97,7 @@ public InetSocketAddress toSocketAddress(boolean useSsl) { "SSL requested for host_id={} ({}:{}) but tls_port is not configured in client routes. " + "Falling back to non-SSL port {}. This may indicate a configuration issue.", hostId, - resolvedAddress.getHostAddress(), + hostname, nativeTransportPort, nativeTransportPort); port = nativeTransportPort; @@ -93,6 +105,34 @@ public InetSocketAddress toSocketAddress(boolean useSsl) { } else { port = nativeTransportPort; } + + // Validate port is configured + if (port == null) { + throw new IllegalStateException( + String.format( + "No port configured for host_id=%s, hostname=%s. " + + "The system.client_routes table may be incomplete.", + hostId, hostname)); + } + + // Resolve DNS at connection time (resolver handles caching) + InetAddress resolvedAddress = dnsResolver.resolve(hostname); + return new InetSocketAddress(resolvedAddress, port); } + + @Override + public String toString() { + return "ResolvedClientRoute{" + + "hostId=" + + hostId + + ", hostname='" + + hostname + + '\'' + + ", nativeTransportPort=" + + nativeTransportPort + + ", nativeTransportPortSsl=" + + nativeTransportPortSsl + + '}'; + } } From 26aebdd5e0dd0c9791b2c0901a76abeaee9106ad Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Thu, 26 Feb 2026 18:26:22 +0100 Subject: [PATCH 6/9] feat: PrivateLink support Phase 3 - Query implementation, DNS resolver, and unit tests - Implement CachingDnsResolver with configurable TTL (default 500ms), per-hostname Semaphore(1) concurrency limit, double-checked locking fast-path, and last-known-good fallback on UnknownHostException - Implement queryAndResolveRoutes() in ClientRoutesHandler: queries system.client_routes filtered by configured connection IDs, parses AdminResult rows into ResolvedClientRoute map, atomically swaps via AtomicReference - Wire CachingDnsResolver into ClientRoutesHandler (replaces no-op stub) - Integrate handler.init() into DefaultSession startup chain, after topology monitor init and before refreshNodes(); non-fatal on failure - Trigger handler.refresh() on control connection reconnect before refreshNodes() to prevent stale routes after reconnection - Add 22 unit tests across three suites: CachingDnsResolverTest (7), ClientRoutesHandlerTest (8), ClientRoutesAddressTranslatorTest (7) Related to DRIVER-86, DRIVER-88 --- .../api/core/session/SessionBuilder.java | 10 +- .../core/channel/ProtocolInitHandler.java | 38 +++- .../core/clientroutes/CachingDnsResolver.java | 144 +++++++++++++++ .../clientroutes/ClientRoutesHandler.java | 138 ++++++++++---- .../core/control/ControlConnection.java | 22 ++- .../internal/core/session/DefaultSession.java | 20 +++ .../clientroutes/CachingDnsResolverTest.java | 168 ++++++++++++++++++ .../ClientRoutesAddressTranslatorTest.java | 131 ++++++++++++++ .../clientroutes/ClientRoutesHandlerTest.java | 164 +++++++++++++++++ 9 files changed, 788 insertions(+), 47 deletions(-) create mode 100644 core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/CachingDnsResolver.java create mode 100644 core/src/test/java/com/datastax/oss/driver/internal/core/clientroutes/CachingDnsResolverTest.java create mode 100644 core/src/test/java/com/datastax/oss/driver/internal/core/clientroutes/ClientRoutesAddressTranslatorTest.java create mode 100644 core/src/test/java/com/datastax/oss/driver/internal/core/clientroutes/ClientRoutesHandlerTest.java 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 7d315697957..31251d08437 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 @@ -746,11 +746,9 @@ public SelfT withCloudProxyAddress(@Nullable InetSocketAddress cloudProxyAddress * (such as AWS PrivateLink) 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 + *

This configuration is mutually exclusive with a user-provided {@link * com.datastax.oss.driver.api.core.addresstranslation.AddressTranslator}. If both are specified, - * a warning will be logged and the client routes configuration will take precedence. If you need - * custom address translation behavior with client routes, consider implementing that logic within - * your client routes endpoint mapping instead. + * an {@link IllegalStateException} is thrown when the session is built. * *

Example usage: * @@ -766,8 +764,8 @@ public SelfT withCloudProxyAddress(@Nullable InetSocketAddress cloudProxyAddress * .build(); * } * - * @param clientRoutesConfig the client routes configuration to use, or null to disable client - * routes. + * @param clientRoutesConfig the client routes configuration to use, or {@code null} to disable + * client routes. * @see ClientRoutesConfig */ @NonNull 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..c1baad2ca8d 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,33 @@ 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 older than 2025.4 (OSS) / 2025.4 (Enterprise), + // which do not implement the client_routes feature at the protocol level. The driver + // registers for this event only when ClientRoutesConfig is set, so this branch fires + // on any pre-2025.4 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 ≥ 2025.4);" + + " 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..f33aad1cb88 --- /dev/null +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/CachingDnsResolver.java @@ -0,0 +1,144 @@ +/* + * 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.net.InetAddress; +import java.net.UnknownHostException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Semaphore; +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; + 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; + } + + Semaphore semaphore = semaphores.computeIfAbsent(hostname, h -> new Semaphore(1)); + 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(); + 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(); + } + } else { + try { + // Double-checked locking: a concurrent thread may have resolved while we were acquiring. + entry = cachedEntry(hostname); + if (entry != null) { + return entry.address; + } + return doResolve(hostname); + } finally { + semaphore.release(); + } + } + } + + /** 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 clearCache() { + cache.clear(); + semaphores.clear(); + // lastKnownGood is retained for fallback + } + + 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; + } +} diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/ClientRoutesHandler.java b/core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/ClientRoutesHandler.java index 843f4467164..f344291f76e 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/ClientRoutesHandler.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/ClientRoutesHandler.java @@ -18,18 +18,25 @@ package com.datastax.oss.driver.internal.core.clientroutes; 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.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.context.InternalDriverContext; 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.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; import net.jcip.annotations.ThreadSafe; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -38,12 +45,15 @@ public class ClientRoutesHandler implements AutoCloseable { private static final Logger LOG = LoggerFactory.getLogger(ClientRoutesHandler.class); - @SuppressWarnings("UnusedVariable") // Will be used for querying system.client_routes - private final InternalDriverContext context; + private static final String SELECT_ROUTES_TEMPLATE = + "SELECT connection_id, host_id, address, port, tls_port FROM %s" + + " WHERE connection_id IN (%s) ALLOW FILTERING"; + + final InternalDriverContext context; private final ClientRoutesConfig config; private final String logPrefix; - private final AtomicReference> resolvedRoutesRef; + final AtomicReference> resolvedRoutesRef; private final DnsResolver dnsResolver; private volatile boolean closed = false; @@ -53,34 +63,12 @@ public ClientRoutesHandler( this.config = config; this.logPrefix = context.getSessionName(); this.resolvedRoutesRef = new AtomicReference<>(new ConcurrentHashMap<>()); - // TODO Phase 3: Implement CachingDnsResolver with configurable cache duration - // For now, create a placeholder that will be implemented in Phase 2 this.dnsResolver = createDnsResolver(config.getDnsCacheDurationMillis()); } - /** - * Creates a DNS resolver with the specified cache duration. - * - *

Phase 3 TODO: Implement CachingDnsResolver with: - Configurable cache TTL - * (dnsCacheDurationMillis) - Concurrency limit (default: 1 per hostname) - Fallback to last known - * good IP on resolution failure - Cache eviction based on TTL - */ - @SuppressWarnings( - "UnusedVariable") // dnsCacheDurationMillis will be used in the Phase 3 implementation + /** Creates a DNS resolver with the specified cache duration. */ private DnsResolver createDnsResolver(long dnsCacheDurationMillis) { - // Placeholder for Phase 3 implementation - return new DnsResolver() { - @NonNull - @Override - public InetAddress resolve(@NonNull String hostname) throws UnknownHostException { - return InetAddress.getByName(hostname); - } - - @Override - public void clearCache() { - // No-op for now - } - }; + return new CachingDnsResolver(dnsCacheDurationMillis); } public CompletionStage init() { @@ -88,18 +76,100 @@ public CompletionStage init() { "[{}] Initializing ClientRoutesHandler with {} endpoints", logPrefix, config.getEndpoints().size()); - return queryAndResolveRoutes(); + // Propagate failures so callers can detect unsupported servers or configuration problems. + return queryAndResolveRoutes(/* propagateErrors= */ true); } public CompletionStage refresh() { LOG.debug("[{}] Refreshing client routes", logPrefix); - return queryAndResolveRoutes(); + // Refresh failures are non-fatal: log a warning and keep the previous route map. + return queryAndResolveRoutes(/* propagateErrors= */ false); } - private CompletionStage queryAndResolveRoutes() { - // TODO: Query system.client_routes table - // For now, return completed future - return CompletableFuture.completedFuture(null); + /** + * Queries the configured client-routes table and updates the in-memory route map. + * + * @param propagateErrors {@code true} to let query errors propagate to the caller (used during + * {@link #init()} so session startup can detect missing tables); {@code false} to catch all + * errors and log a warning (used during {@link #refresh()} where continuity matters more). + */ + private CompletionStage queryAndResolveRoutes(boolean propagateErrors) { + DriverChannel channel = context.getControlConnection().channel(); + if (channel == null) { + LOG.warn("[{}] Control connection channel is null, cannot query client routes", logPrefix); + return CompletableFuture.completedFuture(null); + } + + List endpoints = config.getEndpoints(); + if (endpoints.isEmpty()) { + LOG.warn("[{}] No endpoints configured for client routes", logPrefix); + return CompletableFuture.completedFuture(null); + } + + // Build the IN clause with literal UUID values — AdminRequestHandler does not support + // List as a named parameter, so we inline the values directly. + String connectionIdsCsv = + endpoints.stream() + .map(ep -> ep.getConnectionId().toString()) + .collect(Collectors.joining(", ")); + String query = String.format(SELECT_ROUTES_TEMPLATE, config.getTableName(), connectionIdsCsv); + + Duration timeout = + context + .getConfig() + .getDefaultProfile() + .getDuration(DefaultDriverOption.CONTROL_CONNECTION_TIMEOUT); + + try { + CompletionStage future = + AdminRequestHandler.query(channel, query, timeout, Integer.MAX_VALUE, logPrefix) + .start() + .thenAccept( + adminResult -> { + Map newRoutes = new ConcurrentHashMap<>(); + for (AdminRow row : adminResult) { + if (row.isNull("host_id") || row.isNull("address") || row.isNull("port")) { + LOG.warn("[{}] Skipping incomplete client_routes row: {}", logPrefix, row); + continue; + } + UUID hostId = row.getUuid("host_id"); + String address = row.getString("address"); + Integer port = row.getInteger("port"); + Integer tlsPort = + row.contains("tls_port") && !row.isNull("tls_port") + ? row.getInteger("tls_port") + : null; + //noinspection DataFlowIssue + newRoutes.put( + hostId, new ResolvedClientRoute(hostId, address, port, tlsPort)); + } + resolvedRoutesRef.set(newRoutes); + LOG.debug( + "[{}] Updated client routes: {} routes loaded", + logPrefix, + newRoutes.size()); + }); + + if (propagateErrors) { + // Let failures propagate so that init() callers (e.g. session startup) can detect + // missing tables or configuration problems rather than silently succeeding. + return future; + } else { + return future.exceptionally( + e -> { + LOG.warn("[{}] Failed to query client routes: {}", logPrefix, e.getMessage(), e); + return null; + }); + } + } catch (Exception e) { + LOG.warn("[{}] Exception while querying client routes: {}", logPrefix, e.getMessage(), e); + if (propagateErrors) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(e); + return failed; + } + return CompletableFuture.completedFuture(null); + } } @Nullable 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 f2cb757cfe7..afb2457a893 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 @@ -501,9 +501,23 @@ private void onSuccessfulReconnect() { // Otherwise, perform a full refresh (we don't know how long we were disconnected) if (!isFirstConnection) { - context - .getMetadataManager() - .refreshNodes() + ClientRoutesHandler clientRoutesHandler = context.getClientRoutesHandler(); + CompletionStage clientRoutesRefresh = + (clientRoutesHandler != null) + ? clientRoutesHandler + .refresh() + .exceptionally( + e -> { + LOG.warn( + "[{}] Failed to refresh client routes after reconnection", + logPrefix, + e); + return null; + }) + : CompletableFuture.completedFuture(null); + + clientRoutesRefresh + .thenCompose(ignored -> context.getMetadataManager().refreshNodes()) .whenComplete( (result, error) -> { if (error != null) { @@ -629,7 +643,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) { diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/session/DefaultSession.java b/core/src/main/java/com/datastax/oss/driver/internal/core/session/DefaultSession.java index b129b85625c..1ebcf943378 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/session/DefaultSession.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/session/DefaultSession.java @@ -410,6 +410,7 @@ private void init(CqlIdentifier keyspace) { context .getTopologyMonitor() .init() + .thenCompose(v -> initClientRoutes()) .thenCompose(v -> metadataManager.refreshNodes()) .thenCompose(v -> checkProtocolVersion()) .thenCompose(v -> initialSchemaRefresh()) @@ -434,6 +435,25 @@ private void init(CqlIdentifier keyspace) { }); } + private CompletionStage initClientRoutes() { + ClientRoutesHandler handler = context.getClientRoutesHandler(); + if (handler == null) { + return CompletableFuture.completedFuture(null); + } + return handler + .init() + .exceptionally( + e -> { + LOG.warn( + "[{}] Failed to initialize client routes (table may not exist on this server " + + "version — client-routes feature will be inactive): {}", + logPrefix, + e.getMessage(), + e); + return null; + }); + } + private CompletionStage checkProtocolVersion() { try { boolean protocolWasForced = diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/clientroutes/CachingDnsResolverTest.java b/core/src/test/java/com/datastax/oss/driver/internal/core/clientroutes/CachingDnsResolverTest.java new file mode 100644 index 00000000000..54d9edef5db --- /dev/null +++ b/core/src/test/java/com/datastax/oss/driver/internal/core/clientroutes/CachingDnsResolverTest.java @@ -0,0 +1,168 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Test; + +public class CachingDnsResolverTest { + + private static final InetAddress ADDR_1; + private static final InetAddress ADDR_2; + + static { + try { + ADDR_1 = InetAddress.getByAddress(new byte[] {127, 0, 0, 1}); + ADDR_2 = InetAddress.getByAddress(new byte[] {127, 0, 0, 2}); + } catch (UnknownHostException e) { + throw new ExceptionInInitializerError(e); + } + } + + @Test + public void should_return_cached_address_within_ttl() throws UnknownHostException { + AtomicInteger callCount = new AtomicInteger(0); + CachingDnsResolver resolver = + new CachingDnsResolver( + 10_000, + hostname -> { + callCount.incrementAndGet(); + return ADDR_1; + }); + + InetAddress first = resolver.resolve("host1"); + InetAddress second = resolver.resolve("host1"); + + assertThat(first).isEqualTo(ADDR_1); + assertThat(second).isEqualTo(ADDR_1); + assertThat(callCount.get()).isEqualTo(1); + } + + @Test + public void should_re_resolve_after_ttl_expires() throws Exception { + AtomicInteger callCount = new AtomicInteger(0); + // 0ms TTL means cache entries are immediately stale + CachingDnsResolver resolver = + new CachingDnsResolver( + 0, + hostname -> { + callCount.incrementAndGet(); + return ADDR_1; + }); + + resolver.resolve("host1"); + Thread.sleep(5); // ensure expiry + resolver.resolve("host1"); + + assertThat(callCount.get()).isEqualTo(2); + } + + @Test + public void should_resolve_different_hostnames_independently() throws UnknownHostException { + CachingDnsResolver resolver = + new CachingDnsResolver(10_000, hostname -> hostname.equals("host1") ? ADDR_1 : ADDR_2); + + assertThat(resolver.resolve("host1")).isEqualTo(ADDR_1); + assertThat(resolver.resolve("host2")).isEqualTo(ADDR_2); + assertThat(resolver.resolve("host1")).isEqualTo(ADDR_1); + } + + @Test + public void should_fall_back_to_last_known_good_on_failure() throws Exception { + AtomicBoolean shouldFail = new AtomicBoolean(false); + CachingDnsResolver resolver = + new CachingDnsResolver( + 0, + hostname -> { + if (shouldFail.get()) { + throw new UnknownHostException("simulated failure"); + } + return ADDR_1; + }); + + // Populate last-known-good + assertThat(resolver.resolve("host1")).isEqualTo(ADDR_1); + + // Expire cache and make resolution fail + Thread.sleep(5); + shouldFail.set(true); + + // Should return last-known-good instead of throwing + assertThat(resolver.resolve("host1")).isEqualTo(ADDR_1); + } + + @Test + public void should_throw_when_no_fallback_available() { + CachingDnsResolver resolver = + new CachingDnsResolver( + 10_000, + hostname -> { + throw new UnknownHostException("always fails"); + }); + + assertThatThrownBy(() -> resolver.resolve("unknown-host")) + .isInstanceOf(UnknownHostException.class); + } + + @Test + public void should_clear_cache_on_clearCache() throws Exception { + AtomicInteger callCount = new AtomicInteger(0); + CachingDnsResolver resolver = + new CachingDnsResolver( + 10_000, + hostname -> { + callCount.incrementAndGet(); + return ADDR_1; + }); + + resolver.resolve("host1"); + assertThat(callCount.get()).isEqualTo(1); + + resolver.clearCache(); + resolver.resolve("host1"); + + assertThat(callCount.get()).isEqualTo(2); + } + + @Test + public void should_retain_last_known_good_after_clearCache() throws Exception { + AtomicBoolean shouldFail = new AtomicBoolean(false); + CachingDnsResolver resolver = + new CachingDnsResolver( + 10_000, + hostname -> { + if (shouldFail.get()) { + throw new UnknownHostException("simulated failure"); + } + return ADDR_1; + }); + + resolver.resolve("host1"); // populate last-known-good + resolver.clearCache(); // clear cache but not last-known-good + shouldFail.set(true); + + // Should still fall back to last-known-good after cache clear + assertThat(resolver.resolve("host1")).isEqualTo(ADDR_1); + } +} diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/clientroutes/ClientRoutesAddressTranslatorTest.java b/core/src/test/java/com/datastax/oss/driver/internal/core/clientroutes/ClientRoutesAddressTranslatorTest.java new file mode 100644 index 00000000000..240f416c439 --- /dev/null +++ b/core/src/test/java/com/datastax/oss/driver/internal/core/clientroutes/ClientRoutesAddressTranslatorTest.java @@ -0,0 +1,131 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; +import static org.mockito.Mockito.when; + +import com.datastax.oss.driver.api.core.ssl.SslEngineFactory; +import com.datastax.oss.driver.internal.core.context.InternalDriverContext; +import java.net.InetSocketAddress; +import java.util.Optional; +import java.util.UUID; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class ClientRoutesAddressTranslatorTest { + + @Mock private InternalDriverContext context; + @Mock private ClientRoutesHandler handler; + @Mock private SslEngineFactory sslEngineFactory; + + private ClientRoutesAddressTranslator translator; + + private static final InetSocketAddress ORIGINAL = new InetSocketAddress("10.0.0.1", 9042); + private static final InetSocketAddress TRANSLATED = new InetSocketAddress("192.168.1.1", 19042); + + @Before + public void setup() { + when(context.getSessionName()).thenReturn("test-session"); + when(context.getClientRoutesHandler()).thenReturn(handler); + when(context.getSslEngineFactory()).thenReturn(Optional.empty()); + translator = new ClientRoutesAddressTranslator(context); + } + + @Test + public void should_bypass_translation_for_null_host_id() { + InetSocketAddress result = translator.translate(ORIGINAL, null, "dc1", "rack1"); + + assertThat(result).isEqualTo(ORIGINAL); + verifyZeroInteractions(handler); + } + + @Test + public void should_bypass_translation_via_legacy_translate_method() { + // The no-arg translate is used for contact points + InetSocketAddress result = translator.translate(ORIGINAL); + + assertThat(result).isEqualTo(ORIGINAL); + verifyZeroInteractions(handler); + } + + @Test + public void should_translate_known_host_id() { + UUID hostId = UUID.randomUUID(); + when(handler.translate(eq(hostId), anyBoolean())).thenReturn(TRANSLATED); + + InetSocketAddress result = translator.translate(ORIGINAL, hostId, "dc1", "rack1"); + + assertThat(result).isEqualTo(TRANSLATED); + verify(handler).translate(eq(hostId), anyBoolean()); + } + + @Test + public void should_fallback_to_original_when_handler_returns_null() { + UUID hostId = UUID.randomUUID(); + when(handler.translate(eq(hostId), anyBoolean())).thenReturn(null); + + InetSocketAddress result = translator.translate(ORIGINAL, hostId, "dc1", "rack1"); + + assertThat(result).isEqualTo(ORIGINAL); + } + + @Test + public void should_pass_ssl_flag_based_on_ssl_engine_factory() { + UUID hostId = UUID.randomUUID(); + // Enable SSL + when(context.getSslEngineFactory()).thenReturn(Optional.of(sslEngineFactory)); + when(handler.translate(eq(hostId), eq(true))).thenReturn(TRANSLATED); + // Recreate translator so it picks up new SSL setting + translator = new ClientRoutesAddressTranslator(context); + + InetSocketAddress result = translator.translate(ORIGINAL, hostId, "dc1", "rack1"); + + assertThat(result).isEqualTo(TRANSLATED); + verify(handler).translate(hostId, true); + } + + @Test + public void should_handle_null_handler_gracefully() { + when(context.getClientRoutesHandler()).thenReturn(null); + // Recreate translator so it uses null handler + translator = new ClientRoutesAddressTranslator(context); + UUID hostId = UUID.randomUUID(); + + // Should return original address, not throw + InetSocketAddress result = translator.translate(ORIGINAL, hostId, "dc1", "rack1"); + + assertThat(result).isEqualTo(ORIGINAL); + } + + @Test + public void close_does_not_close_handler() { + // Handler lifecycle is managed by context; translator.close() must be a no-op + translator.close(); + // handler was never closed — no interactions + verifyZeroInteractions(handler); + } +} diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/clientroutes/ClientRoutesHandlerTest.java b/core/src/test/java/com/datastax/oss/driver/internal/core/clientroutes/ClientRoutesHandlerTest.java new file mode 100644 index 00000000000..8225cddcf70 --- /dev/null +++ b/core/src/test/java/com/datastax/oss/driver/internal/core/clientroutes/ClientRoutesHandlerTest.java @@ -0,0 +1,164 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import com.datastax.oss.driver.api.core.config.ClientRoutesConfig; +import com.datastax.oss.driver.api.core.config.ClientRoutesEndpoint; +import com.datastax.oss.driver.internal.core.context.InternalDriverContext; +import com.datastax.oss.driver.shaded.guava.common.collect.ImmutableMap; +import java.net.InetSocketAddress; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class ClientRoutesHandlerTest { + + @Mock private InternalDriverContext context; + + private TestableClientRoutesHandler handler; + + /** + * Subclass exposing package-private {@code resolvedRoutesRef} so tests can inject test data + * without actually executing admin queries. + */ + @SuppressWarnings("NewClassNamingConvention") + static class TestableClientRoutesHandler extends ClientRoutesHandler { + TestableClientRoutesHandler(InternalDriverContext ctx, ClientRoutesConfig cfg) { + super(ctx, cfg); + } + + void setRoutes(Map routes) { + resolvedRoutesRef.set(new ConcurrentHashMap<>(routes)); + } + } + + @Before + public void setup() { + when(context.getSessionName()).thenReturn("test-session"); + ClientRoutesConfig config = + ClientRoutesConfig.builder() + .addEndpoint(new ClientRoutesEndpoint(UUID.randomUUID(), "host1:9042")) + .build(); + handler = new TestableClientRoutesHandler(context, config); + } + + // ---- translate() ------------------------------------------------------- + + @Test + public void should_return_null_for_unknown_host_id() { + assertThat(handler.translate(UUID.randomUUID(), false)).isNull(); + } + + @Test + public void should_translate_known_host_id_non_ssl() { + UUID hostId = UUID.randomUUID(); + handler.setRoutes( + ImmutableMap.of(hostId, new ResolvedClientRoute(hostId, "127.0.0.1", 9042, 9142))); + + InetSocketAddress result = handler.translate(hostId, false); + + assertThat(result).isNotNull(); + assertThat(result.getPort()).isEqualTo(9042); + } + + @Test + public void should_select_tls_port_when_ssl() { + UUID hostId = UUID.randomUUID(); + handler.setRoutes( + ImmutableMap.of(hostId, new ResolvedClientRoute(hostId, "127.0.0.1", 9042, 9142))); + + InetSocketAddress result = handler.translate(hostId, true); + + assertThat(result).isNotNull(); + assertThat(result.getPort()).isEqualTo(9142); + } + + @Test + public void should_fall_back_to_non_ssl_port_when_tls_port_absent() { + UUID hostId = UUID.randomUUID(); + // tls_port is null — should warn and fall back to non-SSL port + handler.setRoutes( + ImmutableMap.of(hostId, new ResolvedClientRoute(hostId, "127.0.0.1", 9042, null))); + + InetSocketAddress result = handler.translate(hostId, true); + + assertThat(result).isNotNull(); + assertThat(result.getPort()).isEqualTo(9042); + } + + @Test + public void should_return_null_when_no_port_configured() { + UUID hostId = UUID.randomUUID(); + // Both ports null → IllegalStateException → translate() catches it and returns null + handler.setRoutes( + ImmutableMap.of(hostId, new ResolvedClientRoute(hostId, "127.0.0.1", null, null))); + + assertThat(handler.translate(hostId, false)).isNull(); + } + + @Test + public void should_return_null_after_close() { + UUID hostId = UUID.randomUUID(); + handler.setRoutes( + ImmutableMap.of(hostId, new ResolvedClientRoute(hostId, "127.0.0.1", 9042, null))); + + handler.close(); + + assertThat(handler.translate(hostId, false)).isNull(); + } + + @Test + public void should_return_null_for_unresolvable_hostname() { + UUID hostId = UUID.randomUUID(); + // Use a hostname guaranteed not to resolve + handler.setRoutes( + ImmutableMap.of( + hostId, + new ResolvedClientRoute(hostId, "this.host.does.not.exist.invalid", 9042, null))); + + // Should not throw; returns null and logs a warning + assertThat(handler.translate(hostId, false)).isNull(); + } + + @Test + public void should_refresh_updates_routes() { + UUID hostId1 = UUID.randomUUID(); + UUID hostId2 = UUID.randomUUID(); + + handler.setRoutes( + ImmutableMap.of(hostId1, new ResolvedClientRoute(hostId1, "127.0.0.1", 9042, null))); + assertThat(handler.translate(hostId1, false)).isNotNull(); + assertThat(handler.translate(hostId2, false)).isNull(); + + // Simulate a refresh that swaps in a different set of routes + handler.setRoutes( + ImmutableMap.of(hostId2, new ResolvedClientRoute(hostId2, "127.0.0.2", 9042, null))); + + assertThat(handler.translate(hostId1, false)).isNull(); + assertThat(handler.translate(hostId2, false)).isNotNull(); + } +} From 9ba11094b71edf6af52b341a4693a666ec343a3c Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Fri, 27 Feb 2026 15:30:28 +0100 Subject: [PATCH 7/9] feat: add integration tests for PrivateLink client routes feature --- .../core/clientroutes/ClientRoutesIT.java | 409 ++++++++++++++++++ .../ClientRoutesUnsupportedVersionIT.java | 208 +++++++++ 2 files changed, 617 insertions(+) create mode 100644 integration-tests/src/test/java/com/datastax/oss/driver/core/clientroutes/ClientRoutesIT.java create mode 100644 integration-tests/src/test/java/com/datastax/oss/driver/core/clientroutes/ClientRoutesUnsupportedVersionIT.java diff --git a/integration-tests/src/test/java/com/datastax/oss/driver/core/clientroutes/ClientRoutesIT.java b/integration-tests/src/test/java/com/datastax/oss/driver/core/clientroutes/ClientRoutesIT.java new file mode 100644 index 00000000000..4956832dc22 --- /dev/null +++ b/integration-tests/src/test/java/com/datastax/oss/driver/core/clientroutes/ClientRoutesIT.java @@ -0,0 +1,409 @@ +/* + * 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.core.clientroutes; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import com.datastax.oss.driver.api.core.CqlSession; +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.cql.ResultSet; +import com.datastax.oss.driver.api.core.cql.Row; +import com.datastax.oss.driver.api.testinfra.ScyllaOnly; +import com.datastax.oss.driver.api.testinfra.ScyllaRequirement; +import com.datastax.oss.driver.api.testinfra.ccm.CustomCcmRule; +import com.datastax.oss.driver.api.testinfra.session.SessionRule; +import com.datastax.oss.driver.api.testinfra.session.SessionUtils; +import com.datastax.oss.driver.categories.IsolatedTests; +import com.datastax.oss.driver.internal.core.clientroutes.ClientRoutesHandler; +import com.datastax.oss.driver.internal.core.context.InternalDriverContext; +import java.net.InetSocketAddress; +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.junit.AssumptionViolatedException; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.experimental.categories.Category; +import org.junit.rules.RuleChain; +import org.junit.rules.TestRule; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Integration tests for the PrivateLink client routes feature. + * + *

These tests require a ScyllaDB instance that exposes the {@code system.client_routes} table + * (introduced in scylladb/scylladb#27323). Each test opens its own CQL sessions to populate the + * test table and configure {@link ClientRoutesConfig} precisely. + * + *

The positive tests use a separate keyspace-level table (not the actual {@code + * system.client_routes}) where write access is available, configured via {@link + * ClientRoutesConfig.Builder#withTableName}. This allows testing the full query and translation + * pipeline without requiring privileged write access to the {@code system} keyspace. + * + *

A dedicated negative test ({@link + * ClientRoutesUnsupportedVersionIT#should_not_activate_on_unsupported_version()}) is annotated to + * run only on versions that do not support the feature and verifies that the handler + * remains empty when {@code system.client_routes} does not exist. + */ +@Category(IsolatedTests.class) +@ScyllaOnly(description = "system.client_routes is a ScyllaDB-only system table") +@ScyllaRequirement( + minEnterprise = "2026.1", + description = + "system.client_routes requires ScyllaDB Enterprise >= 2026.1 (scylladb/scylladb#27323)") +public class ClientRoutesIT { + + private static final Logger LOG = LoggerFactory.getLogger(ClientRoutesIT.class); + + /** + * Name of a test-keyspace table that mirrors the {@code system.client_routes} schema. Tests + * INSERT into this table via a plain session, then point {@link ClientRoutesConfig} at it. + */ + private static final String TEST_TABLE = "test_client_routes.client_routes"; + + private static final String CREATE_KEYSPACE = + "CREATE KEYSPACE IF NOT EXISTS test_client_routes" + + " WITH replication = {'class':'SimpleStrategy','replication_factor':1}"; + + /** + * DDL matching the server-side {@code system.client_routes} schema. Using a user keyspace lets + * the test insert rows freely without requiring internal permissions. + */ + private static final String CREATE_TABLE = + "CREATE TABLE IF NOT EXISTS " + + TEST_TABLE + + " (" + + " connection_id uuid," + + " host_id uuid," + + " address text," + + " port int," + + " tls_port int," + + " PRIMARY KEY (connection_id, host_id)" + + ")"; + + public static final CustomCcmRule CCM_RULE = CustomCcmRule.builder().withNodes(1).build(); + + private static final SessionRule SESSION_RULE = + SessionRule.builder(CCM_RULE).withKeyspace(false).build(); + + @ClassRule + public static final TestRule CHAIN = RuleChain.outerRule(CCM_RULE).around(SESSION_RULE); + + // ---- helpers ----------------------------------------------------------- + + /** Returns the IP address of CCM node 1 (e.g. {@code "127.0.2.1"}). */ + private String nodeAddress() { + return CCM_RULE.getCcmBridge().getNodeIpAddress(1); + } + + /** Opens a plain (no client-routes) session against CCM to set up the test table and rows. */ + private CqlSession openAdminSession() { + return (CqlSession) + SessionUtils.baseBuilder() + .addContactEndPoints(CCM_RULE.getContactPoints()) + .withConfigLoader( + SessionUtils.configLoaderBuilder() + .withDuration(DefaultDriverOption.REQUEST_TIMEOUT, Duration.ofSeconds(10)) + .build()) + .build(); + } + + /** Ensures the test keyspace and table exist and are empty. */ + private void prepareTable(CqlSession admin) { + admin.execute(CREATE_KEYSPACE); + admin.execute(CREATE_TABLE); + admin.execute("TRUNCATE TABLE " + TEST_TABLE); + } + + /** Inserts one row into the test client_routes table. */ + private void insertRoute( + CqlSession admin, UUID connectionId, UUID hostId, String address, int port) { + admin.execute( + "INSERT INTO " + + TEST_TABLE + + " (connection_id, host_id, address, port)" + + " VALUES (" + + connectionId + + ", " + + hostId + + ", '" + + address + + "', " + + port + + ")"); + } + + /** Builds a {@link CqlSession} with the given {@link ClientRoutesConfig}. */ + private CqlSession openClientRoutesSession(ClientRoutesConfig config) { + return (CqlSession) + SessionUtils.baseBuilder() + .addContactEndPoints(CCM_RULE.getContactPoints()) + .withClientRoutesConfig(config) + .withConfigLoader( + SessionUtils.configLoaderBuilder() + .withDuration(DefaultDriverOption.REQUEST_TIMEOUT, Duration.ofSeconds(10)) + .build()) + .build(); + } + + /** Returns the {@link ClientRoutesHandler} from an open session's context. */ + private ClientRoutesHandler handlerOf(CqlSession session) { + ClientRoutesHandler handler = + ((InternalDriverContext) session.getContext()).getClientRoutesHandler(); + assertThat(handler) + .as("ClientRoutesHandler must be non-null when ClientRoutesConfig is set") + .isNotNull(); + return handler; + } + + /** + * Guards a positive test: confirms that {@code system.client_routes} really exists on the running + * server. If it doesn't, the test is skipped via an {@link AssumptionViolatedException} rather + * than failing, which provides a clear signal that the feature is not yet available. + * + *

This is a defence-in-depth check on top of the class-level {@link ScyllaRequirement} — it + * catches cases where the version annotation is wrong or the binary predates the feature despite + * its reported version. + */ + private void requireSystemClientRoutesTable(CqlSession admin) { + Row row = + admin + .execute( + "SELECT table_name FROM system_schema.tables" + + " WHERE keyspace_name='system' AND table_name='client_routes'") + .one(); + if (row == null) { + throw new AssumptionViolatedException( + "system.client_routes does not exist on this server — " + + "feature requires ScyllaDB Enterprise >= 2026.1 (not yet available on OSS). " + + "Skipping positive client-routes test."); + } + } + + // ---- tests ------------------------------------------------------------- + + /** + * Verifies that {@link ClientRoutesHandler#init()} reads rows from the configured table and + * populates the internal route map on session startup. + */ + @Test + public void should_load_routes_from_table_on_init() { + UUID connectionId = UUID.randomUUID(); + String nodeAddr = nodeAddress(); + + try (CqlSession admin = openAdminSession()) { + requireSystemClientRoutesTable(admin); + prepareTable(admin); + + // Discover the node host_id from system.local + Row localRow = admin.execute("SELECT host_id FROM system.local WHERE key='local'").one(); + assertThat(localRow).isNotNull(); + UUID hostId = localRow.getUuid("host_id"); + assertThat(hostId).isNotNull(); + + insertRoute(admin, connectionId, hostId, nodeAddr, 9042); + + ClientRoutesConfig config = + ClientRoutesConfig.builder() + .addEndpoint(new ClientRoutesEndpoint(connectionId, nodeAddr + ":9042")) + .withTableName(TEST_TABLE) + .build(); + + try (CqlSession session = openClientRoutesSession(config)) { + // Session must be usable + ResultSet rs = + session.execute("SELECT release_version FROM system.local WHERE key='local'"); + assertThat(rs.one()).isNotNull(); + + // Handler must have loaded the route for the known host_id + InetSocketAddress translated = handlerOf(session).translate(hostId, false); + assertThat(translated) + .as("Handler should have a translated address for host_id=%s", hostId) + .isNotNull(); + assertThat(translated.getPort()).isEqualTo(9042); + + LOG.info("Translated host_id={} → {}", hostId, translated); + } + } + } + + /** + * Verifies that calling {@link ClientRoutesHandler#refresh()} after inserting new rows picks up + * the new routes without restarting the session. + */ + @Test + public void should_refresh_routes_after_table_update() throws Exception { + UUID connectionId = UUID.randomUUID(); + UUID hostId = UUID.randomUUID(); // synthetic — tests the query/map pipeline + String nodeAddr = nodeAddress(); + + try (CqlSession admin = openAdminSession()) { + requireSystemClientRoutesTable(admin); + prepareTable(admin); // empty initially + + ClientRoutesConfig config = + ClientRoutesConfig.builder() + .addEndpoint(new ClientRoutesEndpoint(connectionId, nodeAddr + ":9042")) + .withTableName(TEST_TABLE) + .build(); + + try (CqlSession session = openClientRoutesSession(config)) { + ClientRoutesHandler handler = handlerOf(session); + + // No route yet + assertThat(handler.translate(hostId, false)).isNull(); + + // Insert a new route and explicitly trigger a refresh + insertRoute(admin, connectionId, hostId, nodeAddr, 9042); + handler.refresh().toCompletableFuture().get(10, TimeUnit.SECONDS); + + InetSocketAddress translated = handler.translate(hostId, false); + assertThat(translated).isNotNull(); + assertThat(translated.getPort()).isEqualTo(9042); + } + } + } + + /** + * Verifies that a session with client routes configured against an empty table still starts + * successfully and remains usable (nodes accessible via untranslated addresses). + */ + @Test + public void should_start_session_when_table_is_empty() { + String nodeAddr = nodeAddress(); + UUID connectionId = UUID.randomUUID(); + + try (CqlSession admin = openAdminSession()) { + requireSystemClientRoutesTable(admin); + prepareTable(admin); // intentionally empty + + ClientRoutesConfig config = + ClientRoutesConfig.builder() + .addEndpoint(new ClientRoutesEndpoint(connectionId, nodeAddr + ":9042")) + .withTableName(TEST_TABLE) + .build(); + + try (CqlSession session = openClientRoutesSession(config)) { + ResultSet rs = + session.execute("SELECT release_version FROM system.local WHERE key='local'"); + assertThat(rs.one()).isNotNull(); + + // No routes loaded — translate returns null for any host_id + assertThat(handlerOf(session).translate(UUID.randomUUID(), false)).isNull(); + } + } + } + + /** + * Verifies that a route row with a {@code tls_port} column is correctly surfaced by {@link + * ClientRoutesHandler#translate(UUID, boolean)} depending on the {@code useSsl} flag. + */ + @Test + public void should_select_tls_port_when_ssl_configured() { + UUID connectionId = UUID.randomUUID(); + UUID hostId = UUID.randomUUID(); + String nodeAddr = nodeAddress(); + + try (CqlSession admin = openAdminSession()) { + requireSystemClientRoutesTable(admin); + prepareTable(admin); + + admin.execute( + "INSERT INTO " + + TEST_TABLE + + " (connection_id, host_id, address, port, tls_port)" + + " VALUES (" + + connectionId + + ", " + + hostId + + ", '" + + nodeAddr + + "', 9042, 9142)"); + + ClientRoutesConfig config = + ClientRoutesConfig.builder() + .addEndpoint(new ClientRoutesEndpoint(connectionId, nodeAddr + ":9042")) + .withTableName(TEST_TABLE) + .build(); + + try (CqlSession session = openClientRoutesSession(config)) { + ClientRoutesHandler handler = handlerOf(session); + + InetSocketAddress nonSsl = handler.translate(hostId, false); + assertThat(nonSsl).isNotNull(); + assertThat(nonSsl.getPort()).isEqualTo(9042); + + InetSocketAddress ssl = handler.translate(hostId, true); + assertThat(ssl).isNotNull(); + assertThat(ssl.getPort()).isEqualTo(9142); + } + } + } + + /** + * Verifies that after a control connection force-reconnect, {@link ClientRoutesHandler#refresh()} + * is called automatically and picks up new rows inserted while the session was running. + */ + @Test + public void should_refresh_routes_after_control_connection_reconnect() { + UUID connectionId = UUID.randomUUID(); + UUID hostId = UUID.randomUUID(); + String nodeAddr = nodeAddress(); + + try (CqlSession admin = openAdminSession()) { + requireSystemClientRoutesTable(admin); + prepareTable(admin); // no rows initially + + ClientRoutesConfig config = + ClientRoutesConfig.builder() + .addEndpoint(new ClientRoutesEndpoint(connectionId, nodeAddr + ":9042")) + .withTableName(TEST_TABLE) + .build(); + + try (CqlSession session = openClientRoutesSession(config)) { + ClientRoutesHandler handler = handlerOf(session); + assertThat(handler.translate(hostId, false)).isNull(); + + // Insert a new route while the session is running + insertRoute(admin, connectionId, hostId, nodeAddr, 9042); + + // Force control connection reconnect — onSuccessfulReconnect triggers handler.refresh() + ((InternalDriverContext) session.getContext()).getControlConnection().reconnectNow(); + + // Routes are refreshed asynchronously; wait for the translation to become available + await().atMost(15, TimeUnit.SECONDS).until(() -> handler.translate(hostId, false) != null); + + assertThat(handler.translate(hostId, false)) + .as("Route should be available after reconnect and refresh for host_id=%s", hostId) + .isNotNull() + .extracting(InetSocketAddress::getPort) + .isEqualTo(9042); + } + } + } +} diff --git a/integration-tests/src/test/java/com/datastax/oss/driver/core/clientroutes/ClientRoutesUnsupportedVersionIT.java b/integration-tests/src/test/java/com/datastax/oss/driver/core/clientroutes/ClientRoutesUnsupportedVersionIT.java new file mode 100644 index 00000000000..8810049186c --- /dev/null +++ b/integration-tests/src/test/java/com/datastax/oss/driver/core/clientroutes/ClientRoutesUnsupportedVersionIT.java @@ -0,0 +1,208 @@ +/* + * 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.core.clientroutes; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.datastax.oss.driver.api.core.CqlSession; +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.cql.ResultSet; +import com.datastax.oss.driver.api.core.cql.Row; +import com.datastax.oss.driver.api.testinfra.ScyllaOnly; +import com.datastax.oss.driver.api.testinfra.ScyllaRequirement; +import com.datastax.oss.driver.api.testinfra.ccm.CcmBridge; +import com.datastax.oss.driver.api.testinfra.ccm.CustomCcmRule; +import com.datastax.oss.driver.api.testinfra.session.SessionRule; +import com.datastax.oss.driver.api.testinfra.session.SessionUtils; +import com.datastax.oss.driver.categories.IsolatedTests; +import com.datastax.oss.driver.internal.core.clientroutes.ClientRoutesHandler; +import com.datastax.oss.driver.internal.core.context.InternalDriverContext; +import java.time.Duration; +import java.util.UUID; +import org.junit.AssumptionViolatedException; +import org.junit.ClassRule; +import org.junit.Test; +import org.junit.experimental.categories.Category; +import org.junit.rules.RuleChain; +import org.junit.rules.TestRule; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Negative integration test for the PrivateLink client routes feature. + * + *

This test class is intentionally not gated with a minimum version requirement: it + * must run on older ScyllaDB Enterprise versions (before 2026.1) where the {@code + * system.client_routes} table does not exist. + * + *

The single test here verifies that: + * + *

    + *
  1. {@code system.client_routes} is truly absent on the running server — if it is present, the + * test fails loudly to signal that the version gate in {@link ClientRoutesIT} needs updating. + *
  2. The driver gracefully opens a session even when the table is missing (no crash on init). + *
  3. The {@link ClientRoutesHandler} returns {@code null} for all host IDs — the feature is not + * accidentally activated. + *
+ * + *

This class is annotated with {@code @ScyllaOnly} because {@code system.client_routes} is + * specific to ScyllaDB Enterprise (not yet available on OSS) and has no equivalent in Apache + * Cassandra. The {@code maxEnterprise = "2026.1"} constraint ensures this test is skipped on + * versions that do support the feature (it would have nothing meaningful to assert there, + * and the positive tests in {@link ClientRoutesIT} cover that range). + */ +@Category(IsolatedTests.class) +@ScyllaOnly(description = "system.client_routes is a ScyllaDB Enterprise-only feature") +@ScyllaRequirement( + maxEnterprise = "2026.1", + description = + "Negative test for Enterprise versions where system.client_routes does not exist. " + + "Skipped on Enterprise >= 2026.1 (use ClientRoutesIT for those versions).") +public class ClientRoutesUnsupportedVersionIT { + + private static final Logger LOG = LoggerFactory.getLogger(ClientRoutesUnsupportedVersionIT.class); + + public static final CustomCcmRule CCM_RULE = CustomCcmRule.builder().withNodes(1).build(); + + private static final SessionRule SESSION_RULE = + SessionRule.builder(CCM_RULE).withKeyspace(false).build(); + + @ClassRule + public static final TestRule CHAIN = RuleChain.outerRule(CCM_RULE).around(SESSION_RULE); + + // ---- helpers ----------------------------------------------------------- + + private String nodeAddress() { + return CCM_RULE.getCcmBridge().getNodeIpAddress(1); + } + + private CqlSession openAdminSession() { + return (CqlSession) + SessionUtils.baseBuilder() + .addContactEndPoints(CCM_RULE.getContactPoints()) + .withConfigLoader( + SessionUtils.configLoaderBuilder() + .withDuration(DefaultDriverOption.REQUEST_TIMEOUT, Duration.ofSeconds(10)) + .build()) + .build(); + } + + private CqlSession openClientRoutesSession(ClientRoutesConfig config) { + return (CqlSession) + SessionUtils.baseBuilder() + .addContactEndPoints(CCM_RULE.getContactPoints()) + .withClientRoutesConfig(config) + .withConfigLoader( + SessionUtils.configLoaderBuilder() + .withDuration(DefaultDriverOption.REQUEST_TIMEOUT, Duration.ofSeconds(10)) + .build()) + .build(); + } + + // ---- test -------------------------------------------------------------- + + /** + * Verifies that on a ScyllaDB Enterprise version that predates the {@code system.client_routes} + * table (i.e. Enterprise < 2026.1), the driver behaves correctly: + * + *

    + *
  • The system table does not exist — confirmed via {@code system_schema.tables}. + *
  • A session with {@link ClientRoutesConfig} can still be opened (graceful degradation). + *
  • The {@link ClientRoutesHandler} stays empty; {@code translate()} returns {@code null}. + *
+ * + *

The assertion on the absent table acts as a built-in guard against stale version gates: if + * {@code system.client_routes} ever appears on a version thought to be unsupported, this test + * fails with a clear message rather than silently passing. + */ + @Test + public void should_not_activate_on_unsupported_version() { + // This test targets Scylla Enterprise only; OSS boundary is not yet confirmed. + if (!CcmBridge.SCYLLA_ENTERPRISE) { + throw new AssumptionViolatedException( + "Negative client-routes test targets Scylla Enterprise only " + + "(OSS feature boundary not yet confirmed). Skipping on OSS."); + } + + try (CqlSession admin = openAdminSession()) { + + // ── Step 1 ── confirm the table is genuinely absent ─────────────────────────────────────── + // If system.client_routes already exists on this build, the maxEnterprise gate in this + // class's @ScyllaRequirement is wrong (or the feature shipped earlier than 2026.1). + // Fail with an explicit message so the gate can be corrected. + Row tableRow = + admin + .execute( + "SELECT table_name FROM system_schema.tables" + + " WHERE keyspace_name='system' AND table_name='client_routes'") + .one(); + assertThat(tableRow) + .as( + "system.client_routes MUST NOT exist on Enterprise < 2026.1 — " + + "if this assertion fails the @ScyllaRequirement(minEnterprise) gate in " + + "ClientRoutesIT (or maxEnterprise here) is out of date and must be corrected.") + .isNull(); + + // ── Step 2 ── session must still open (graceful degradation) ────────────────────────────── + UUID connectionId = UUID.randomUUID(); + String nodeAddr = nodeAddress(); + + // Use the real default table name (system.client_routes) — no user-space mirror — so any + // successful query result would mean the feature is genuinely active (it should not be). + ClientRoutesConfig config = + ClientRoutesConfig.builder() + .addEndpoint(new ClientRoutesEndpoint(connectionId, nodeAddr + ":9042")) + .build(); + + try (CqlSession session = openClientRoutesSession(config)) { + // The session must be fully usable despite the missing table. + ResultSet rs = + session.execute("SELECT release_version FROM system.local WHERE key='local'"); + assertThat(rs.one()) + .as("Session opened with ClientRoutesConfig must be usable even on unsupported server") + .isNotNull(); + + // ── Step 3 ── translation must return null ──────────────────────────────────────────── + ClientRoutesHandler handler = + ((InternalDriverContext) session.getContext()).getClientRoutesHandler(); + assertThat(handler) + .as("ClientRoutesHandler must be non-null whenever ClientRoutesConfig is set") + .isNotNull(); + + UUID anyHostId = UUID.randomUUID(); + assertThat(handler.translate(anyHostId, false)) + .as( + "ClientRoutesHandler.translate() must return null on an unsupported server — " + + "the feature must NOT be silently active when system.client_routes is absent") + .isNull(); + + LOG.info( + "Confirmed: client-routes feature correctly inactive on unsupported Enterprise {}", + CCM_RULE.getCcmBridge().getScyllaUnparsedVersion().orElse("")); + } + } + } +} From ed34f9f33db4cd1a942dc9204b7fb699ba1b7f55 Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Fri, 27 Feb 2026 15:37:47 +0100 Subject: [PATCH 8/9] feat: update documentation and error messages for PrivateLink client routes support --- .../core/channel/ProtocolInitHandler.java | 9 +-- manual/core/address_resolution/README.md | 59 +++++++++++++------ upgrade_guide/README.md | 36 +++++++++++ 3 files changed, 83 insertions(+), 21 deletions(-) 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 c1baad2ca8d..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 @@ -369,17 +369,18 @@ void onResponse(Message response) { && 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 older than 2025.4 (OSS) / 2025.4 (Enterprise), - // which do not implement the client_routes feature at the protocol level. The driver + // 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-2025.4 cluster that the user has (mis)configured with client routes. + // 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 ≥ 2025.4);" + "[{}] 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); diff --git a/manual/core/address_resolution/README.md b/manual/core/address_resolution/README.md index a7d12263e9e..cf7506fcb00 100644 --- a/manual/core/address_resolution/README.md +++ b/manual/core/address_resolution/README.md @@ -120,15 +120,15 @@ retrieved from or sent by Cassandra nodes are. ### Client Routes (PrivateLink deployments) -For cloud deployments using PrivateLink or similar private endpoint technologies (such as ScyllaDB Cloud), nodes are -accessed through private DNS endpoints rather than direct IP addresses. The driver provides a client routes feature -to handle this topology. +For cloud deployments using PrivateLink or similar private endpoint technologies (such as ScyllaDB +Cloud), nodes are accessed through private DNS endpoints rather than direct IP addresses. The driver +provides a built-in client routes feature that handles address translation automatically. Client routes configuration is done programmatically and is **mutually exclusive** with: -- A custom `AddressTranslator` (if both are provided, client routes takes precedence) -- Cloud secure connect bundles (if both are provided, the cloud bundle takes precedence) +- A custom `AddressTranslator` (if both are provided, an `IllegalStateException` is thrown) +- Cloud secure connect bundles (if both are provided, an `IllegalStateException` is thrown) -Example configuration: +#### Quick start ```java import com.datastax.oss.driver.api.core.CqlSession; @@ -136,33 +136,58 @@ import com.datastax.oss.driver.api.core.config.ClientRoutesConfig; import com.datastax.oss.driver.api.core.config.ClientRoutesEndpoint; import java.util.UUID; -// Configure endpoints with connection IDs and addresses ClientRoutesConfig config = ClientRoutesConfig.builder() .addEndpoint(new ClientRoutesEndpoint( UUID.fromString("12345678-1234-1234-1234-123456789012"), "my-cluster.us-east-1.aws.scylladb.com:9042")) .build(); -// Build session - endpoints are automatically used as seed hosts +// Contact points are seeded automatically from endpoint addresses. CqlSession session = CqlSession.builder() .withClientRoutesConfig(config) .withLocalDatacenter("datacenter1") .build(); ``` -When client routes are configured: -* The driver will use endpoint addresses as seed hosts if no explicit contact points are provided -* Custom `AddressTranslator` configuration will be overridden by the client routes handler (a warning is logged); the default `PassThroughAddressTranslator` is used internally -* Connection IDs map to the `system.client_routes` table entries +#### How it works -The system table name can be customized in the [configuration](../configuration/) (primarily for testing): +1. **Startup** — after the control connection is established, the driver queries + `system.client_routes` (filtered to the configured `connection_id` values) and builds an + in-memory map of `host_id → (hostname, port, tls_port)`. +2. **Translation** — every time the driver opens a connection to a peer node, it looks up the + node's `host_id` in the route map and resolves the associated DNS hostname. Contact points bypass + translation so the initial seed addresses are used as-is. +3. **Event-driven updates** — the driver registers for `CLIENT_ROUTES_CHANGE` server events. When + one arrives, it re-queries the table and atomically swaps the route map. +4. **Reconnect** — if the control connection is recreated the driver performs a full re-read of the + route table before refreshing node metadata. +#### DNS caching + +DNS is resolved at connection time (not at route discovery time). The driver caches resolved +addresses per hostname with a configurable TTL (default: **500 ms**): + +```java +ClientRoutesConfig config = ClientRoutesConfig.builder() + .addEndpoint(...) + .withDnsCacheDuration(1_000) // milliseconds + .build(); ``` -datastax-java-driver.advanced.client-routes.table-name = "system.client_routes" -``` -**Note:** As of the current version, the client routes configuration API is available, but the full handler implementation -(DNS resolution, address translation, event handling) is still under development. +Additional properties of the DNS resolver: + +- **Per-hostname concurrency limit**: at most one in-flight DNS lookup per hostname; concurrent + callers wait and reuse the result. +- **Last-known-good fallback**: if a DNS lookup fails, the resolver returns the last successfully + resolved address instead of throwing, so transient DNS hiccups do not cause connection failures. +- **`clearCache()`**: called automatically on session close to free resources. + +#### Limitations + +- Requires ScyllaDB Enterprise ≥ 2026.1 with `system.client_routes` support + (scylladb/scylladb#27323). Not yet available on ScyllaDB OSS. +- Not supported on Apache Cassandra. +- Mutually exclusive with custom `AddressTranslator` and with cloud secure connect bundles. ### EC2 multi-region diff --git a/upgrade_guide/README.md b/upgrade_guide/README.md index 8fdf068919a..aa2bd02c32f 100644 --- a/upgrade_guide/README.md +++ b/upgrade_guide/README.md @@ -19,6 +19,42 @@ under the License. ## Upgrade guide +### 4.19.0 + +#### PrivateLink support via client routes + +The driver now supports automatic address translation for ScyllaDB Cloud PrivateLink deployments +through the new client routes feature. When enabled, the driver reads endpoint mappings from the +`system.client_routes` system table and translates peer addresses transparently at connection time, +with TTL-based DNS caching and automatic refresh on `CLIENT_ROUTES_CHANGE` events. + +Configure it programmatically on the session builder: + +```java +ClientRoutesConfig config = ClientRoutesConfig.builder() + .addEndpoint(new ClientRoutesEndpoint( + UUID.fromString(""), + "my-cluster.region.provider.scylladb.com:9042")) + .withDnsCacheDuration(500) // optional, default 500 ms + .build(); + +CqlSession session = CqlSession.builder() + .withClientRoutesConfig(config) + .withLocalDatacenter("datacenter1") + .build(); +``` + +Key points: + +- **Contact points are seeded automatically** from the endpoint `connectionAddr` values when no + explicit contact points are configured. +- **Mutually exclusive** with a custom `AddressTranslator` and with cloud secure connect bundles — + providing both throws `IllegalStateException` at session build time. +- **Requires ScyllaDB Enterprise ≥ 2026.1** (scylladb/scylladb#27323). The feature is not + available on ScyllaDB OSS or Apache Cassandra. + +See [Address resolution — Client Routes](../manual/core/address_resolution/) for full details. + ### 4.18.1 #### Keystore reloading in DefaultSslEngineFactory From c24ab8c641095f1c264b88a275631c2cade9d2bf Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Tue, 3 Mar 2026 15:12:38 +0100 Subject: [PATCH 9/9] feat: enhance CachingDnsResolver with atomic semaphore management and testing for concurrent resolutions --- .../core/clientroutes/CachingDnsResolver.java | 98 ++++++++++++++----- .../core/clientroutes/DnsResolver.java | 10 +- .../clientroutes/CachingDnsResolverTest.java | 72 ++++++++++++++ manual/core/address_resolution/README.md | 12 ++- 4 files changed, 164 insertions(+), 28 deletions(-) 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 index f33aad1cb88..cb668a44f83 100644 --- 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 @@ -17,12 +17,14 @@ */ 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; @@ -32,7 +34,16 @@ public class CachingDnsResolver implements DnsResolver { private static final Logger LOG = LoggerFactory.getLogger(CachingDnsResolver.class); private final long cacheDurationNanos; - private final ConcurrentHashMap semaphores = new ConcurrentHashMap<>(); + /** + * 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; @@ -55,7 +66,21 @@ public InetAddress resolve(@NonNull String hostname) throws UnknownHostException return entry.address; } - Semaphore semaphore = semaphores.computeIfAbsent(hostname, h -> new Semaphore(1)); + // 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). @@ -63,31 +88,28 @@ public InetAddress resolve(@NonNull String hostname) throws UnknownHostException 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(); - } - } else { - try { - // Double-checked locking: a concurrent thread may have resolved while we were acquiring. - entry = cachedEntry(hostname); - if (entry != null) { - return entry.address; - } - return doResolve(hostname); - } finally { - semaphore.release(); + } + 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); } } @@ -123,8 +145,14 @@ private InetAddress doResolve(String hostname) throws UnknownHostException { @Override public void clearCache() { cache.clear(); - semaphores.clear(); - // lastKnownGood is retained for fallback + // 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 { @@ -141,4 +169,24 @@ static class CacheEntry { 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/DnsResolver.java b/core/src/main/java/com/datastax/oss/driver/internal/core/clientroutes/DnsResolver.java index d098528d710..0d6986333be 100644 --- 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 @@ -53,9 +53,15 @@ public interface DnsResolver { InetAddress resolve(@NonNull String hostname) throws UnknownHostException; /** - * Clears all cached DNS entries. + * 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 is primarily useful for testing or when forcing a fresh DNS lookup is required. + *

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/test/java/com/datastax/oss/driver/internal/core/clientroutes/CachingDnsResolverTest.java b/core/src/test/java/com/datastax/oss/driver/internal/core/clientroutes/CachingDnsResolverTest.java index 54d9edef5db..94168b54252 100644 --- a/core/src/test/java/com/datastax/oss/driver/internal/core/clientroutes/CachingDnsResolverTest.java +++ b/core/src/test/java/com/datastax/oss/driver/internal/core/clientroutes/CachingDnsResolverTest.java @@ -22,6 +22,13 @@ import java.net.InetAddress; import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import org.junit.Test; @@ -165,4 +172,69 @@ public void should_retain_last_known_good_after_clearCache() throws Exception { // Should still fall back to last-known-good after cache clear assertThat(resolver.resolve("host1")).isEqualTo(ADDR_1); } + + @Test + public void should_not_retain_semaphores_after_resolution() throws UnknownHostException { + CachingDnsResolver resolver = new CachingDnsResolver(10_000, hostname -> ADDR_1); + + // Resolve a batch of distinct hostnames to ensure the semaphore map does not accumulate them. + int hostCount = 20; + for (int i = 0; i < hostCount; i++) { + resolver.resolve("host-" + i); + } + + // After all resolutions complete no semaphore entries should be retained. + assertThat(resolver.semaphoreCount()).isZero(); + } + + @Test + public void should_not_retain_semaphores_after_concurrent_resolution() throws Exception { + int threadCount = 10; + CachingDnsResolver resolver = + new CachingDnsResolver( + 10_000, + hostname -> { + // Simulate a short delay to increase contention on the semaphore. + try { + Thread.sleep(1); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return ADDR_1; + }); + + CountDownLatch start = new CountDownLatch(1); + ExecutorService exec = Executors.newFixedThreadPool(threadCount); + List unexpectedErrors = Collections.synchronizedList(new ArrayList<>()); + try { + for (int i = 0; i < threadCount; i++) { + final int idx = i; + exec.submit( + () -> { + try { + start.await(); + resolver.resolve("concurrent-host-" + idx); + } catch (InterruptedException e) { + // Expected during executor shutdown; restore the interrupt flag. + Thread.currentThread().interrupt(); + } catch (Throwable t) { + // Any other throwable (UnknownHostException, NPE, IllegalStateException, …) + // is unexpected and must not be silently swallowed. + unexpectedErrors.add(t); + } + }); + } + start.countDown(); + } finally { + exec.shutdown(); + assertThat(exec.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + } + + assertThat(unexpectedErrors) + .as("No unexpected exceptions should occur during concurrent resolution") + .isEmpty(); + + // All concurrent resolutions have finished; no semaphore should remain. + assertThat(resolver.semaphoreCount()).isZero(); + } } diff --git a/manual/core/address_resolution/README.md b/manual/core/address_resolution/README.md index cf7506fcb00..8e642e70fa1 100644 --- a/manual/core/address_resolution/README.md +++ b/manual/core/address_resolution/README.md @@ -180,7 +180,17 @@ Additional properties of the DNS resolver: callers wait and reuse the result. - **Last-known-good fallback**: if a DNS lookup fails, the resolver returns the last successfully resolved address instead of throwing, so transient DNS hiccups do not cause connection failures. -- **`clearCache()`**: called automatically on session close to free resources. +- **Route-map refresh** — the driver re-queries `system.client_routes` and atomically swaps the + in-memory route map in two situations: + - a `CLIENT_ROUTES_CHANGE` server event is received, or + - the control connection reconnects after a failure (`onSuccessfulReconnect`). + + A route-map refresh does **not** flush the DNS cache. Existing cached addresses remain valid + until their TTL expires; new hostnames are resolved on first use. +- **`clearCache()`** — called exactly once, when the session is closed (`CqlSession.close()`). It + is not called during route-map refreshes and is not intended for manual or administrative use. + Last-known-good entries are intentionally kept across a cache clear: should the same hostname be + re-resolved after a close/reopen cycle, the fallback address is still available. #### Limitations