From 792d9c5f5947e7b898378dfd22031dc4836f3c3d Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Wed, 29 Apr 2026 23:52:08 +0200 Subject: [PATCH 1/2] client routes: disable fallback to broadcast address when route is missing When a node has client-routes configured but no matching entry is found, fall back to the broadcast address by default (backward-compatible). When advanced.client-routes.exclusive-proxy=true, throw instead of falling back so the node stays DOWN until a CLIENT_ROUTES_CHANGE event provides the route. The constructor of ClientRoutesEndPoint now enforces that fallbackEndPoint is non-null when exclusiveProxy=false, making the invariant explicit at construction time rather than silently failing at resolve() time. The WARN log on null host_id no longer mentions exclusive-proxy behaviour since that path fires regardless of the flag; the message now focuses on the actual cause (corrupted system tables). --- .../core/metadata/ClientRoutesEndPoint.java | 44 +++++++++-- .../metadata/ClientRoutesTopologyMonitor.java | 21 ++++-- .../metadata/ClientRoutesEndPointTest.java | 60 ++++++++------- .../ClientRoutesTopologyMonitorTest.java | 73 +++++++++++++------ 4 files changed, 138 insertions(+), 60 deletions(-) diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesEndPoint.java b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesEndPoint.java index 15d825b2efc..47f2739a8d6 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesEndPoint.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesEndPoint.java @@ -27,12 +27,17 @@ import java.net.SocketAddress; import java.util.Objects; import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class ClientRoutesEndPoint implements EndPoint { + private static final Logger LOG = LoggerFactory.getLogger(ClientRoutesEndPoint.class); + private final UUID hostId; private final ClientRoutesTopologyMonitor topologyMonitor; private final String metricPrefix; - @NonNull private final EndPoint fallbackEndPoint; + @Nullable private final EndPoint fallbackEndPoint; + private final boolean exclusiveProxy; /** * @param topologyMonitor the topology monitor used to resolve the endpoint address on demand. @@ -42,18 +47,26 @@ public class ClientRoutesEndPoint implements EndPoint { * determined, in which case the hostId is used as the metric prefix instead. * @param fallbackEndPoint the default endpoint to fall back to when {@code * topologyMonitor.resolve()} returns {@code null}, i.e. when this node is not accessed via a - * cloud private endpoint. Must not be {@code null}. + * cloud private endpoint. Must be non-null when {@code exclusiveProxy} is {@code false}; + * ignored (and may be {@code null}) when {@code exclusiveProxy} is {@code true}. + * @param exclusiveProxy when {@code true}, {@link #resolve()} throws instead of falling back to + * {@code fallbackEndPoint} if no route is found. */ public ClientRoutesEndPoint( @NonNull ClientRoutesTopologyMonitor topologyMonitor, @NonNull UUID hostId, @Nullable InetAddress broadcastInetAddress, - @NonNull EndPoint fallbackEndPoint) { + @Nullable EndPoint fallbackEndPoint, + boolean exclusiveProxy) { this.topologyMonitor = Objects.requireNonNull(topologyMonitor, "Topology monitor cannot be null"); this.hostId = Objects.requireNonNull(hostId, "HOST uuid cannot be null"); - this.fallbackEndPoint = - Objects.requireNonNull(fallbackEndPoint, "Fallback endpoint cannot be null"); + if (!exclusiveProxy && fallbackEndPoint == null) { + throw new IllegalArgumentException( + "fallbackEndPoint must be non-null when exclusiveProxy is false"); + } + this.fallbackEndPoint = fallbackEndPoint; + this.exclusiveProxy = exclusiveProxy; this.metricPrefix = buildMetricPrefix(broadcastInetAddress, hostId); } @@ -73,7 +86,26 @@ public SocketAddress resolve() { } catch (IOException e) { throw new UncheckedIOException("DNS resolution failed for host_id=" + hostId, e); } - return fallbackEndPoint.resolve(); + if (!exclusiveProxy && fallbackEndPoint != null) { + // Default (backward-compatible) mode: fall back to the node's broadcast address. + // This supports mixed proxy/direct topologies where some nodes are behind the private + // endpoint and others are reached directly. + return fallbackEndPoint.resolve(); + } + // Exclusive-proxy mode: the driver must connect only through proxies. Falling back to the + // node's broadcast address would bypass the proxy infrastructure and cause silent + // misbehaviour (e.g. during the window between adding a new node and posting its client + // route entry). The node will remain DOWN and the reconnection loop will retry until a + // CLIENT_ROUTES_CHANGE event populates the route. + LOG.warn( + "No client route entry found for host_id={}. " + + "The node will remain DOWN until a route is published via CLIENT_ROUTES_CHANGE.", + hostId); + throw new IllegalStateException( + "No client route entry found for host_id=" + + hostId + + ". Will not connect to the node's broadcast address because client routes " + + "are configured; the driver must connect exclusively through proxies."); } @Override diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesTopologyMonitor.java b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesTopologyMonitor.java index 569637e0873..2548eaaccb0 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesTopologyMonitor.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesTopologyMonitor.java @@ -82,6 +82,7 @@ public class ClientRoutesTopologyMonitor extends DefaultTopologyMonitor { private final String logPrefix; private final AtomicReference> resolvedRoutesCache; private final boolean useSSL; + private final boolean exclusiveProxy; private volatile boolean closed = false; private final AtomicInteger consecutiveEmptyResults = new AtomicInteger(0); @@ -147,6 +148,11 @@ public ClientRoutesTopologyMonitor( this.logPrefix = context.getSessionName(); this.resolvedRoutesCache = new AtomicReference<>(Collections.emptyMap()); this.useSSL = context.getSslEngineFactory().isPresent(); + this.exclusiveProxy = + context + .getConfig() + .getDefaultProfile() + .getBoolean(DefaultDriverOption.CLIENT_ROUTES_EXCLUSIVE_PROXY); } @Override @@ -459,14 +465,15 @@ protected EndPoint buildNodeEndPoint( UUID hostId = row.getUuid("host_id"); if (hostId == null) { LOG.warn( - "[{}] host_id is null in system row for address {} — cannot assign a client route. " - + "This may indicate corrupted system tables. " - + "Falling back to default endpoint resolution.", + "[{}] host_id is null in system row for address {} — cannot build a client-routes" + + " endpoint. This may indicate corrupted system tables. The node will be ignored.", logPrefix, broadcastRpcAddress); - return super.buildNodeEndPoint(row, broadcastRpcAddress, localEndPoint); + throw new IllegalStateException( + "host_id is null in system row for address " + + broadcastRpcAddress + + "; cannot build a ClientRoutesEndPoint without a host_id"); } - EndPoint fallback = super.buildNodeEndPoint(row, broadcastRpcAddress, localEndPoint); InetAddress broadcastInetAddress = null; if (broadcastRpcAddress != null) { broadcastInetAddress = broadcastRpcAddress.getAddress(); @@ -477,7 +484,9 @@ protected EndPoint buildNodeEndPoint( if (broadcastInetAddress == null) { broadcastInetAddress = row.getInetAddress("peer"); } - return new ClientRoutesEndPoint(this, hostId, broadcastInetAddress, fallback); + EndPoint fallback = + exclusiveProxy ? null : super.buildNodeEndPoint(row, broadcastRpcAddress, localEndPoint); + return new ClientRoutesEndPoint(this, hostId, broadcastInetAddress, fallback, exclusiveProxy); } /** diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesEndPointTest.java b/core/src/test/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesEndPointTest.java index f31dd2861ed..0b1f010516d 100644 --- a/core/src/test/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesEndPointTest.java +++ b/core/src/test/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesEndPointTest.java @@ -46,23 +46,38 @@ public void should_resolve_via_topology_monitor() throws UnknownHostException { InetSocketAddress expected = new InetSocketAddress("127.0.0.1", 9042); when(topologyMonitor.resolve(hostId)).thenReturn(expected); - ClientRoutesEndPoint ep = - new ClientRoutesEndPoint(topologyMonitor, hostId, null, fallbackEndPoint); + ClientRoutesEndPoint ep = new ClientRoutesEndPoint(topologyMonitor, hostId, null, null, true); assertThat(ep.resolve()).isEqualTo(expected); } @Test - public void should_fallback_when_resolve_returns_null() throws UnknownHostException { + public void should_throw_when_exclusive_proxy_and_resolve_returns_null() + throws UnknownHostException { + UUID hostId = UUID.randomUUID(); + when(topologyMonitor.resolve(hostId)).thenReturn(null); + + ClientRoutesEndPoint ep = new ClientRoutesEndPoint(topologyMonitor, hostId, null, null, true); + + assertThatThrownBy(ep::resolve) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("No client route entry found") + .hasMessageContaining(hostId.toString()) + .hasMessageContaining("exclusively through proxies"); + } + + @Test + public void should_fall_back_to_broadcast_when_exclusive_proxy_disabled() + throws UnknownHostException { UUID hostId = UUID.randomUUID(); - InetSocketAddress fallbackAddr = new InetSocketAddress("10.0.0.1", 9042); + InetSocketAddress fallbackAddress = new InetSocketAddress("10.0.0.1", 9042); when(topologyMonitor.resolve(hostId)).thenReturn(null); - when(fallbackEndPoint.resolve()).thenReturn(fallbackAddr); + when(fallbackEndPoint.resolve()).thenReturn(fallbackAddress); ClientRoutesEndPoint ep = - new ClientRoutesEndPoint(topologyMonitor, hostId, null, fallbackEndPoint); + new ClientRoutesEndPoint(topologyMonitor, hostId, null, fallbackEndPoint, false); - assertThat(ep.resolve()).isEqualTo(fallbackAddr); + assertThat(ep.resolve()).isEqualTo(fallbackAddress); } @Test @@ -70,8 +85,7 @@ public void should_wrap_io_exceptions_in_unchecked_io_exception() throws Unknown UUID hostId = UUID.randomUUID(); when(topologyMonitor.resolve(hostId)).thenThrow(new UnknownHostException("no-such-host")); - ClientRoutesEndPoint ep = - new ClientRoutesEndPoint(topologyMonitor, hostId, null, fallbackEndPoint); + ClientRoutesEndPoint ep = new ClientRoutesEndPoint(topologyMonitor, hostId, null, null, true); assertThatThrownBy(ep::resolve) .isInstanceOf(UncheckedIOException.class) @@ -85,8 +99,7 @@ public void should_reflect_route_changes_on_subsequent_resolve() throws UnknownH InetSocketAddress addr2 = new InetSocketAddress("10.0.0.1", 9043); when(topologyMonitor.resolve(hostId)).thenReturn(addr1); - ClientRoutesEndPoint ep = - new ClientRoutesEndPoint(topologyMonitor, hostId, null, fallbackEndPoint); + ClientRoutesEndPoint ep = new ClientRoutesEndPoint(topologyMonitor, hostId, null, null, true); assertThat(ep.resolve()).isEqualTo(addr1); @@ -101,10 +114,8 @@ public void should_reflect_route_changes_on_subsequent_resolve() throws UnknownH @Test public void should_be_equal_when_same_host_id() { UUID hostId = UUID.randomUUID(); - ClientRoutesEndPoint ep1 = - new ClientRoutesEndPoint(topologyMonitor, hostId, null, fallbackEndPoint); - ClientRoutesEndPoint ep2 = - new ClientRoutesEndPoint(topologyMonitor, hostId, null, fallbackEndPoint); + ClientRoutesEndPoint ep1 = new ClientRoutesEndPoint(topologyMonitor, hostId, null, null, true); + ClientRoutesEndPoint ep2 = new ClientRoutesEndPoint(topologyMonitor, hostId, null, null, true); assertThat(ep1).isEqualTo(ep2); assertThat(ep1.hashCode()).isEqualTo(ep2.hashCode()); @@ -113,9 +124,9 @@ public void should_be_equal_when_same_host_id() { @Test public void should_not_be_equal_when_different_host_id() { ClientRoutesEndPoint ep1 = - new ClientRoutesEndPoint(topologyMonitor, UUID.randomUUID(), null, fallbackEndPoint); + new ClientRoutesEndPoint(topologyMonitor, UUID.randomUUID(), null, null, true); ClientRoutesEndPoint ep2 = - new ClientRoutesEndPoint(topologyMonitor, UUID.randomUUID(), null, fallbackEndPoint); + new ClientRoutesEndPoint(topologyMonitor, UUID.randomUUID(), null, null, true); assertThat(ep1).isNotEqualTo(ep2); } @@ -123,8 +134,7 @@ public void should_not_be_equal_when_different_host_id() { @Test public void should_not_be_equal_to_non_client_routes_endpoint() { UUID hostId = UUID.randomUUID(); - ClientRoutesEndPoint ep = - new ClientRoutesEndPoint(topologyMonitor, hostId, null, fallbackEndPoint); + ClientRoutesEndPoint ep = new ClientRoutesEndPoint(topologyMonitor, hostId, null, null, true); assertThat(ep).isNotEqualTo("not an endpoint"); assertThat(ep).isNotEqualTo(null); @@ -135,8 +145,7 @@ public void should_not_be_equal_to_non_client_routes_endpoint() { @Test public void should_use_host_id_as_metric_prefix_when_address_is_null() { UUID hostId = UUID.randomUUID(); - ClientRoutesEndPoint ep = - new ClientRoutesEndPoint(topologyMonitor, hostId, null, fallbackEndPoint); + ClientRoutesEndPoint ep = new ClientRoutesEndPoint(topologyMonitor, hostId, null, null, true); assertThat(ep.asMetricPrefix()).isEqualTo(hostId.toString()); } @@ -145,8 +154,7 @@ public void should_use_host_id_as_metric_prefix_when_address_is_null() { public void should_format_ipv4_metric_prefix() throws Exception { UUID hostId = UUID.randomUUID(); InetAddress ipv4 = InetAddress.getByAddress(new byte[] {10, 0, 0, 1}); - ClientRoutesEndPoint ep = - new ClientRoutesEndPoint(topologyMonitor, hostId, ipv4, fallbackEndPoint); + ClientRoutesEndPoint ep = new ClientRoutesEndPoint(topologyMonitor, hostId, ipv4, null, true); assertThat(ep.asMetricPrefix()).isEqualTo("10_0_0_1_" + hostId); } @@ -156,8 +164,7 @@ public void should_format_ipv6_metric_prefix() throws Exception { UUID hostId = UUID.randomUUID(); InetAddress ipv6 = InetAddress.getByAddress(new byte[] {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}); - ClientRoutesEndPoint ep = - new ClientRoutesEndPoint(topologyMonitor, hostId, ipv6, fallbackEndPoint); + ClientRoutesEndPoint ep = new ClientRoutesEndPoint(topologyMonitor, hostId, ipv6, null, true); // IPv6 keeps colons (consistent with DefaultEndPoint), dots replaced by underscores assertThat(ep.asMetricPrefix()).isEqualTo("0:0:0:0:0:0:0:1_" + hostId); @@ -168,8 +175,7 @@ public void should_format_ipv6_metric_prefix() throws Exception { @Test public void should_return_host_id_as_string() { UUID hostId = UUID.randomUUID(); - ClientRoutesEndPoint ep = - new ClientRoutesEndPoint(topologyMonitor, hostId, null, fallbackEndPoint); + ClientRoutesEndPoint ep = new ClientRoutesEndPoint(topologyMonitor, hostId, null, null, true); assertThat(ep.toString()).isEqualTo("ClientRoutesEndPoint(" + hostId + ")"); } diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesTopologyMonitorTest.java b/core/src/test/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesTopologyMonitorTest.java index a1ba4617ef5..9586ca52420 100644 --- a/core/src/test/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesTopologyMonitorTest.java +++ b/core/src/test/java/com/datastax/oss/driver/internal/core/metadata/ClientRoutesTopologyMonitorTest.java @@ -38,7 +38,6 @@ import com.datastax.oss.driver.shaded.guava.common.collect.ImmutableMap; import edu.umd.cs.findbugs.annotations.NonNull; import java.net.InetSocketAddress; -import java.net.SocketAddress; import java.net.UnknownHostException; import java.time.Duration; import java.util.ArrayList; @@ -143,6 +142,8 @@ public void setup() { when(defaultProfile.getDuration(DefaultDriverOption.CONTROL_CONNECTION_TIMEOUT)) .thenReturn(Duration.ofSeconds(5)); when(defaultProfile.getBoolean(DefaultDriverOption.RECONNECT_ON_INIT)).thenReturn(false); + when(defaultProfile.getBoolean(DefaultDriverOption.CLIENT_ROUTES_EXCLUSIVE_PROXY)) + .thenReturn(false); when(context.getSslEngineFactory()).thenReturn(Optional.empty()); ClientRoutesConfig config = ClientRoutesConfig.builder() @@ -163,6 +164,21 @@ private void initHandler() { handler.init(); } + /** + * Creates a fresh handler with the given {@code exclusiveProxy} setting, sharing all other + * context stubs from {@link #setup()}. + */ + private TestableClientRoutesTopologyMonitor createHandlerWithExclusiveProxy( + boolean exclusiveProxy) { + when(defaultProfile.getBoolean(DefaultDriverOption.CLIENT_ROUTES_EXCLUSIVE_PROXY)) + .thenReturn(exclusiveProxy); + ClientRoutesConfig config = + ClientRoutesConfig.builder() + .addEndpoint(new ClientRouteProxy(connectionId, "host1")) + .build(); + return new TestableClientRoutesTopologyMonitor(context, config); + } + // ---- resolve() ------------------------------------------------------- @Test @@ -1041,19 +1057,16 @@ public void should_not_propagate_exception_when_query_fails() throws Exception { // ---- buildNodeEndPoint fallback ----------------------------------------- @Test - public void should_build_default_endpoint_when_host_id_is_null() { - // row.getUuid("host_id") returns null, triggering the hostId == null - // branch in buildNodeEndPoint which delegates to super.buildNodeEndPoint(). + public void should_throw_when_host_id_is_null() { + // row.getUuid("host_id") returns null — with client routes configured, the driver + // must not fall back to direct broadcast address, so buildNodeEndPoint throws. AdminRow row = Mockito.mock(AdminRow.class); when(row.getUuid("host_id")).thenReturn(null); - when(row.contains("peer")).thenReturn(false); // local-node row → super returns localEndPoint EndPoint localEndPoint = Mockito.mock(EndPoint.class); - EndPoint result = handler.buildNodeEndPoint(row, null, localEndPoint); - - // hostId == null branch → super.buildNodeEndPoint() is called → returns localEndPoint - assertThat(result).isNotInstanceOf(ClientRoutesEndPoint.class); - assertThat(result).isSameAs(localEndPoint); + assertThatThrownBy(() -> handler.buildNodeEndPoint(row, null, localEndPoint)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("host_id is null"); } @Test @@ -1065,7 +1078,6 @@ public void should_build_client_routes_endpoint_when_host_id_non_null() { UUID hostId = UUID.randomUUID(); AdminRow row = Mockito.mock(AdminRow.class); when(row.getUuid("host_id")).thenReturn(hostId); - when(row.contains("peer")).thenReturn(false); EndPoint localEndPoint = Mockito.mock(EndPoint.class); EndPoint result = handler.buildNodeEndPoint(row, null, localEndPoint); @@ -1138,24 +1150,43 @@ hostId1, new ClientRouteRecord(hostId1, "127.0.0.1", 9042), } @Test - public void should_resolve_to_fallback_when_no_route_for_host_id() { - // Simulates a node that is not accessed via PrivateLink (no route in cache for its host_id). - // resolve() must return the regular endpoint address (the fallback), not throw. + public void should_throw_when_no_route_for_host_id_and_exclusive_proxy_enabled() { + // With exclusive-proxy=true: resolve() must throw instead of falling back to broadcast + // address, to prevent the driver from bypassing proxy infrastructure. + TestableClientRoutesTopologyMonitor exclusiveHandler = createHandlerWithExclusiveProxy(true); + UUID hostId = UUID.randomUUID(); + AdminRow row = Mockito.mock(AdminRow.class); + when(row.getUuid("host_id")).thenReturn(hostId); + EndPoint localEndPoint = Mockito.mock(EndPoint.class); + + EndPoint endpoint = exclusiveHandler.buildNodeEndPoint(row, null, localEndPoint); + assertThat(endpoint).isInstanceOf(ClientRoutesEndPoint.class); + + // Cache is empty (no route for this host_id) → must throw, not fall back + assertThatThrownBy(endpoint::resolve) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("No client route entry found") + .hasMessageContaining(hostId.toString()); + } + + @Test + public void should_fall_back_to_broadcast_when_no_route_and_exclusive_proxy_disabled() + throws Exception { + // With exclusive-proxy=false (default): resolve() delegates to the fallback endpoint when + // no route exists, supporting mixed proxy/direct topologies. UUID hostId = UUID.randomUUID(); - InetSocketAddress fallbackAddress = new InetSocketAddress("127.0.0.99", 9999); AdminRow row = Mockito.mock(AdminRow.class); when(row.getUuid("host_id")).thenReturn(hostId); - when(row.contains("peer")).thenReturn(false); EndPoint localEndPoint = Mockito.mock(EndPoint.class); - when(localEndPoint.resolve()).thenReturn(fallbackAddress); + InetSocketAddress directAddress = new InetSocketAddress("10.0.0.1", 9042); + when(localEndPoint.resolve()).thenReturn(directAddress); + // handler uses exclusiveProxy=false (set in setup()) EndPoint endpoint = handler.buildNodeEndPoint(row, null, localEndPoint); assertThat(endpoint).isInstanceOf(ClientRoutesEndPoint.class); - // Cache is empty (no PrivateLink route) → resolves to the regular endpoint address - SocketAddress resolved = ((ClientRoutesEndPoint) endpoint).resolve(); - assertThat(resolved).isEqualTo(fallbackAddress); - Mockito.verify(localEndPoint).resolve(); + // Cache is empty → falls back to the direct broadcast address + assertThat(endpoint.resolve()).isEqualTo(directAddress); } // ---- savePort() -------------------------------------------------------- From d341fef03aff267db32bf77f2aa6a9b9061a48db Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Wed, 29 Apr 2026 23:52:30 +0200 Subject: [PATCH 2/2] client routes: add exclusive-proxy config flag for strict proxy enforcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces advanced.client-routes.exclusive-proxy (default: false). When false (default), the driver falls back to the node broadcast address when no client-route entry exists — preserving backward compatibility with mixed proxy/direct topologies. When true, no fallback is attempted: the node stays DOWN until a CLIENT_ROUTES_CHANGE event publishes the route. This prevents silent bypass of the proxy infrastructure during node additions. --- .../api/core/config/DefaultDriverOption.java | 17 ++++++++++++++++- .../oss/driver/api/core/config/OptionsMap.java | 1 + .../api/core/config/TypedDriverOption.java | 9 +++++++++ core/src/main/resources/reference.conf | 13 +++++++++++++ 4 files changed, 39 insertions(+), 1 deletion(-) 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 c5a482c5d50..1755afaa0a1 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 @@ -1119,7 +1119,22 @@ public enum DefaultDriverOption implements DriverOption { * *

Value type: boolean */ - CLIENT_ROUTES_SHARD_AWARENESS_ENABLED("advanced.client-routes.shard-awarness-enabled"); + CLIENT_ROUTES_SHARD_AWARENESS_ENABLED("advanced.client-routes.shard-awarness-enabled"), + + /** + * Whether the driver connects exclusively through proxies when client routes are configured. + * + *

When {@code true}, any node whose {@code host_id} does not appear in the {@code + * system.client_routes} table is treated as unreachable; the driver will never fall back to the + * node's broadcast address. The node stays DOWN and the reconnection loop retries until a {@code + * CLIENT_ROUTES_CHANGE} event populates the route. + * + *

When {@code false} (the default), nodes that have no route entry are contacted directly + * using their broadcast address, preserving backward-compatible mixed proxy/direct topologies. + * + *

Value type: boolean + */ + CLIENT_ROUTES_EXCLUSIVE_PROXY("advanced.client-routes.exclusive-proxy"); private final String path; diff --git a/core/src/main/java/com/datastax/oss/driver/api/core/config/OptionsMap.java b/core/src/main/java/com/datastax/oss/driver/api/core/config/OptionsMap.java index 60190fc1cce..6857a6c7a06 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 @@ -400,6 +400,7 @@ protected static void fillWithDriverDefaults(OptionsMap map) { // values) with no sensible scalar default, analogous to how CONFIG_RELOAD_INTERVAL is omitted. map.put(TypedDriverOption.CLIENT_ROUTES_NATIVE_TRANSPORT_PORT, 9042); map.put(TypedDriverOption.CLIENT_ROUTES_SHARD_AWARENESS_ENABLED, false); + map.put(TypedDriverOption.CLIENT_ROUTES_EXCLUSIVE_PROXY, false); } @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 db5edb5b947..de2039e590c 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 @@ -954,6 +954,15 @@ public String toString() { new TypedDriverOption<>( DefaultDriverOption.CLIENT_ROUTES_SHARD_AWARENESS_ENABLED, GenericType.BOOLEAN); + /** + * Whether the driver connects exclusively through proxies when client routes are configured. When + * {@code false} (default), nodes without a route entry are contacted directly via their broadcast + * address. + */ + public static final TypedDriverOption CLIENT_ROUTES_EXCLUSIVE_PROXY = + new TypedDriverOption<>( + DefaultDriverOption.CLIENT_ROUTES_EXCLUSIVE_PROXY, GenericType.BOOLEAN); + private static Iterable> introspectBuiltInValues() { try { ImmutableList.Builder> result = ImmutableList.builder(); diff --git a/core/src/main/resources/reference.conf b/core/src/main/resources/reference.conf index 92c5b98118f..7dc4da00b8a 100644 --- a/core/src/main/resources/reference.conf +++ b/core/src/main/resources/reference.conf @@ -1173,6 +1173,19 @@ datastax-java-driver { # Default: false shard-awarness-enabled = false + # When true, the driver connects exclusively through proxies and will never fall back to a + # node's broadcast address when no route entry exists in system.client_routes. Any node + # without a route is treated as unreachable; it goes DOWN and the reconnection loop retries + # until a CLIENT_ROUTES_CHANGE event populates the route. + # + # When false (default), nodes that have no route entry are reached directly using their + # broadcast address, preserving backward-compatible mixed proxy/direct topologies where some + # nodes are behind the private endpoint and others are not. + # + # Required: no + # Default: false + exclusive-proxy = false + } # Whether to resolve the addresses passed to `basic.contact-points`.