diff --git a/core/pom.xml b/core/pom.xml index 8342b8b6df5..0efeb510b1c 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -160,6 +160,18 @@ mockito-core test + + com.networknt + json-schema-validator + test + + + + me.fabriciorby + maven-surefire-junit5-tree-reporter + + + io.reactivex.rxjava2 rxjava 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 3a6e4ed69bb..6578da641d1 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 @@ -1175,16 +1175,21 @@ public enum DefaultDriverOption implements DriverOption { */ ADDRESS_TRANSLATOR_RESOLVE_ADDRESSES("advanced.address-translator.resolve-addresses"), /** - * Whether the driver reports its effective configuration to ScyllaDB at connection time. - * - *

When {@code true}, the driver adds two entries to the CQL {@code STARTUP} options, which - * ScyllaDB stores in {@code system.clients.client_options} so operators can inspect driver - * settings while investigating incidents: a {@code SESSION_ID} on every connection (so the server - * can group a session's connections) and a compact JSON payload under the {@code DRIVER_CONFIG} - * key on the control connection only. At this stage the {@code DRIVER_CONFIG} payload carries - * only schema-version metadata ({"version":1}); reporting of the effective - * configuration fields is planned for a later stage. When {@code false}, neither entry is sent - * and there is no change on the wire. + * Whether the driver reports its effective configuration to the cluster at connection time. + * Defaults to {@code true}. + * + *

When {@code true}, the control connection adds a compact JSON payload under the {@code + * DRIVER_CONFIG} key to its CQL {@code STARTUP} options, which the server stores in its + * client-connection system table ({@code system.clients} on ScyllaDB, {@code + * system_views.clients} on Cassandra 4.1+) so operators can inspect driver settings while + * investigating incidents. It describes the effective configuration of the driver's default + * execution profile (connection/socket settings, timeouts, retry/reconnection/ + * speculative-execution/load-balancing policies, connection pooling, query defaults, and TLS). + * Only the control connection sends it, since it describes the whole session. When {@code false}, + * {@code DRIVER_CONFIG} is not sent. + * + *

Reporting is best-effort: if the report cannot be built, or would exceed 32 KiB, it is + * skipped (with a warning) rather than allowed to interfere with connecting. * *

Value type: boolean */ 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 1d906caa985..c1a428b3524 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,7 +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.DRIVER_CONFIG_REPORTING_ENABLED, false); + map.put(TypedDriverOption.DRIVER_CONFIG_REPORTING_ENABLED, true); } @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 e412b99b404..af93e734ef1 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 @@ -976,7 +976,7 @@ public String toString() { new TypedDriverOption<>( DefaultDriverOption.CLIENT_ROUTES_SHARD_AWARENESS_ENABLED, GenericType.BOOLEAN); - /** Whether the driver reports its configuration to ScyllaDB at connection time. */ + /** Whether the driver reports its configuration to the cluster at connection time. */ public static final TypedDriverOption DRIVER_CONFIG_REPORTING_ENABLED = new TypedDriverOption<>( DefaultDriverOption.DRIVER_CONFIG_REPORTING_ENABLED, GenericType.BOOLEAN); diff --git a/core/src/main/java/com/datastax/oss/driver/api/core/ssl/ProgrammaticSslEngineFactory.java b/core/src/main/java/com/datastax/oss/driver/api/core/ssl/ProgrammaticSslEngineFactory.java index d65eaa864aa..6eb8d52fcb2 100644 --- a/core/src/main/java/com/datastax/oss/driver/api/core/ssl/ProgrammaticSslEngineFactory.java +++ b/core/src/main/java/com/datastax/oss/driver/api/core/ssl/ProgrammaticSslEngineFactory.java @@ -133,6 +133,11 @@ public SSLEngine newSslEngine(@NonNull EndPoint remoteEndpoint) { return engine; } + @Override + public boolean isHostnameValidationRequired() { + return requireHostnameValidation; + } + @Override public void close() { // nothing to do diff --git a/core/src/main/java/com/datastax/oss/driver/api/core/ssl/SslEngineFactory.java b/core/src/main/java/com/datastax/oss/driver/api/core/ssl/SslEngineFactory.java index db4f18a97b9..749c60ef143 100644 --- a/core/src/main/java/com/datastax/oss/driver/api/core/ssl/SslEngineFactory.java +++ b/core/src/main/java/com/datastax/oss/driver/api/core/ssl/SslEngineFactory.java @@ -37,4 +37,22 @@ public interface SslEngineFactory extends AutoCloseable { */ @NonNull SSLEngine newSslEngine(@NonNull EndPoint remoteEndpoint); + + /** + * Whether this factory validates the server certificate against the node's host name. + * + *

This is a diagnostic accessor (reported in the driver-configuration blob sent to the server + * at connection time); it does not affect how {@link #newSslEngine} behaves. + * + *

The driver's built-in factories override this to return their real value. It is a {@code + * default} method so that existing implementations keep compiling, and the default returns {@code + * false} because the driver cannot assume an arbitrary custom factory performs host name + * validation, and must not over-report a security control that may not actually be active. Custom + * factories that do validate should override this to report accurately. + * + * @since 4.19.2.1 + */ + default boolean isHostnameValidationRequired() { + return false; + } } diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/channel/DriverChannel.java b/core/src/main/java/com/datastax/oss/driver/internal/core/channel/DriverChannel.java index d4d1bb600c7..5e6fac7f9e7 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/channel/DriverChannel.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/channel/DriverChannel.java @@ -170,8 +170,7 @@ public int getShardId() { } public ShardingInfo getShardingInfo() { - ConnectionShardingInfo info = getSupportedFeatures().getShardingInfo(); - return info != null ? info.shardingInfo : null; + return getSupportedFeatures().getNodeShardingInfo(); } public LwtInfo getLwtInfo() { 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 cf782096f18..4296ff17f10 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 @@ -191,14 +191,25 @@ Message getRequest() { return request = Options.INSTANCE; case STARTUP: Map startupOptions = new HashMap<>(context.getStartupOptions()); - if (featureStore != null) { - featureStore.populateStartupOptions(startupOptions); + featureStore.populateStartupOptions(startupOptions); + // The DRIVER_CONFIG blob describes the whole session, so only the control connection + // carries it (options.reportConfig); the other connections are correlated to it by the + // SESSION_ID that every connection already carries from context.getStartupOptions(). + // No-op when driver config reporting is disabled. + if (options.reportConfig) { + // Sharding info is both of the backend-conditional signals the report needs, in + // one value: non-null is the driver's own proxy check for "this is ScyllaDB" + // (also used, independently, by + // CassandraSchemaQueries.shouldApplyUsingTimeout()), which gates ScyllaDB-only + // server-side behavior such as the USING TIMEOUT clause on schema queries; and + // its shard count is what ChannelPool keys its pools by, so the reported pool + // sizing can be per-shard rather than per-host. It is only populated once the + // OPTIONS/SUPPORTED handshake has run, which ChannelFactory always requests. + context + .getDriverConfigReporter() + .populateControlConnectionOptions( + startupOptions, featureStore.getNodeShardingInfo()); } - // Adds SESSION_ID on every connection and DRIVER_CONFIG on the control connection - // (options.reportConfig); no-op when driver config reporting is disabled. - context - .getDriverConfigReporter() - .populateStartupOptions(startupOptions, options.reportConfig); return request = new Startup(startupOptions); case GET_CLUSTER_NAME: return request = CLUSTER_NAME_QUERY; diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/context/DefaultDriverConfigReporter.java b/core/src/main/java/com/datastax/oss/driver/internal/core/context/DefaultDriverConfigReporter.java index 2cd1b5a8560..afdf47b5b24 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/context/DefaultDriverConfigReporter.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/context/DefaultDriverConfigReporter.java @@ -17,14 +17,36 @@ */ package com.datastax.oss.driver.internal.core.context; +import com.datastax.dse.driver.internal.core.loadbalancing.DseDcInferringLoadBalancingPolicy; +import com.datastax.dse.driver.internal.core.loadbalancing.DseLoadBalancingPolicy; import com.datastax.oss.driver.api.core.config.DefaultDriverOption; import com.datastax.oss.driver.api.core.config.DriverExecutionProfile; -import com.datastax.oss.driver.api.core.uuid.Uuids; +import com.datastax.oss.driver.api.core.connection.ReconnectionPolicy; +import com.datastax.oss.driver.api.core.loadbalancing.LoadBalancingPolicy; +import com.datastax.oss.driver.api.core.metadata.NodeShardingInfo; +import com.datastax.oss.driver.api.core.retry.RetryPolicy; +import com.datastax.oss.driver.api.core.specex.SpeculativeExecutionPolicy; +import com.datastax.oss.driver.api.core.ssl.SslEngineFactory; +import com.datastax.oss.driver.internal.core.connection.ConstantReconnectionPolicy; +import com.datastax.oss.driver.internal.core.connection.ExponentialReconnectionPolicy; +import com.datastax.oss.driver.internal.core.loadbalancing.BasicLoadBalancingPolicy; +import com.datastax.oss.driver.internal.core.loadbalancing.DcInferringLoadBalancingPolicy; +import com.datastax.oss.driver.internal.core.loadbalancing.DefaultLoadBalancingPolicy; +import com.datastax.oss.driver.internal.core.pool.ChannelPool; +import com.datastax.oss.driver.internal.core.retry.ConsistencyDowngradingRetryPolicy; +import com.datastax.oss.driver.internal.core.retry.DefaultRetryPolicy; +import com.datastax.oss.driver.internal.core.specex.ConstantSpeculativeExecutionPolicy; +import com.datastax.oss.driver.internal.core.specex.NoSpeculativeExecutionPolicy; +import com.datastax.oss.driver.internal.core.ssl.JdkSslHandlerFactory; +import com.datastax.oss.driver.internal.core.ssl.SslHandlerFactory; +import com.datastax.oss.driver.internal.core.time.ServerSideTimestampGenerator; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import edu.umd.cs.findbugs.annotations.Nullable; +import java.nio.charset.StandardCharsets; import java.util.Map; -import java.util.UUID; +import java.util.Optional; import net.jcip.annotations.ThreadSafe; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,8 +55,57 @@ * Default {@link DriverConfigReporter}: serializes the driver configuration to the cross-driver * {@code DRIVER_CONFIG} JSON shape and adds it to the control connection's {@code STARTUP} options. * - *

The blob is (re)built on demand every time the control connection initializes, so it always - * reflects the current (possibly reloaded) configuration without any caching. + *

The blob is (re)built on demand every time the control connection initializes, so every group + * here always reads the current (possibly reloaded) {@link DriverExecutionProfile} at report time — + * never a cached field off a policy object. The running policy instances themselves may + * not be so current, though: some (e.g. {@code ExponentialReconnectionPolicy}'s backoff delays, or + * {@code DefaultLoadBalancingPolicy}'s slow-avoidance flag behind {@code latency-awareness}) cache + * the config value they were constructed with and don't re-read it on a live reload. So immediately + * after a reload, the report can show a value the already-running policy doesn't reflect yet — + * until that policy is rebuilt (e.g. a new instance takes over on the next reconnect). + * + *

Follows the schema's omission principle throughout: a key the Java driver has no equivalent + * for is left out of the JSON entirely rather than reported as {@code null}. The same applies where + * a configured value falls outside what the schema can express but the key is optional: a + * disabled timeout, a disabled {@code SO_LINGER}, an unbounded page size and the like are omitted + * rather than emitted as a number the schema rejects. + * + *

Known limitation: that omission is not always available. Several schema fields are + * required and constrained to a positive integer, yet {@code 0} is a legitimate value for + * the driver option behind them. Sometimes it means "disabled" — {@code + * query-defaults.request.timeout-ms} and {@code connection.connect.timeout-ms} both switch their + * timeout off at 0, and {@code connection-pool.desired-connections-count} of 0 is a pool that opens + * no connections. Sometimes it just means "no delay": {@code ConstantReconnectionPolicy} accepts a + * zero {@code advanced.reconnection-policy.base-delay} (reconnect immediately, no backoff) and + * {@code ConstantSpeculativeExecutionPolicy} a zero {@code + * advanced.speculative-execution-policy.delay} (fire every speculative execution at once) — each + * rejecting only negative values, unlike {@code ExponentialReconnectionPolicy}, which requires a + * strictly positive base delay. Either way the reported field lands one below the schema's minimum, + * and is reported as-is. The reporter deliberately neither fabricates an in-range value — + * which would misreport a setting an operator may have chosen on purpose — nor drops the whole + * report over one field, so such a document is accurate but fails schema validation. Tracked as a + * cross-driver schema gap: the fix is to let these fields express the value, the way {@code + * control-plane.schema-agreement.timeout-ms} already admits 0. + * + *

One consequence of measuring these limits on the emitted milliseconds: a sub-millisecond + * duration rounds to {@code 0} and is therefore treated as disabled, so an optional field + * configured to, say, 500 microseconds is omitted rather than reported as 1. + * + *

Known limitation: the report always describes {@link + * com.datastax.oss.driver.api.core.config.DriverExecutionProfile#DEFAULT_NAME the default execution + * profile}, not whichever profile a given request actually runs with. A session that relies on + * named execution profiles for some of its traffic will have that traffic's real settings + * (consistency level, timeouts, retry policy, ...) differ from what {@code DRIVER_CONFIG} reports. + * Reporting per-profile configuration would need a schema shape for multiple profiles, which the + * cross-driver schema doesn't define; this is a known gap, not an oversight. + * + *

Known limitation: the {@code connection-pool} group is reported from the control + * connection's node shard count and describes the whole session, so on a cluster whose nodes + * have differing shard counts the per-node pools will not all match what is reported. + * + *

Thread safety: this class is safe to use as shipped, and holds no mutable state. Note + * that {@code buildJson()} runs on every control-connection (re)initialization, and may be called + * concurrently with a reconnect racing a fresh session start. */ @ThreadSafe public class DefaultDriverConfigReporter implements DriverConfigReporter { @@ -44,73 +115,110 @@ public class DefaultDriverConfigReporter implements DriverConfigReporter { /** STARTUP option key under which the config JSON is sent. */ public static final String DRIVER_CONFIG_KEY = "DRIVER_CONFIG"; - /** STARTUP option key under which the per-session identifier is sent. */ - public static final String SESSION_ID_KEY = "SESSION_ID"; - /** * Major schema version. Adding keys is backward-compatible and does not bump this; only * changing/removing the meaning of an existing key does. */ static final int SCHEMA_VERSION = 1; + /** + * Upper bound on the UTF-8 size of the {@code DRIVER_CONFIG} value; a longer report is dropped + * rather than sent. + * + *

{@code STARTUP} options are serialized with {@code PrimitiveCodec#writeString(String, + * Object)}, which writes a 16-bit length prefix with no bounds check (see {@code + * ByteBufPrimitiveCodec}): a value longer than 65535 bytes would silently truncate that prefix + * modulo 65536 while still appending the whole body, corrupting the frame and failing the + * handshake. Most of this report is fixed-shape, but some of it is user-supplied and unbounded — + * datacenter and rack names, consistency levels, and the class names of custom policy objects — + * so enforcing a limit here keeps "reporting must never prevent a connection from being + * established" a property of this class rather than of the user's configuration. 32KiB is + * generous for a configuration report, and is the same limit the other ScyllaDB drivers apply. + */ + static final int MAX_DRIVER_CONFIG_LENGTH = 32 * 1024; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); protected final InternalDriverContext context; - // Dedicated, driver-generated identifier for this session. Not derived from the (user-settable, - // Insights-oriented) CLIENT_ID, so that it is guaranteed unique per session as the grouping key - // requires. The reporter is a per-session singleton (built once via LazyReference), so this value - // is stable and shared across all of the session's connections. - private final UUID sessionId = Uuids.random(); - public DefaultDriverConfigReporter(InternalDriverContext context) { this.context = context; } @Override - public void populateStartupOptions( - Map startupOptions, boolean reportDriverConfig) { + public void populateControlConnectionOptions( + Map startupOptions, @Nullable NodeShardingInfo shardingInfo) { // Configuration reporting is a best-effort diagnostic aid: it runs on the connection // initialization path, so any failure here (a bad config read, a misbehaving policy while // introspecting, a serialization error) must be swallowed rather than allowed to break the - // connection — which would prevent the session from establishing or reconnecting. + // connection — which would prevent the session from establishing or reconnecting. Also catches + // InternalError specifically: customPolicy() calls getClass().getSimpleName() on arbitrary + // user-supplied policy objects, which has a documented JDK edge case throwing InternalError for + // certain synthetic classes. Deliberately not a bare `Error` — that would also swallow + // OutOfMemoryError/StackOverflowError, masking a real JVM-level failure instead of this one + // narrow, documented case. try { if (!isEnabled()) { return; } - // SESSION_ID on every connection so the server can group a session's connections. - startupOptions.put(SESSION_ID_KEY, sessionId.toString()); - // DRIVER_CONFIG blob only on the control connection. - if (reportDriverConfig) { - String json = buildJson(); - if (json != null) { - startupOptions.put(DRIVER_CONFIG_KEY, json); - } + String json = buildJson(shardingInfo); + if (json == null) { + return; + } + // Measured on the encoded bytes, since that is what the length prefix on the wire counts. + int length = json.getBytes(StandardCharsets.UTF_8).length; + if (length > MAX_DRIVER_CONFIG_LENGTH) { + LOG.warn( + "The driver configuration report is {} bytes long, which exceeds the {} byte limit; " + + "skipping DRIVER_CONFIG", + length, + MAX_DRIVER_CONFIG_LENGTH); + return; } - } catch (RuntimeException e) { - LOG.warn( - "Error while building the driver configuration report; skipping driver config reporting", - e); + startupOptions.put(DRIVER_CONFIG_KEY, json); + } catch (InternalError | RuntimeException e) { + LOG.warn("Error while building the driver configuration report; skipping DRIVER_CONFIG", e); } } + // Read on every control-connection initialization rather than cached, so that a configuration + // reload takes effect on the next (re)connect. The fallback mirrors the reference.conf default, + // so that a configuration omitting the option behaves like the shipped one. private boolean isEnabled() { return context .getConfig() .getDefaultProfile() - .getBoolean(DefaultDriverOption.DRIVER_CONFIG_REPORTING_ENABLED, false); + .getBoolean(DefaultDriverOption.DRIVER_CONFIG_REPORTING_ENABLED, true); } /** * Builds the compact, single-line JSON configuration report. * - *

Stage 1 emits only the schema {@code version}; the individual configuration groups are - * populated in {@link #populateConfig(ObjectNode, DriverExecutionProfile)} in a later stage. + *

Relies on the policy/generator {@code LazyReference}s (reconnection, retry, speculative + * execution, load balancing, timestamp generator, SSL engine and handler factories) already being + * resolved by the time this runs, which holds today because session bootstrap eagerly forces them + * before {@code ProtocolInitHandler} sends the first {@code STARTUP}. That ordering isn't + * enforced by this class; a future change to session bootstrap that defers one of those + * references could make this the first caller to resolve it, from a Netty event-loop thread + * mid-{@code STARTUP} build. + * + *

The SSL engine factory is a near miss: session bootstrap resolves it only as a side effect + * of {@code DefaultDriverContext.buildSslHandlerFactory()}, so an override of that method (the + * documented expert extension point) bypasses the eager resolution. {@link #tls()} reads the + * engine factory only once it has established that the handler factory in force is the driver's + * own {@code JdkSslHandlerFactory} — which the default context can only have produced by + * resolving the engine factory already. What is left is an override that returns a {@code + * JdkSslHandlerFactory} of its own: there, this class would be the first to resolve the + * configured engine factory, on a Netty event-loop thread, which for the built-in factory means + * reading keystore/truststore files. + * + * @param shardingInfo the control connection's sharding information, or {@code null} if the + * server advertised none; see {@link DriverConfigReporter#populateControlConnectionOptions}. */ - protected String buildJson() { + String buildJson(@Nullable NodeShardingInfo shardingInfo) { ObjectNode root = OBJECT_MAPPER.createObjectNode(); root.put("version", SCHEMA_VERSION); - populateConfig(root, context.getConfig().getDefaultProfile()); + populateConfig(root, context.getConfig().getDefaultProfile(), shardingInfo); try { return OBJECT_MAPPER.writeValueAsString(root); } catch (JsonProcessingException e) { @@ -121,11 +229,405 @@ protected String buildJson() { } /** - * Populates the configuration groups onto the report root. Placeholder in Stage 1; Stage 2 fills - * in {@code connection}, {@code socket}, the policy groups, {@code query_defaults}, {@code tls}, - * etc. + * Populates the configuration groups onto the report root, from the default execution profile + * plus the context's policies. Each group follows the cross-driver schema; a key the Java driver + * has no equivalent for is omitted rather than reported as {@code null}. */ - protected void populateConfig(ObjectNode root, DriverExecutionProfile config) { - // Stage 2: populate configuration groups from `config` and the context's policies. + private void populateConfig( + ObjectNode root, DriverExecutionProfile config, @Nullable NodeShardingInfo shardingInfo) { + root.set("connection", connection(config)); + root.set("socket", socket(config)); + // Non-null sharding info means the peer is ScyllaDB, which is all control-plane needs; the + // connection pool also needs the shard count, so it takes the whole thing. + root.set("control-plane", controlPlane(config, /* scyllaDb= */ shardingInfo != null)); + root.set("reconnection-policy", reconnectionPolicy(config)); + root.set("retry-policy", retryPolicy()); + // No null variant in the schema for this group: omitted entirely when there is none. + ObjectNode specExec = speculativeExecutionPolicy(config); + if (specExec != null) { + root.set("speculative-execution-policy", specExec); + } + root.set("load-balancing-policy", loadBalancingPolicy(config)); + root.set("node-location-preference", nodeLocationPreference(config)); + root.set("connection-pool", connectionPool(config, shardingInfo)); + root.set("query-defaults", queryDefaults(config)); + root.set("tls", tls()); + } + + private ObjectNode connection(DriverExecutionProfile config) { + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + ObjectNode connect = OBJECT_MAPPER.createObjectNode(); + connect.put( + "timeout-ms", + config.getDuration(DefaultDriverOption.CONNECTION_CONNECT_TIMEOUT).toMillis()); + n.set("connect", connect); + // The Java driver has no socket-level read/write timeouts, and connection.heartbeat is a + // reserved empty placeholder in this schema version (no slot for HEARTBEAT_INTERVAL/TIMEOUT + // yet) — all three are omitted entirely rather than reported as empty/null. + return n; + } + + private ObjectNode socket(DriverExecutionProfile config) { + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + n.put("tcp-no-delay", config.getBoolean(DefaultDriverOption.SOCKET_TCP_NODELAY, true)); + // keep-alive and reuse-address are unset by default; the driver leaves the socket option + // untouched, so the effective value is the JDK/OS default — false for both SO_KEEPALIVE and + // (client-socket) SO_REUSEADDR. The schema requires both keys, so they are always emitted. + n.put("keep-alive", config.getBoolean(DefaultDriverOption.SOCKET_KEEP_ALIVE, false)); + n.put("reuse-address", config.getBoolean(DefaultDriverOption.SOCKET_REUSE_ADDRESS, false)); + // A negative linger interval means SO_LINGER is disabled (reference.conf documents the + // sentinel), which the schema's non-negative interval-s cannot express — so the group is + // omitted in that case, the same way "page" is when paging is unbounded. Zero is a real + // value here (close immediately) and is reported. + if (config.isDefined(DefaultDriverOption.SOCKET_LINGER_INTERVAL)) { + int lingerInterval = config.getInt(DefaultDriverOption.SOCKET_LINGER_INTERVAL); + if (lingerInterval >= 0) { + ObjectNode linger = OBJECT_MAPPER.createObjectNode(); + linger.put("interval-s", lingerInterval); + n.set("linger", linger); + } + } + // Both buffer sizes are positive-only in the schema, and a non-positive one wouldn't survive + // Netty's own validation anyway; omit rather than emit a value the schema rejects. + if (config.isDefined(DefaultDriverOption.SOCKET_RECEIVE_BUFFER_SIZE)) { + int size = config.getInt(DefaultDriverOption.SOCKET_RECEIVE_BUFFER_SIZE); + if (size > 0) { + ObjectNode receiveBuffer = OBJECT_MAPPER.createObjectNode(); + receiveBuffer.put("size-bytes", size); + n.set("receive-buffer", receiveBuffer); + } + } + if (config.isDefined(DefaultDriverOption.SOCKET_SEND_BUFFER_SIZE)) { + int size = config.getInt(DefaultDriverOption.SOCKET_SEND_BUFFER_SIZE); + if (size > 0) { + ObjectNode sendBuffer = OBJECT_MAPPER.createObjectNode(); + sendBuffer.put("size-bytes", size); + n.set("send-buffer", sendBuffer); + } + } + return n; + } + + private ObjectNode controlPlane(DriverExecutionProfile config, boolean scyllaDb) { + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + + // These two are siblings under one "timeout" object but are NOT two views of the same timeout: + // client-side-ms is CONTROL_CONNECTION_TIMEOUT, which bounds topology and schema-agreement + // polling, while server-side-ms is METADATA_SCHEMA_REQUEST_TIMEOUT, which bounds schema queries + // (and is also their own client-side wait, see CassandraSchemaQueries). So no single query is + // subject to both numbers. That grouping is a characteristic of the cross-driver schema, not a + // choice made here — the mapping matches the schema's own per-driver table — and is recorded as + // an open item for the schema owner rather than worked around. + ObjectNode timeout = OBJECT_MAPPER.createObjectNode(); + // Both fields are optional and positive-only, and both options treat a non-positive value + // as "no timeout"; omit rather than report a 0 the schema rejects. Measured on the emitted + // milliseconds, not the Duration, since a sub-millisecond timeout is active but rounds to + // 0 here. + long clientSideMs = + config.getDuration(DefaultDriverOption.CONTROL_CONNECTION_TIMEOUT).toMillis(); + if (clientSideMs > 0) { + timeout.put("client-side-ms", clientSideMs); + } + if (scyllaDb) { + // ScyllaDB only: CassandraSchemaQueries adds a "USING TIMEOUT ms" clause (this same + // value) to every schema query it runs, making METADATA_SCHEMA_REQUEST_TIMEOUT a genuine + // server-side timeout on this backend. Genuine Cassandra never gets that clause, so the + // field is omitted there (not applicable). + long serverSideMs = + config.getDuration(DefaultDriverOption.METADATA_SCHEMA_REQUEST_TIMEOUT).toMillis(); + if (serverSideMs > 0) { + timeout.put("server-side-ms", serverSideMs); + } + } + ObjectNode systemQueries = OBJECT_MAPPER.createObjectNode(); + systemQueries.set("timeout", timeout); + n.set("system-queries", systemQueries); + + ObjectNode schemaAgreement = OBJECT_MAPPER.createObjectNode(); + // Required and non-negative in the schema, and 0 is meaningful (SchemaAgreementChecker skips + // the check entirely). A negative value behaves identically — the first pass is already past + // the deadline — so normalizing it to 0 is exact rather than invented, and keeps the required + // field in range. + schemaAgreement.put( + "timeout-ms", + Math.max( + 0L, + config + .getDuration(DefaultDriverOption.CONTROL_CONNECTION_AGREEMENT_TIMEOUT) + .toMillis())); + n.set("schema-agreement", schemaAgreement); + + return n; + } + + private ObjectNode reconnectionPolicy(DriverExecutionProfile config) { + ReconnectionPolicy policy = context.getReconnectionPolicy(); + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + // Exact-class checks, not instanceof: none of these built-ins are final, so a user subclass + // (e.g. to tweak one method) must fall through to the "custom" branch below rather than be + // misreported as the unmodified built-in. + if (policy.getClass() == ExponentialReconnectionPolicy.class) { + n.put("type", "exponential"); + n.put("base-ms", config.getDuration(DefaultDriverOption.RECONNECTION_BASE_DELAY).toMillis()); + n.put("max-ms", config.getDuration(DefaultDriverOption.RECONNECTION_MAX_DELAY).toMillis()); + // Java's built-in reconnection policies are unbounded: max-attempts is omitted. + } else if (policy.getClass() == ConstantReconnectionPolicy.class) { + n.put("type", "constant"); + n.put("delay-ms", config.getDuration(DefaultDriverOption.RECONNECTION_BASE_DELAY).toMillis()); + } else { + customPolicy(n, policy); + } + return n; + } + + private ObjectNode retryPolicy() { + RetryPolicy policy = context.getRetryPolicy(DriverExecutionProfile.DEFAULT_NAME); + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + // Exact-class check, not instanceof: DefaultRetryPolicy/ConsistencyDowngradingRetryPolicy are + // not final, so a user subclass must fall through to "custom" rather than be misreported. + if (policy.getClass() == DefaultRetryPolicy.class) { + n.put("type", "standard-error-aware"); + // No configurable backoff: omitted. + } else if (policy.getClass() == ConsistencyDowngradingRetryPolicy.class) { + n.put("type", "downgrading-consistency"); + // No configurable backoff: omitted. + } else { + customPolicy(n, policy); + } + return n; + } + + /** + * Returns {@code null} when there is no speculative execution policy to report, in which case the + * whole group is omitted from the report (the schema has no null variant for it). + */ + private ObjectNode speculativeExecutionPolicy(DriverExecutionProfile config) { + SpeculativeExecutionPolicy policy = + context.getSpeculativeExecutionPolicy(DriverExecutionProfile.DEFAULT_NAME); + // Exact-class checks, not instanceof: neither built-in is final. + if (policy.getClass() == NoSpeculativeExecutionPolicy.class) { + return null; + } + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + if (policy.getClass() == ConstantSpeculativeExecutionPolicy.class) { + n.put("type", "constant"); + n.put("max-executions", config.getInt(DefaultDriverOption.SPECULATIVE_EXECUTION_MAX)); + n.put( + "delay-ms", + config.getDuration(DefaultDriverOption.SPECULATIVE_EXECUTION_DELAY).toMillis()); + } else { + customPolicy(n, policy); + } + return n; + } + + private ObjectNode loadBalancingPolicy(DriverExecutionProfile config) { + LoadBalancingPolicy policy = + context.getLoadBalancingPolicy(DriverExecutionProfile.DEFAULT_NAME); + Class policyClass = policy.getClass(); + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + // DcInferringLoadBalancingPolicy extends DefaultLoadBalancingPolicy, overriding only how the + // local DC is discovered; DseLoadBalancingPolicy/DseDcInferringLoadBalancingPolicy are + // deprecated, behavior-identical aliases of the two ("equivalent to DefaultLoadBalancingPolicy, + // which should now be used instead" per their own javadoc). All three built-in policies are + // always token-aware and unconditionally shuffle replicas whenever more than one is available + // (neither has a config option to disable shuffling) and honor the same DC-failover option; + // only "type" and "latency-awareness" differ, so those two are resolved per class below and the + // shared fields are written once. Exact-class checks (not instanceof) so an actual user + // subclass of any of these still falls through to "custom" below. + boolean isDcInferring = + policyClass == DcInferringLoadBalancingPolicy.class + || policyClass == DseDcInferringLoadBalancingPolicy.class; + String type; + boolean latencyAwareness; + if (isDcInferring + || policyClass == DefaultLoadBalancingPolicy.class + || policyClass == DseLoadBalancingPolicy.class) { + type = isDcInferring ? "dc-inferring" : "default"; + // No classic latency-percentile host ordering; the closest available signal is slow-replica + // avoidance (a busy/health-based reorder of already-selected replicas), on by default. + latencyAwareness = + config.getBoolean(DefaultDriverOption.LOAD_BALANCING_POLICY_SLOW_AVOIDANCE, true); + } else if (policyClass == BasicLoadBalancingPolicy.class) { + type = "basic"; + // Unlike DefaultLoadBalancingPolicy, BasicLoadBalancingPolicy has no slow-replica-avoidance + // mechanism at all. + latencyAwareness = false; + } else { + customPolicy(n, policy); + return n; + } + n.put("type", type); + n.put("token-aware", true); + n.put("shuffle", true); + n.put( + "dc-failover", + config.getInt(DefaultDriverOption.LOAD_BALANCING_DC_FAILOVER_MAX_NODES_PER_REMOTE_DC, 0) + > 0); + n.put("latency-awareness", latencyAwareness); + return n; + } + + /** + * Session-level datacenter/rack preference. Java has no session-level locality API separate from + * the load balancing policy, so this is sourced from the same places the (default) load balancing + * policy itself reads locality from: the local DC can be set either programmatically via {@link + * com.datastax.oss.driver.api.core.session.SessionBuilder#withLocalDatacenter} (which takes + * precedence, mirroring {@code OptionalLocalDcHelper}) or via config; the local rack has no + * programmatic override and is config-only. + * + *

Known limitation: when neither is set, this reports {@code dc-auto} for the entire + * lifetime of the session, not just "not yet known for this particular report" — including on + * later control-connection reconnects, long after {@code DcInferringLoadBalancingPolicy} has + * resolved a real DC. That resolved value ({@code BasicLoadBalancingPolicy#getLocalDatacenter()}) + * is {@code protected}, on an internal class in a different package, and not exposed anywhere on + * the public {@link LoadBalancingPolicy} interface or {@link InternalDriverContext}; reporting it + * would need new public API surface, out of scope for this reporter. + */ + private ObjectNode nodeLocationPreference(DriverExecutionProfile config) { + String localDc = context.getLocalDatacenter(DriverExecutionProfile.DEFAULT_NAME); + if (localDc == null && config.isDefined(DefaultDriverOption.LOAD_BALANCING_LOCAL_DATACENTER)) { + localDc = config.getString(DefaultDriverOption.LOAD_BALANCING_LOCAL_DATACENTER); + } + String localRack = + config.isDefined(DefaultDriverOption.LOAD_BALANCING_LOCAL_RACK) + ? config.getString(DefaultDriverOption.LOAD_BALANCING_LOCAL_RACK) + : null; + + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + if (localDc != null && localRack != null) { + n.put("type", "rack"); + n.put("local-dc", localDc); + n.put("local-rack", localRack); + } else if (localDc != null) { + n.put("type", "dc"); + n.put("local-dc", localDc); + } else if (localRack != null) { + // DC isn't explicit (DefaultLoadBalancingPolicy will infer one from the first contacted + // node), but rack was configured explicitly on its own: report rack-auto so the known rack + // isn't silently dropped. local-dc is omitted since it isn't known yet at report time. + n.put("type", "rack-auto"); + n.put("local-rack", localRack); + } else { + // DC is inferred from the first contacted node; not known yet at control-connection-init + // report time, so local-dc is omitted. + n.put("type", "dc-auto"); + } + return n; + } + + private ObjectNode connectionPool( + DriverExecutionProfile config, @Nullable NodeShardingInfo shardingInfo) { + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + int configuredSize = config.getInt(DefaultDriverOption.CONNECTION_POOL_LOCAL_SIZE); + if (shardingInfo == null) { + // One pool per host, and the configured size is that pool's size. Reported as-is even when + // 0 (a pool that opens no connections): required and positive-only in the schema, so there + // is no valid representation — see the class javadoc. + n.put("type", "host"); + n.put("desired-connections-count", configuredSize); + } else { + // On ScyllaDB the pool is literally keyed per shard: ChannelPool allocates one ChannelSet per + // shard and treats CONNECTION_POOL_LOCAL_SIZE as a per-node total, so the per-shard target — + // which is what this schema field means once "type" is "shard" — is that size spread over the + // shards and rounded up. Delegated to the pool's own helper so this can't drift from the + // arithmetic the pool actually applies. Note this holds regardless of + // advanced.connection.advanced-shard-awareness.enabled: that option only decides whether a + // connection can reach a chosen shard in a single connect, not how the pool is keyed or + // sized, and is reported separately under shard-aware.enabled below. + n.put("type", "shard"); + n.put( + "desired-connections-count", + ChannelPool.connectionsPerShard(configuredSize, shardingInfo.getShardsCount())); + } + // Optional group, positive-only: omit rather than emit a non-positive value the schema rejects + // (and which reference.conf documents as invalid anyway). + if (config.isDefined(DefaultDriverOption.CONNECTION_MAX_REQUESTS)) { + int maxRequests = config.getInt(DefaultDriverOption.CONNECTION_MAX_REQUESTS); + if (maxRequests > 0) { + ObjectNode connection = OBJECT_MAPPER.createObjectNode(); + connection.put("max-requests", maxRequests); + n.set("connection", connection); + } + } + ObjectNode shardAware = OBJECT_MAPPER.createObjectNode(); + shardAware.put( + "enabled", + config.getBoolean(DefaultDriverOption.CONNECTION_ADVANCED_SHARD_AWARENESS_ENABLED, true)); + n.set("shard-aware", shardAware); + return n; + } + + private ObjectNode queryDefaults(DriverExecutionProfile config) { + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + int pageSize = config.getInt(DefaultDriverOption.REQUEST_PAGE_SIZE); + if (pageSize > 0) { + ObjectNode page = OBJECT_MAPPER.createObjectNode(); + page.put("size", pageSize); + n.set("page", page); + } + // pageSize <= 0 means paging is unbounded: the "page" group is omitted entirely (the schema + // has no "unbounded" sentinel). + n.put("consistency", config.getString(DefaultDriverOption.REQUEST_CONSISTENCY)); + if (config.isDefined(DefaultDriverOption.REQUEST_SERIAL_CONSISTENCY)) { + n.put("serial-consistency", config.getString(DefaultDriverOption.REQUEST_SERIAL_CONSISTENCY)); + } + n.put("idempotence", config.getBoolean(DefaultDriverOption.REQUEST_DEFAULT_IDEMPOTENCE)); + // Client-side timestamps are assigned unless the server-side generator is configured. + n.put( + "client-timestamps", + !(context.getTimestampGenerator() instanceof ServerSideTimestampGenerator)); + ObjectNode request = OBJECT_MAPPER.createObjectNode(); + // Reported as-is even when 0, which legitimately disables the request timeout: the schema + // makes this field required and positive-only, so a disabled timeout has no valid + // representation — see the class javadoc. + request.put("timeout-ms", config.getDuration(DefaultDriverOption.REQUEST_TIMEOUT).toMillis()); + n.set("request", request); + return n; + } + + private ObjectNode tls() { + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + // TLS is on exactly when the channel pipeline gets an SSL handler, which ChannelFactory decides + // from the low-level SslHandlerFactory. Deliberately not getSslEngineFactory(): that is only + // the public JDK-based path that DefaultDriverContext.buildSslHandlerFactory() wraps, and an + // override of that method (the documented expert extension point, e.g. Netty's native OpenSSL) + // supplies a handler factory with no engine factory at all — a session that is encrypted all + // the same. + Optional handlerFactory = context.getSslHandlerFactory(); + n.put("enabled", handlerFactory.isPresent()); + // Host name validation, on the other hand, is a property of the JDK SSLEngine that the engine + // factory configures, so it can only be read on the JDK path — when the handler factory in + // force is the JdkSslHandlerFactory that buildSslHandlerFactory() wraps an engine factory in. + // The gate is on that handler factory rather than merely on an engine factory being present, + // because the two are independent: a context can override buildSslHandlerFactory() and still + // configure advanced.ssl-engine-factory.class, leaving an engine factory that nothing on the + // connection path consults. Reading it there would claim validation the handler in force never + // performs, so anything else stays conservatively false. Exact-class check, like the policy + // branches above: JdkSslHandlerFactory is not final, and a subclass need not use the engine it + // was given. + // Note this is the factory's own state, not the SSL_HOSTNAME_VALIDATION config option: that + // option only governs the built-in DefaultSslEngineFactory. A factory supplied via + // SessionBuilder.withSslContext(...) (ProgrammaticSslEngineFactory) validates only if + // explicitly asked to (default off) regardless of that option, so reading the option here would + // falsely report validation as on when it isn't. + boolean jdkPath = + handlerFactory.isPresent() && handlerFactory.get().getClass() == JdkSslHandlerFactory.class; + n.put( + "hostname-verification", + jdkPath + && context + .getSslEngineFactory() + .map(SslEngineFactory::isHostnameValidationRequired) + .orElse(false)); + return n; + } + + private void customPolicy(ObjectNode node, Object policy) { + node.put("type", "custom"); + // getSimpleName() is empty for an anonymous class (a common way to supply a one-off policy); + // fall back to the full (binary) name so the policy is still identifiable. + String name = policy.getClass().getSimpleName(); + node.put("name", name.isEmpty() ? policy.getClass().getName() : name); } } 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 3d1d5b82b87..2983ef0787f 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 @@ -361,6 +361,10 @@ public DefaultDriverContext( /** * Returns the options to send in a Startup message. * + *

Called once per session (the result is held by a {@code LazyReference} and copied into every + * connection's {@code STARTUP}), which is what makes the {@link + * StartupOptionsBuilder#SESSION_ID_KEY SESSION_ID} it contains stable for the whole session. + * * @see #getStartupOptions() */ protected Map buildStartupOptions() { diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/context/DriverConfigReporter.java b/core/src/main/java/com/datastax/oss/driver/internal/core/context/DriverConfigReporter.java index d614793e9d0..ab76ea57d0d 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/context/DriverConfigReporter.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/context/DriverConfigReporter.java @@ -17,37 +17,42 @@ */ package com.datastax.oss.driver.internal.core.context; +import com.datastax.oss.driver.api.core.metadata.NodeShardingInfo; +import edu.umd.cs.findbugs.annotations.Nullable; import java.util.Map; /** - * Adds the client-configuration-reporting entries to a connection's CQL {@code STARTUP} options, so - * ScyllaDB can store them in {@code system.clients.client_options} and operators can inspect a - * client's effective driver settings while investigating incidents. + * Adds the {@code DRIVER_CONFIG} entry to the control connection's CQL {@code STARTUP} options, so + * ScyllaDB can store it in {@code system.clients.client_options} and operators can inspect the + * driver's effective settings while investigating incidents. * - *

Two entries are produced, both governed by {@code advanced.driver-config-reporting.enabled}: + *

The blob describes the whole session, so only the control connection carries it — pooled + * connections are correlated back to it through the {@link StartupOptionsBuilder#SESSION_ID_KEY + * SESSION_ID} startup option, which the driver sends on every connection unconditionally and + * independently of this reporter. * - *

+ *

Governed by {@code advanced.driver-config-reporting.enabled} (enabled by default). */ public interface DriverConfigReporter { /** - * Adds the reporting entries to the given startup options: {@code SESSION_ID} on every - * connection, plus {@code DRIVER_CONFIG} when {@code reportDriverConfig} is true (the control - * connection). Does nothing when configuration reporting is disabled. + * Adds the {@code DRIVER_CONFIG} blob to the given startup options, unless configuration + * reporting is disabled. * - *

Called from the protocol-initialization handler for every connection. + *

Called from the protocol-initialization handler for the control connection only. * *

Implementations must not throw: this runs on the connection initialization path, so a * failure to build the report must be swallowed (and logged) rather than propagated, otherwise it * would prevent the session from establishing or reconnecting. * - * @param reportDriverConfig whether this connection should also carry the full {@code - * DRIVER_CONFIG} blob; true only for the control connection. + * @param shardingInfo the sharding information the server advertised on this connection, or + * {@code null} if it advertised none. Non-null is the driver's own proxy check for "this is + * ScyllaDB" (the same one {@code CassandraSchemaQueries.shouldApplyUsingTimeout()} makes, + * independently), so this single value carries both of the backend-conditional facts the + * report needs: that server-side behavior which only applies on that backend is in play (e.g. + * the {@code USING TIMEOUT} clause added to schema queries), and the shard count that + * connection pools are keyed by there. */ - void populateStartupOptions(Map startupOptions, boolean reportDriverConfig); + void populateControlConnectionOptions( + Map startupOptions, @Nullable NodeShardingInfo shardingInfo); } diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/context/StartupOptionsBuilder.java b/core/src/main/java/com/datastax/oss/driver/internal/core/context/StartupOptionsBuilder.java index 684d6b01b9c..dd3a0307a79 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/context/StartupOptionsBuilder.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/context/StartupOptionsBuilder.java @@ -37,8 +37,20 @@ public class StartupOptionsBuilder { public static final String APPLICATION_VERSION_KEY = "APPLICATION_VERSION"; public static final String CLIENT_ID_KEY = "CLIENT_ID"; + /** + * STARTUP option key under which the session's identifier is sent, so that the server can group + * all of a session's connections (and correlate them with the configuration that the control + * connection reports under {@code DRIVER_CONFIG}). + * + *

This is an innate driver behavior: the option is sent on every connection, unconditionally. + * In particular it is not governed by {@code advanced.driver-config-reporting.enabled}, + * which only decides whether the control connection also reports the configuration itself. + */ + public static final String SESSION_ID_KEY = "SESSION_ID"; + protected final InternalDriverContext context; private UUID clientId; + private UUID sessionId; private String applicationName; private String applicationVersion; @@ -84,9 +96,9 @@ public StartupOptionsBuilder withApplicationVersion(@Nullable String application * *

The default set of options are built here and include {@link * com.datastax.oss.protocol.internal.request.Startup#COMPRESSION_KEY} (if the context passed in - * has a compressor/algorithm set), and the driver's {@link #DRIVER_NAME_KEY} and {@link - * #DRIVER_VERSION_KEY}. The {@link com.datastax.oss.protocol.internal.request.Startup} - * constructor will add {@link + * has a compressor/algorithm set), the driver's {@link #DRIVER_NAME_KEY} and {@link + * #DRIVER_VERSION_KEY}, and the {@link #SESSION_ID_KEY}. The {@link + * com.datastax.oss.protocol.internal.request.Startup} constructor will add {@link * com.datastax.oss.protocol.internal.request.Startup#CQL_VERSION_KEY}. * * @return Map of Startup Options. @@ -94,7 +106,7 @@ public StartupOptionsBuilder withApplicationVersion(@Nullable String application public Map build() { DriverExecutionProfile config = context.getConfig().getDefaultProfile(); - NullAllowingImmutableMap.Builder builder = NullAllowingImmutableMap.builder(3); + NullAllowingImmutableMap.Builder builder = NullAllowingImmutableMap.builder(4); // add compression (if configured) and driver name and version String compressionAlgorithm = context.getCompressor().algorithm(); if (compressionAlgorithm != null && !compressionAlgorithm.trim().isEmpty()) { @@ -102,6 +114,17 @@ public Map build() { } builder.put(DRIVER_NAME_KEY, getDriverName()).put(DRIVER_VERSION_KEY, getDriverVersion()); + // Identifier of this session, sent on every connection so the server can group them. Not + // derived from the (user-settable, Insights-oriented) CLIENT_ID below, so that it is guaranteed + // unique per session as the grouping key requires. Generated lazily here rather than eagerly in + // a field initializer, mirroring clientId; DefaultDriverContext builds the startup options + // exactly once per session (LazyReference), which is what makes the value stable across all of + // the session's connections, including reconnects. + if (sessionId == null) { + sessionId = Uuids.random(); + } + builder.put(SESSION_ID_KEY, sessionId.toString()); + // Add Insights entries, falling back to generation / config if no programmatic values provided: if (clientId == null) { clientId = Uuids.random(); diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/schema/queries/CassandraSchemaQueries.java b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/schema/queries/CassandraSchemaQueries.java index f909a0cb387..59a5a50c9b6 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/schema/queries/CassandraSchemaQueries.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/schema/queries/CassandraSchemaQueries.java @@ -171,7 +171,10 @@ private void executeOnAdminExecutor() { } protected boolean shouldApplyUsingTimeout() { - // We use non-null sharding info as a proxy check for cluster being a ScyllaDB cluster + // We use non-null sharding info as a proxy check for cluster being a ScyllaDB cluster. + // The same check (independently, on the control channel) backs the "scyllaDb" signal that + // DefaultDriverConfigReporter uses to decide whether to report a server-side USING TIMEOUT + // value; see ProtocolInitHandler's STARTUP case. return (channel.getShardingInfo() != null); } diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/pool/ChannelPool.java b/core/src/main/java/com/datastax/oss/driver/internal/core/pool/ChannelPool.java index c9bc5df2f85..b495bcf929a 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/pool/ChannelPool.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/pool/ChannelPool.java @@ -103,6 +103,26 @@ public static CompletionStage init( return pool.connect(); } + /** + * The number of connections this pool opens per shard for a given configured pool size. + * + *

Pools are keyed per shard (one {@link ChannelSet} per shard), while {@code + * advanced.connection.pool.local.size} / {@code remote.size} is a per-node total, so the + * configured size is spread over the node's shards and rounded up — which guarantees at least one + * connection per shard for any non-zero size. A non-sharded node counts as one shard, for which + * this is the identity. + * + *

Shared with {@code DefaultDriverConfigReporter}, which reports this number as {@code + * connection-pool.desired-connections-count} for a shard-keyed pool: one implementation means the + * report cannot drift from the arithmetic the pool actually applies. + * + * @param configuredSize the configured pool size for the node's distance. + * @param shardsCount the node's shard count; at least 1, as everywhere else in this class. + */ + public static int connectionsPerShard(int configuredSize, int shardsCount) { + return configuredSize / shardsCount + (configuredSize % shardsCount > 0 ? 1 : 0); + } + // This is read concurrently, but only mutated on adminExecutor (by methods in SingleThreaded) @VisibleForTesting ChannelSet[] channels; @@ -366,9 +386,8 @@ private void addChannel(DriverChannel c) { private void initialize(DriverChannel c) { shardingInfo = c.getShardingInfo(); ((DefaultNode) node).setShardingInfo(shardingInfo); - int wanted = getConfiguredSize(distance); int shardsCount = shardingInfo == null ? 1 : shardingInfo.getShardsCount(); - wantedCount = wanted / shardsCount + (wanted % shardsCount > 0 ? 1 : 0); + wantedCount = connectionsPerShard(getConfiguredSize(distance), shardsCount); channels = new ChannelSet[shardsCount]; for (int i = 0; i < channels.length; ++i) { channels[i] = new ChannelSet(); @@ -696,9 +715,8 @@ private void onChannelClosed(DriverChannel channel) { private void resize(NodeDistance newDistance) { assert adminExecutor.inEventLoop(); distance = newDistance; - int newChannelCount = getConfiguredSize(newDistance); int shardsCount = shardingInfo == null ? 1 : shardingInfo.getShardsCount(); - newChannelCount = newChannelCount / shardsCount + (newChannelCount % shardsCount > 0 ? 1 : 0); + int newChannelCount = connectionsPerShard(getConfiguredSize(newDistance), shardsCount); if (newChannelCount > wantedCount) { LOG.debug("[{}] Growing ({} => {} channels)", logPrefix, wantedCount, newChannelCount); wantedCount = newChannelCount; diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/protocol/ProtocolFeatureStore.java b/core/src/main/java/com/datastax/oss/driver/internal/core/protocol/ProtocolFeatureStore.java index 87134d32030..9d7fdbf1c8b 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/protocol/ProtocolFeatureStore.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/protocol/ProtocolFeatureStore.java @@ -2,6 +2,7 @@ import com.datastax.oss.protocol.internal.ProtocolFeatures; import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; import io.netty.channel.Channel; import io.netty.util.AttributeKey; import java.util.List; @@ -39,6 +40,19 @@ public ShardingInfo.ConnectionShardingInfo getShardingInfo() { return shardingInfo; } + /** + * The node-level sharding information the server advertised on this connection, unwrapped from + * the per-connection {@link ShardingInfo.ConnectionShardingInfo} (whose {@code shardId} is + * specific to this one connection and so of no interest to node-level or session-level callers). + * + * @return {@code null} if the server advertised no sharding information, which is the driver's + * own proxy check for "this is not ScyllaDB". + */ + @Nullable + public ShardingInfo getNodeShardingInfo() { + return shardingInfo == null ? null : shardingInfo.shardingInfo; + } + public TabletInfo getTabletFeatureInfo() { return tabletInfo; } diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/ssl/DefaultSslEngineFactory.java b/core/src/main/java/com/datastax/oss/driver/internal/core/ssl/DefaultSslEngineFactory.java index 343d3f9e4e7..27acbdb0711 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/ssl/DefaultSslEngineFactory.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/ssl/DefaultSslEngineFactory.java @@ -137,6 +137,11 @@ public SSLEngine newSslEngine(@NonNull EndPoint remoteEndpoint) { return engine; } + @Override + public boolean isHostnameValidationRequired() { + return requireHostnameValidation; + } + protected SSLContext buildContext(DriverExecutionProfile config) throws Exception { if (config.isDefined(DefaultDriverOption.SSL_KEYSTORE_PATH) || config.isDefined(DefaultDriverOption.SSL_TRUSTSTORE_PATH)) { diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/ssl/SniSslEngineFactory.java b/core/src/main/java/com/datastax/oss/driver/internal/core/ssl/SniSslEngineFactory.java index 4d2cb69fbfc..004aa198873 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/ssl/SniSslEngineFactory.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/ssl/SniSslEngineFactory.java @@ -92,6 +92,12 @@ private int getFakePort(String sniServerName) { return FAKE_PORT_OFFSET + fakePorts.indexOf(sniServerName); } + @Override + public boolean isHostnameValidationRequired() { + // SNI connections always set the "HTTPS" endpoint identification algorithm above. + return true; + } + @Override public void close() { // nothing to do diff --git a/core/src/main/resources/reference.conf b/core/src/main/resources/reference.conf index 8a3a444319e..72414a378ca 100644 --- a/core/src/main/resources/reference.conf +++ b/core/src/main/resources/reference.conf @@ -1206,21 +1206,25 @@ datastax-java-driver { advanced.driver-config-reporting { - # Whether the driver reports its effective configuration to ScyllaDB at connection time. + # Whether the driver reports its effective configuration to the cluster at connection time. # - # When true, the driver adds two entries to the CQL STARTUP options, which ScyllaDB stores in - # system.clients.client_options so operators can inspect driver settings while investigating - # incidents: a SESSION_ID on every connection (so the server can group a session's connections) - # and a compact JSON payload under the DRIVER_CONFIG key on the control connection only. At this - # stage the DRIVER_CONFIG payload carries only schema-version metadata ({"version":1}); reporting - # of the effective configuration fields is planned for a later stage. When false, neither entry - # is sent and there is no change on the wire. + # When true, the control connection adds a compact JSON payload under the DRIVER_CONFIG key to + # its CQL STARTUP options, which the server stores in its client-connection system table + # (system.clients on ScyllaDB, system_views.clients on Cassandra 4.1+) so operators can inspect + # driver settings while investigating incidents. It describes the effective configuration of the + # driver's default execution profile (connection/socket settings, timeouts, + # retry/reconnection/speculative-execution/load-balancing policies, connection pooling, query + # defaults, and TLS). Only the control connection sends it, since it describes the whole + # session. When false, DRIVER_CONFIG is not sent. + # + # Reporting is best-effort: if the report cannot be built, or would exceed 32 KiB, it is skipped + # (with a warning) rather than allowed to interfere with connecting. # # Required: no # Modifiable at runtime: yes, the new value will be used for connections initialized after the change. # Overridable in a profile: no - # Default: false - enabled = false + # Default: true + enabled = true } diff --git a/core/src/test/java/com/datastax/dse/driver/internal/core/context/DseStartupOptionsBuilderTest.java b/core/src/test/java/com/datastax/dse/driver/internal/core/context/DseStartupOptionsBuilderTest.java index 9e4556e528d..81df53af9a3 100644 --- a/core/src/test/java/com/datastax/dse/driver/internal/core/context/DseStartupOptionsBuilderTest.java +++ b/core/src/test/java/com/datastax/dse/driver/internal/core/context/DseStartupOptionsBuilderTest.java @@ -80,6 +80,8 @@ private void assertDefaultStartupOptions(Startup startup) { Version version = Version.parse(startup.options.get(StartupOptionsBuilder.DRIVER_VERSION_KEY)); assertThat(version).isEqualTo(Session.OSS_DRIVER_COORDINATES.getVersion()); assertThat(startup.options).containsKey(StartupOptionsBuilder.CLIENT_ID_KEY); + // SESSION_ID is innate and must survive on the DSE path too. + assertThat(startup.options).containsKey(StartupOptionsBuilder.SESSION_ID_KEY); } @Test diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryTestBase.java b/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryTestBase.java index 2780d5bdec9..01a82570810 100644 --- a/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryTestBase.java +++ b/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryTestBase.java @@ -141,8 +141,8 @@ public void setup() throws InterruptedException { when(context.getEventBus()).thenReturn(eventBus); when(context.getWriteCoalescer()).thenReturn(new PassThroughWriteCoalescer(null)); when(context.getCompressor()).thenReturn(compressor); - // The init handler consults the config reporter for every connection; default to a no-op. - when(context.getDriverConfigReporter()).thenReturn((startupOptions, reportDriverConfig) -> {}); + // The init handler consults the config reporter for the control connection; default to a no-op. + when(context.getDriverConfigReporter()).thenReturn((startupOptions, shardingInfo) -> {}); // Start local server ServerBootstrap serverBootstrap = diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ProtocolInitHandlerTest.java b/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ProtocolInitHandlerTest.java index a7051ac466d..235c028ab6b 100644 --- a/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ProtocolInitHandlerTest.java +++ b/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ProtocolInitHandlerTest.java @@ -38,13 +38,17 @@ import com.datastax.oss.driver.api.core.config.DriverExecutionProfile; import com.datastax.oss.driver.api.core.connection.ConnectionInitException; import com.datastax.oss.driver.api.core.metadata.EndPoint; +import com.datastax.oss.driver.api.core.metadata.NodeShardingInfo; import com.datastax.oss.driver.internal.core.DefaultProtocolVersionRegistry; import com.datastax.oss.driver.internal.core.ProtocolVersionRegistry; import com.datastax.oss.driver.internal.core.TestResponses; import com.datastax.oss.driver.internal.core.context.DefaultDriverConfigReporter; +import com.datastax.oss.driver.internal.core.context.DriverConfigReporter; import com.datastax.oss.driver.internal.core.context.InternalDriverContext; +import com.datastax.oss.driver.internal.core.context.StartupOptionsBuilder; import com.datastax.oss.driver.internal.core.metadata.TestNodeFactory; import com.datastax.oss.driver.shaded.guava.common.collect.ImmutableList; +import com.datastax.oss.driver.shaded.guava.common.collect.ImmutableMap; import com.datastax.oss.protocol.internal.Frame; import com.datastax.oss.protocol.internal.ProtocolConstants; import com.datastax.oss.protocol.internal.ProtocolConstants.ErrorCode; @@ -58,6 +62,7 @@ import com.datastax.oss.protocol.internal.response.Authenticate; import com.datastax.oss.protocol.internal.response.Error; import com.datastax.oss.protocol.internal.response.Ready; +import com.datastax.oss.protocol.internal.response.Supported; import com.datastax.oss.protocol.internal.response.result.SetKeyspace; import com.datastax.oss.protocol.internal.util.Bytes; import io.netty.channel.ChannelFuture; @@ -69,6 +74,7 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import org.junit.Before; import org.junit.Test; import org.mockito.Mock; @@ -103,9 +109,9 @@ public void setup() { when(defaultProfile.getDuration(DefaultDriverOption.HEARTBEAT_INTERVAL)) .thenReturn(Duration.ofSeconds(30)); when(internalDriverContext.getProtocolVersionRegistry()).thenReturn(protocolVersionRegistry); - // The init handler consults the config reporter for every connection; default to a no-op. + // The init handler consults the config reporter for the control connection; default to a no-op. when(internalDriverContext.getDriverConfigReporter()) - .thenReturn((startupOptions, reportDriverConfig) -> {}); + .thenReturn((startupOptions, shardingInfo) -> {}); channel .pipeline() @@ -157,21 +163,17 @@ public void should_initialize() { assertThat(connectFuture).isSuccess(); } - // Mirrors the real reporter: SESSION_ID on every connection, DRIVER_CONFIG only when asked. + // Mirrors the real reporter, which only ever sees the control connection. private void stubConfigReporter() { when(internalDriverContext.getDriverConfigReporter()) .thenReturn( - (startupOptions, reportDriverConfig) -> { - startupOptions.put(DefaultDriverConfigReporter.SESSION_ID_KEY, "test-session-id"); - if (reportDriverConfig) { + (startupOptions, shardingInfo) -> startupOptions.put( - DefaultDriverConfigReporter.DRIVER_CONFIG_KEY, "{\"version\":1}"); - } - }); + DefaultDriverConfigReporter.DRIVER_CONFIG_KEY, "{\"version\":1}")); } @Test - public void should_report_session_id_and_driver_config_on_control_connection() { + public void should_report_driver_config_on_control_connection() { stubConfigReporter(); channel .pipeline() @@ -191,13 +193,13 @@ public void should_report_session_id_and_driver_config_on_control_connection() { Frame requestFrame = readOutboundFrame(); assertThat(requestFrame.message).isInstanceOf(Startup.class); Startup startup = (Startup) requestFrame.message; - assertThat(startup.options).containsKey(DefaultDriverConfigReporter.SESSION_ID_KEY); assertThat(startup.options).containsKey(DefaultDriverConfigReporter.DRIVER_CONFIG_KEY); } @Test - public void should_report_session_id_but_not_driver_config_on_pool_connection() { - stubConfigReporter(); + public void should_not_consult_the_config_reporter_on_pool_connection() { + DriverConfigReporter reporter = mock(DriverConfigReporter.class); + when(internalDriverContext.getDriverConfigReporter()).thenReturn(reporter); channel .pipeline() .addLast( @@ -217,8 +219,36 @@ public void should_report_session_id_but_not_driver_config_on_pool_connection() Frame requestFrame = readOutboundFrame(); assertThat(requestFrame.message).isInstanceOf(Startup.class); Startup startup = (Startup) requestFrame.message; - assertThat(startup.options).containsKey(DefaultDriverConfigReporter.SESSION_ID_KEY); assertThat(startup.options).doesNotContainKey(DefaultDriverConfigReporter.DRIVER_CONFIG_KEY); + verify(reporter, never()).populateControlConnectionOptions(any(), any()); + } + + @Test + public void should_pass_session_id_from_the_session_startup_options_to_every_connection() { + // SESSION_ID is not the reporter's business: it comes from the session-wide startup options, so + // it reaches pool connections (reportConfig = false) as well. + when(internalDriverContext.getStartupOptions()) + .thenReturn(ImmutableMap.of(StartupOptionsBuilder.SESSION_ID_KEY, "test-session-id")); + channel + .pipeline() + .addLast( + ChannelFactory.INIT_HANDLER_NAME, + new ProtocolInitHandler( + internalDriverContext, + DefaultProtocolVersion.V4, + null, + END_POINT, + DriverChannelOptions.DEFAULT, + heartbeatHandler, + false)); + + channel.connect(new InetSocketAddress("localhost", 9042)); + + Frame requestFrame = readOutboundFrame(); + assertThat(requestFrame.message).isInstanceOf(Startup.class); + Startup startup = (Startup) requestFrame.message; + assertThat(startup.options) + .containsEntry(StartupOptionsBuilder.SESSION_ID_KEY, "test-session-id"); } @Test @@ -267,6 +297,86 @@ public void should_query_supported_options() { assertThat(connectFuture).isSuccess(); } + @Test + public void should_pass_sharding_info_to_the_reporter_when_present() { + AtomicReference capturedShardingInfo = new AtomicReference<>(); + when(internalDriverContext.getDriverConfigReporter()) + .thenReturn((startupOptions, shardingInfo) -> capturedShardingInfo.set(shardingInfo)); + channel + .pipeline() + .addLast( + ChannelFactory.INIT_HANDLER_NAME, + new ProtocolInitHandler( + internalDriverContext, + DefaultProtocolVersion.V4, + null, + END_POINT, + // Only the control connection reports the config, so only it is passed the info. + DriverChannelOptions.builder().reportConfig(true).build(), + heartbeatHandler, + true)); + + channel.connect(new InetSocketAddress("localhost", 9042)); + + Frame requestFrame = readOutboundFrame(); + assertThat(requestFrame.message).isInstanceOf(Options.class); + + // Simulate a SUPPORTED response carrying the five ScyllaDB sharding-info keys + // ShardingInfo.parseShardingInfo() requires (see ShardingInfo.java:112-128). + Map> shardingOptions = + ImmutableMap.>builder() + .put("SCYLLA_SHARD", ImmutableList.of("0")) + .put("SCYLLA_NR_SHARDS", ImmutableList.of("4")) + .put( + "SCYLLA_PARTITIONER", + ImmutableList.of("org.apache.cassandra.dht.Murmur3Partitioner")) + .put("SCYLLA_SHARDING_ALGORITHM", ImmutableList.of("biased-token-round-robin")) + .put("SCYLLA_SHARDING_IGNORE_MSB", ImmutableList.of("12")) + .build(); + writeInboundFrame(requestFrame, new Supported(shardingOptions)); + + requestFrame = readOutboundFrame(); + assertThat(requestFrame.message).isInstanceOf(Startup.class); + // The reporter receives the node-level info unwrapped from ConnectionShardingInfo, carrying the + // SCYLLA_NR_SHARDS count above — which is what it reports the connection pool's sizing from. + assertThat(capturedShardingInfo.get()).isNotNull(); + assertThat(capturedShardingInfo.get().getShardsCount()).isEqualTo(4); + } + + @Test + public void should_pass_no_sharding_info_to_the_reporter_when_absent() { + // Seeded non-null so the assertion at the end cannot pass vacuously. + AtomicReference capturedShardingInfo = + new AtomicReference<>(mock(NodeShardingInfo.class)); + when(internalDriverContext.getDriverConfigReporter()) + .thenReturn((startupOptions, shardingInfo) -> capturedShardingInfo.set(shardingInfo)); + channel + .pipeline() + .addLast( + ChannelFactory.INIT_HANDLER_NAME, + new ProtocolInitHandler( + internalDriverContext, + DefaultProtocolVersion.V4, + null, + END_POINT, + // Only the control connection reports the config, so only it is passed the info. + DriverChannelOptions.builder().reportConfig(true).build(), + heartbeatHandler, + true)); + + channel.connect(new InetSocketAddress("localhost", 9042)); + + Frame requestFrame = readOutboundFrame(); + assertThat(requestFrame.message).isInstanceOf(Options.class); + + // No sharding-info keys: ShardingInfo.parseShardingInfo(...) returns null. + writeInboundFrame(requestFrame, TestResponses.supportedResponse("mock_key", "mock_value")); + + requestFrame = readOutboundFrame(); + assertThat(requestFrame.message).isInstanceOf(Startup.class); + assertThat(capturedShardingInfo.get()).isNull(); + } + @Test public void should_add_heartbeat_handler_to_pipeline_on_success() { ProtocolInitHandler protocolInitHandler = diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/context/DefaultDriverConfigReporterTest.java b/core/src/test/java/com/datastax/oss/driver/internal/core/context/DefaultDriverConfigReporterTest.java index b303e6db5a5..4910f9a2193 100644 --- a/core/src/test/java/com/datastax/oss/driver/internal/core/context/DefaultDriverConfigReporterTest.java +++ b/core/src/test/java/com/datastax/oss/driver/internal/core/context/DefaultDriverConfigReporterTest.java @@ -21,121 +21,1376 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import com.datastax.dse.driver.internal.core.loadbalancing.DseDcInferringLoadBalancingPolicy; +import com.datastax.dse.driver.internal.core.loadbalancing.DseLoadBalancingPolicy; 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; import com.datastax.oss.driver.api.core.config.DriverExecutionProfile; +import com.datastax.oss.driver.api.core.config.OptionsMap; +import com.datastax.oss.driver.api.core.config.TypedDriverOption; +import com.datastax.oss.driver.api.core.connection.ReconnectionPolicy; +import com.datastax.oss.driver.api.core.context.DriverContext; +import com.datastax.oss.driver.api.core.loadbalancing.LoadBalancingPolicy; +import com.datastax.oss.driver.api.core.metadata.NodeShardingInfo; +import com.datastax.oss.driver.api.core.retry.RetryPolicy; +import com.datastax.oss.driver.api.core.specex.SpeculativeExecutionPolicy; +import com.datastax.oss.driver.api.core.ssl.ProgrammaticSslEngineFactory; +import com.datastax.oss.driver.api.core.ssl.SslEngineFactory; +import com.datastax.oss.driver.api.core.time.TimestampGenerator; +import com.datastax.oss.driver.internal.core.connection.ConstantReconnectionPolicy; +import com.datastax.oss.driver.internal.core.connection.ExponentialReconnectionPolicy; +import com.datastax.oss.driver.internal.core.loadbalancing.BasicLoadBalancingPolicy; +import com.datastax.oss.driver.internal.core.loadbalancing.DcInferringLoadBalancingPolicy; +import com.datastax.oss.driver.internal.core.loadbalancing.DefaultLoadBalancingPolicy; +import com.datastax.oss.driver.internal.core.retry.ConsistencyDowngradingRetryPolicy; +import com.datastax.oss.driver.internal.core.retry.DefaultRetryPolicy; +import com.datastax.oss.driver.internal.core.specex.ConstantSpeculativeExecutionPolicy; +import com.datastax.oss.driver.internal.core.specex.NoSpeculativeExecutionPolicy; +import com.datastax.oss.driver.internal.core.ssl.JdkSslHandlerFactory; +import com.datastax.oss.driver.internal.core.ssl.SslHandlerFactory; +import com.datastax.oss.driver.internal.core.time.ServerSideTimestampGenerator; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion; +import com.networknt.schema.ValidationMessage; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.HashMap; import java.util.Map; -import java.util.UUID; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Supplier; +import javax.net.ssl.SSLContext; import org.junit.Before; import org.junit.Test; +// Many tests below use mock(SomeBuiltinPolicy.class) and assert the reporter recognizes it as that +// exact built-in (not "custom"). This relies on Mockito 5's default inline mock maker returning an +// object whose getClass() is the literal mocked class rather than a generated subclass (verified +// empirically for this project's Mockito version); a return to subclass-based mocking would make +// every exact-class branch under test here fall through to "custom" instead. public class DefaultDriverConfigReporterTest { - private InternalDriverContext context; - private DriverExecutionProfile profile; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + // The normative v1 JSON Schema from the design doc, shipped verbatim as a test resource. Loaded + // once and pinned to draft 2020-12 (its declared $schema); its internal "#/$defs/..." refs + // resolve locally, so validation needs no network access. + private static final JsonSchema SCHEMA = loadSchema(); + + private static JsonSchema loadSchema() { + try (InputStream in = + DefaultDriverConfigReporterTest.class.getResourceAsStream( + "/config/driver-config-report-v1.schema.json")) { + return JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012) + .getSchema(MAPPER.readTree(in)); + } catch (Exception e) { + throw new AssertionError("Cannot load the DRIVER_CONFIG v1 JSON Schema resource", e); + } + } + + // ---- Fixtures for the gating / fail-safe tests (bare mock profile) ---- + private InternalDriverContext mockContext; + private DriverExecutionProfile mockProfile; private DefaultDriverConfigReporter reporter; @Before public void setup() { - context = mock(InternalDriverContext.class); + mockContext = mock(InternalDriverContext.class); DriverConfig config = mock(DriverConfig.class); - profile = mock(DriverExecutionProfile.class); - when(context.getConfig()).thenReturn(config); - when(config.getDefaultProfile()).thenReturn(profile); - reporter = new DefaultDriverConfigReporter(context); + mockProfile = mock(DriverExecutionProfile.class); + when(mockContext.getConfig()).thenReturn(config); + when(config.getDefaultProfile()).thenReturn(mockProfile); + reporter = new DefaultDriverConfigReporter(mockContext); } private void enableReporting(boolean enabled) { - when(profile.getBoolean(DefaultDriverOption.DRIVER_CONFIG_REPORTING_ENABLED, false)) + when(mockProfile.getBoolean(DefaultDriverOption.DRIVER_CONFIG_REPORTING_ENABLED, true)) .thenReturn(enabled); } + /** A reporter over the bare mock context whose report is fixed (or fails) as given. */ + private DefaultDriverConfigReporter reporterReporting(Supplier json) { + return new DefaultDriverConfigReporter(mockContext) { + @Override + String buildJson(NodeShardingInfo shardingInfo) { + return json.get(); + } + }; + } + + // ==================== Gating ==================== + // + // Note that SESSION_ID is not this class's concern: it is an innate startup option built by + // StartupOptionsBuilder and sent on every connection regardless of these settings. + + @Test + public void should_add_driver_config_when_enabled() { + enableReporting(true); + Map options = new HashMap<>(); + reporterReporting(() -> "{\"version\":1}").populateControlConnectionOptions(options, null); + assertThat(options) + .hasSize(1) + .containsEntry(DefaultDriverConfigReporter.DRIVER_CONFIG_KEY, "{\"version\":1}"); + } + @Test public void should_add_nothing_when_disabled() { enableReporting(false); Map options = new HashMap<>(); - reporter.populateStartupOptions(options, /* reportDriverConfig= */ true); - assertThat(options).doesNotContainKey(DefaultDriverConfigReporter.SESSION_ID_KEY); - assertThat(options).doesNotContainKey(DefaultDriverConfigReporter.DRIVER_CONFIG_KEY); + reporter.populateControlConnectionOptions(options, null); + assertThat(options).isEmpty(); } @Test - public void should_add_session_id_and_driver_config_on_control_connection() { + public void should_add_driver_config_when_the_option_is_not_defined() { + // A configuration that omits the option altogether must behave like the shipped default, which + // is enabled. Uses a real (map-based) profile: a mock would return false for any unstubbed + // getBoolean(), ignoring the fallback that is under test here. + Map options = new HashMap<>(); + defaultsReporter(map -> map.remove(TypedDriverOption.DRIVER_CONFIG_REPORTING_ENABLED)) + .populateControlConnectionOptions(options, null); + assertThat(options).containsKey(DefaultDriverConfigReporter.DRIVER_CONFIG_KEY); + } + + // ==================== Fail-safe ==================== + + @Test + public void should_not_throw_when_reading_the_flag_fails() { + when(mockProfile.getBoolean(DefaultDriverOption.DRIVER_CONFIG_REPORTING_ENABLED, true)) + .thenThrow(new IllegalStateException("config blew up")); + Map options = new HashMap<>(); + reporter.populateControlConnectionOptions(options, null); // must not throw + assertThat(options).isEmpty(); + } + + @Test + public void should_skip_driver_config_when_building_fails() { enableReporting(true); Map options = new HashMap<>(); - reporter.populateStartupOptions(options, /* reportDriverConfig= */ true); - // SESSION_ID is a valid, driver-generated UUID. - String sessionId = options.get(DefaultDriverConfigReporter.SESSION_ID_KEY); - assertThat(sessionId).isNotNull(); - assertThat(UUID.fromString(sessionId)).isNotNull(); // does not throw => valid UUID - // Stage 1 emits only the schema version; the value must be valid compact JSON. - assertThat(options.get(DefaultDriverConfigReporter.DRIVER_CONFIG_KEY)) - .isEqualTo("{\"version\":" + DefaultDriverConfigReporter.SCHEMA_VERSION + "}"); + reporterReporting( + () -> { + throw new IllegalStateException("introspection blew up"); + }) + .populateControlConnectionOptions(options, null); // must not throw + assertThat(options).isEmpty(); } @Test - public void should_add_session_id_only_on_pool_connection() { + public void should_skip_driver_config_when_serialization_fails() { + // buildJson() returns null when Jackson fails to serialize the node tree. enableReporting(true); Map options = new HashMap<>(); - reporter.populateStartupOptions(options, /* reportDriverConfig= */ false); - assertThat(options).containsKey(DefaultDriverConfigReporter.SESSION_ID_KEY); - assertThat(options).doesNotContainKey(DefaultDriverConfigReporter.DRIVER_CONFIG_KEY); + reporterReporting(() -> null).populateControlConnectionOptions(options, null); + assertThat(options).isEmpty(); } @Test - public void should_use_a_stable_session_id_across_connections() { + public void should_not_throw_when_a_getSimpleName_call_throws_an_error() { + // customPolicy() calls getClass().getSimpleName() on arbitrary user-supplied policy objects; + // the catch clause must also cover Error (not just RuntimeException) so a JDK edge case there + // can never break connection setup. enableReporting(true); - Map control = new HashMap<>(); - Map pool = new HashMap<>(); - reporter.populateStartupOptions(control, true); - reporter.populateStartupOptions(pool, false); - assertThat(pool.get(DefaultDriverConfigReporter.SESSION_ID_KEY)) - .isEqualTo(control.get(DefaultDriverConfigReporter.SESSION_ID_KEY)); + Map options = new HashMap<>(); + reporterReporting( + () -> { + throw new InternalError("simulated getSimpleName() JDK edge case"); + }) + .populateControlConnectionOptions(options, null); // must not throw + assertThat(options).isEmpty(); } @Test - public void should_use_a_distinct_session_id_per_reporter() { + public void should_skip_driver_config_when_it_exceeds_the_size_limit() { + // STARTUP option values are written with an unchecked 16-bit length prefix, so an oversized + // report would corrupt the frame and fail the handshake rather than merely be useless. Parts of + // the report come from unbounded user-supplied values (DC/rack names, consistency levels, + // custom policy class names), so the limit has to be enforced here. enableReporting(true); - Map first = new HashMap<>(); - reporter.populateStartupOptions(first, false); - // A second session (new reporter instance) must get a different SESSION_ID. - Map second = new HashMap<>(); - new DefaultDriverConfigReporter(context).populateStartupOptions(second, false); - assertThat(second.get(DefaultDriverConfigReporter.SESSION_ID_KEY)) - .isNotEqualTo(first.get(DefaultDriverConfigReporter.SESSION_ID_KEY)); + Map options = new HashMap<>(); + reporterReporting(() -> oversizedReport()) + .populateControlConnectionOptions(options, null); // must not throw + assertThat(options).isEmpty(); } - /** Reporting must never break the connection: a failed config read is swallowed entirely. */ @Test - public void should_not_throw_when_reading_the_flag_fails() { - when(profile.getBoolean(DefaultDriverOption.DRIVER_CONFIG_REPORTING_ENABLED, false)) - .thenThrow(new IllegalStateException("config blew up")); + public void should_add_driver_config_that_is_just_within_the_size_limit() { + enableReporting(true); Map options = new HashMap<>(); - reporter.populateStartupOptions(options, true); // must not throw - assertThat(options).doesNotContainKey(DefaultDriverConfigReporter.SESSION_ID_KEY); - assertThat(options).doesNotContainKey(DefaultDriverConfigReporter.DRIVER_CONFIG_KEY); + String atLimit = padTo(DefaultDriverConfigReporter.MAX_DRIVER_CONFIG_LENGTH); + reporterReporting(() -> atLimit).populateControlConnectionOptions(options, null); + assertThat(options).containsEntry(DefaultDriverConfigReporter.DRIVER_CONFIG_KEY, atLimit); } /** - * Reporting must never break the connection: a failure while building the config groups (as a - * Stage 2 policy introspection might) is swallowed. SESSION_ID is still emitted (it is added - * before, and independently of, the DRIVER_CONFIG blob); only DRIVER_CONFIG is omitted. + * A report that is within the limit by {@link String#length()} but over it once encoded, so that + * the check is pinned to UTF-8 bytes rather than characters. */ + private static String oversizedReport() { + StringBuilder sb = new StringBuilder("{\"version\":1,\"pad\":\""); + // 3 bytes each in UTF-8, so two thirds of the limit in characters is over it in bytes. + for (int i = 0; i < (DefaultDriverConfigReporter.MAX_DRIVER_CONFIG_LENGTH * 2) / 3; i++) { + sb.append('€'); + } + String report = sb.append("\"}").toString(); + assertThat(report.length()).isLessThan(DefaultDriverConfigReporter.MAX_DRIVER_CONFIG_LENGTH); + assertThat(report.getBytes(StandardCharsets.UTF_8).length) + .isGreaterThan(DefaultDriverConfigReporter.MAX_DRIVER_CONFIG_LENGTH); + return report; + } + + /** A single-byte-per-character report of exactly {@code length} bytes. */ + private static String padTo(int length) { + String prefix = "{\"version\":1,\"pad\":\""; + String suffix = "\"}"; + StringBuilder sb = new StringBuilder(prefix); + for (int i = prefix.length() + suffix.length(); i < length; i++) { + sb.append('x'); + } + String report = sb.append(suffix).toString(); + assertThat(report.getBytes(StandardCharsets.UTF_8).length).isEqualTo(length); + return report; + } + + // ==================== Report content ==================== + @Test - public void should_keep_session_id_but_skip_driver_config_when_building_config_groups_fails() { - enableReporting(true); - DefaultDriverConfigReporter throwingReporter = - new DefaultDriverConfigReporter(context) { - @Override - protected void populateConfig(ObjectNode root, DriverExecutionProfile config) { - throw new IllegalStateException("policy introspection blew up"); - } - }; - Map options = new HashMap<>(); - throwingReporter.populateStartupOptions(options, true); // must not throw - assertThat(options).containsKey(DefaultDriverConfigReporter.SESSION_ID_KEY); - assertThat(options).doesNotContainKey(DefaultDriverConfigReporter.DRIVER_CONFIG_KEY); + public void should_report_default_configuration() throws Exception { + JsonNode report = report(defaultsReporter(map -> {})); + + assertThat(report.get("version").asInt()).isEqualTo(DefaultDriverConfigReporter.SCHEMA_VERSION); + + // Groups always present for the default profile. + for (String group : + new String[] { + "connection", + "socket", + "control-plane", + "reconnection-policy", + "retry-policy", + "load-balancing-policy", + "node-location-preference", + "connection-pool", + "query-defaults", + "tls" + }) { + assertThat(report.has(group)).as("group %s present", group).isTrue(); + } + // No speculative execution policy configured by default: the group has no null variant in + // the schema, so it is omitted entirely rather than reported as null. + assertThat(report.has("speculative-execution-policy")).isFalse(); + + JsonNode connection = report.get("connection"); + assertThat(connection.get("connect").get("timeout-ms").asLong()).isPositive(); + // No socket-level read/write timeout, and connection.heartbeat has no schema slot yet: all + // three are omitted rather than present-with-null/empty. + assertThat(connection.has("read")).isFalse(); + assertThat(connection.has("write")).isFalse(); + assertThat(connection.has("heartbeat")).isFalse(); + + JsonNode socket = report.get("socket"); + assertThat(socket.get("tcp-no-delay").asBoolean()).isTrue(); + assertThat(socket.get("keep-alive").asBoolean()).isFalse(); + assertThat(socket.has("linger")).isFalse(); + assertThat(socket.has("receive-buffer")).isFalse(); + assertThat(socket.has("send-buffer")).isFalse(); + + // Built without the ScyllaDB signal (plain Cassandra): no server-side (USING TIMEOUT) + // internal-query timeout, since Cassandra never gets that clause. + JsonNode controlPlane = report.get("control-plane"); + assertThat(controlPlane.get("system-queries").get("timeout").get("client-side-ms").asLong()) + .isPositive(); + assertThat(controlPlane.get("system-queries").get("timeout").has("server-side-ms")).isFalse(); + assertThat(controlPlane.get("schema-agreement").get("timeout-ms").asLong()).isPositive(); + + JsonNode reconnection = report.get("reconnection-policy"); + assertThat(reconnection.get("type").asText()).isEqualTo("exponential"); + assertThat(reconnection.get("base-ms").asLong()).isPositive(); + assertThat(reconnection.get("max-ms").asLong()).isPositive(); + // Java's built-in reconnection policies are unbounded: max-attempts is omitted. + assertThat(reconnection.has("max-attempts")).isFalse(); + + JsonNode retry = report.get("retry-policy"); + assertThat(retry.get("type").asText()).isEqualTo("standard-error-aware"); + assertThat(retry.has("backoff")).isFalse(); + + JsonNode lb = report.get("load-balancing-policy"); + assertThat(lb.get("type").asText()).isEqualTo("default"); + assertThat(lb.get("token-aware").asBoolean()).isTrue(); + // Both are unconditional/on-by-default for DefaultLoadBalancingPolicy: replicas are always + // shuffled, and slow-replica avoidance (the closest available signal for "latency-awareness") + // defaults to enabled. + assertThat(lb.get("shuffle").asBoolean()).isTrue(); + assertThat(lb.get("latency-awareness").asBoolean()).isTrue(); + assertThat(lb.get("dc-failover").asBoolean()).isFalse(); + // local-dc/local-rack are no longer reported here; see node-location-preference below. + assertThat(lb.has("local-dc")).isFalse(); + assertThat(lb.has("local-rack")).isFalse(); + + // local-datacenter not configured in the defaults => DC is inferred (dc-auto), and the value + // isn't known yet at report time. + JsonNode nodeLocation = report.get("node-location-preference"); + assertThat(nodeLocation.get("type").asText()).isEqualTo("dc-auto"); + assertThat(nodeLocation.has("local-dc")).isFalse(); + + JsonNode pool = report.get("connection-pool"); + assertThat(pool.get("type").asText()).isEqualTo("host"); + assertThat(pool.get("desired-connections-count").asInt()).isPositive(); + assertThat(pool.get("shard-aware").get("enabled").asBoolean()).isTrue(); + + JsonNode query = report.get("query-defaults"); + assertThat(query.get("consistency").asText()).isEqualTo("LOCAL_ONE"); + assertThat(query.get("idempotence").asBoolean()).isFalse(); + assertThat(query.get("client-timestamps").asBoolean()).isTrue(); + assertThat(query.get("request").get("timeout-ms").asLong()).isPositive(); + assertThat(query.get("page").get("size").asInt()).isPositive(); + + JsonNode tls = report.get("tls"); + assertThat(tls.get("enabled").asBoolean()).isFalse(); + assertThat(tls.get("hostname-verification").asBoolean()).isFalse(); + } + + @Test + public void should_report_server_side_timeout_for_scylladb() throws Exception { + JsonNode report = report(defaultsReporter(map -> {}), shardingInfo(4)); + JsonNode timeout = report.get("control-plane").get("system-queries").get("timeout"); + assertThat(timeout.get("client-side-ms").asLong()).isPositive(); + // ScyllaDB only: CassandraSchemaQueries adds a "USING TIMEOUT" clause built from this same + // option to every schema query, so it's a genuine server-side timeout on this backend. + assertThat(timeout.get("server-side-ms").asLong()).isPositive(); + } + + @Test + public void should_omit_server_side_timeout_for_cassandra() throws Exception { + JsonNode report = report(defaultsReporter(map -> {})); + assertThat( + report.get("control-plane").get("system-queries").get("timeout").has("server-side-ms")) + .isFalse(); + } + + @Test + public void should_report_constant_reconnection_policy() throws Exception { + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + mock(ConstantReconnectionPolicy.class), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + mock(DefaultLoadBalancingPolicy.class), + mock(TimestampGenerator.class), + Optional.empty()); + JsonNode reconnection = report(r).get("reconnection-policy"); + assertThat(reconnection.get("type").asText()).isEqualTo("constant"); + assertThat(reconnection.get("delay-ms").asLong()).isPositive(); + assertThat(reconnection.has("max-attempts")).isFalse(); + } + + @Test + public void should_report_custom_reconnection_policy() throws Exception { + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + mock(ReconnectionPolicy.class), // neither exponential nor constant + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + mock(DefaultLoadBalancingPolicy.class), + mock(TimestampGenerator.class), + Optional.empty()); + JsonNode reconnection = report(r).get("reconnection-policy"); + assertThat(reconnection.get("type").asText()).isEqualTo("custom"); + assertThat(reconnection.get("name").asText()).isNotEmpty(); + } + + @Test + public void should_report_a_reconnection_policy_subclass_as_custom() throws Exception { + // A real (anonymous) subclass of a built-in, not a mock: proves the exact-class check doesn't + // misclassify user customizations of a built-in as the plain built-in. + // ConstantReconnectionPolicy is not final, and a real subclass of it already exists elsewhere + // in this repo's test code. + ReconnectionPolicy subclass = new ConstantReconnectionPolicy(policyConstructionContext()) {}; + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + subclass, + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + mock(DefaultLoadBalancingPolicy.class), + mock(TimestampGenerator.class), + Optional.empty()); + JsonNode reconnection = report(r).get("reconnection-policy"); + assertThat(reconnection.get("type").asText()).isEqualTo("custom"); + // Also exercises the anonymous-class name fallback: getSimpleName() is empty for an anonymous + // class, so the reported name must fall back to the (non-empty) binary class name. + assertThat(reconnection.get("name").asText()).isNotEmpty(); + } + + @Test + public void should_report_downgrading_consistency_retry_policy() throws Exception { + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + mock(ExponentialReconnectionPolicy.class), + mock(ConsistencyDowngradingRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + mock(DefaultLoadBalancingPolicy.class), + mock(TimestampGenerator.class), + Optional.empty()); + assertThat(report(r).get("retry-policy").get("type").asText()) + .isEqualTo("downgrading-consistency"); + } + + @Test + public void should_report_a_retry_policy_subclass_as_custom() throws Exception { + // Real (anonymous) subclass, not a mock: DefaultRetryPolicy is not final, and a real subclass + // of it already exists elsewhere in this repo's test code (osgi-tests' CustomRetryPolicy). + RetryPolicy subclass = new DefaultRetryPolicy(policyConstructionContext(), "default") {}; + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + mock(ExponentialReconnectionPolicy.class), + subclass, + mock(NoSpeculativeExecutionPolicy.class), + mock(DefaultLoadBalancingPolicy.class), + mock(TimestampGenerator.class), + Optional.empty()); + JsonNode retry = report(r).get("retry-policy"); + assertThat(retry.get("type").asText()).isEqualTo("custom"); + assertThat(retry.get("name").asText()).isNotEmpty(); + } + + @Test + public void should_report_constant_speculative_execution_policy() throws Exception { + DefaultDriverConfigReporter r = + reporterWith( + defaults( + map -> { + map.put(TypedDriverOption.SPECULATIVE_EXECUTION_MAX, 3); + map.put(TypedDriverOption.SPECULATIVE_EXECUTION_DELAY, Duration.ofMillis(100)); + }), + mock(ExponentialReconnectionPolicy.class), + mock(DefaultRetryPolicy.class), + mock(ConstantSpeculativeExecutionPolicy.class), + mock(DefaultLoadBalancingPolicy.class), + mock(TimestampGenerator.class), + Optional.empty()); + JsonNode spec = report(r).get("speculative-execution-policy"); + assertThat(spec.get("type").asText()).isEqualTo("constant"); + assertThat(spec.get("max-executions").asInt()).isEqualTo(3); + assertThat(spec.get("delay-ms").asLong()).isEqualTo(100); + } + + @Test + public void should_report_a_speculative_execution_policy_subclass_as_custom() throws Exception { + // Real (anonymous) subclass, not a mock: NoSpeculativeExecutionPolicy is not final. A subclass + // must be reported as "custom", not silently treated the same as "no policy" (which would drop + // the whole group). + SpeculativeExecutionPolicy subclass = + new NoSpeculativeExecutionPolicy(policyConstructionContext(), "default") {}; + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + mock(ExponentialReconnectionPolicy.class), + mock(DefaultRetryPolicy.class), + subclass, + mock(DefaultLoadBalancingPolicy.class), + mock(TimestampGenerator.class), + Optional.empty()); + JsonNode spec = report(r).get("speculative-execution-policy"); + assertThat(spec.get("type").asText()).isEqualTo("custom"); + assertThat(spec.get("name").asText()).isNotEmpty(); + } + + @Test + public void should_report_dc_inferring_load_balancing_policy() throws Exception { + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + mock(ExponentialReconnectionPolicy.class), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + mock(DcInferringLoadBalancingPolicy.class), // extends DefaultLoadBalancingPolicy + mock(TimestampGenerator.class), + Optional.empty()); + JsonNode lb = report(r).get("load-balancing-policy"); + // Must be its own "dc-inferring" type, not misclassified as "default" via instanceof. + assertThat(lb.get("type").asText()).isEqualTo("dc-inferring"); + assertThat(lb.get("token-aware").asBoolean()).isTrue(); + assertThat(lb.get("shuffle").asBoolean()).isTrue(); + } + + @Test + public void should_report_dse_load_balancing_policy_as_default() throws Exception { + // DseLoadBalancingPolicy is a deprecated, behavior-identical alias of + // DefaultLoadBalancingPolicy; must not fall through to "custom". Note: a real (non-mocked) + // instance of this class requires a resolvable local DC (it uses MandatoryLocalDcHelper) and + // would fail to construct with no DC configured, unlike DcInferringLoadBalancingPolicy below; + // the mock here bypasses that constructor validation, same as + // should_report_default_configuration. + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + mock(ExponentialReconnectionPolicy.class), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + mock(DseLoadBalancingPolicy.class), + mock(TimestampGenerator.class), + Optional.empty()); + assertThat(report(r).get("load-balancing-policy").get("type").asText()).isEqualTo("default"); + } + + @Test + public void should_report_dse_dc_inferring_load_balancing_policy() throws Exception { + // DseDcInferringLoadBalancingPolicy is a deprecated, behavior-identical alias of + // DcInferringLoadBalancingPolicy; must not fall through to "custom". + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + mock(ExponentialReconnectionPolicy.class), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + mock(DseDcInferringLoadBalancingPolicy.class), + mock(TimestampGenerator.class), + Optional.empty()); + assertThat(report(r).get("load-balancing-policy").get("type").asText()) + .isEqualTo("dc-inferring"); + } + + @Test + public void should_report_basic_load_balancing_policy() throws Exception { + // A real, distinct, documented third built-in (reference.conf lists exactly three); must be + // reported as its own "basic" type, not misclassified as "custom". + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + mock(ExponentialReconnectionPolicy.class), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + mock(BasicLoadBalancingPolicy.class), + mock(TimestampGenerator.class), + Optional.empty()); + JsonNode lb = report(r).get("load-balancing-policy"); + assertThat(lb.get("type").asText()).isEqualTo("basic"); + assertThat(lb.get("token-aware").asBoolean()).isTrue(); + assertThat(lb.get("shuffle").asBoolean()).isTrue(); + // Unlike DefaultLoadBalancingPolicy, BasicLoadBalancingPolicy has no slow-replica-avoidance + // mechanism at all: always false, regardless of the (default-policy-only) config option. + assertThat(lb.get("latency-awareness").asBoolean()).isFalse(); + } + + @Test + public void should_report_latency_awareness_disabled_when_slow_avoidance_is_off() + throws Exception { + DefaultDriverConfigReporter r = + defaultsReporter( + map -> map.put(TypedDriverOption.LOAD_BALANCING_POLICY_SLOW_AVOIDANCE, false)); + assertThat(report(r).get("load-balancing-policy").get("latency-awareness").asBoolean()) + .isFalse(); + } + + @Test + public void should_report_custom_load_balancing_policy() throws Exception { + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + mock(ExponentialReconnectionPolicy.class), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + mock(LoadBalancingPolicy.class), // not the default policy + mock(TimestampGenerator.class), + Optional.empty()); + JsonNode lb = report(r).get("load-balancing-policy"); + assertThat(lb.get("type").asText()).isEqualTo("custom"); + assertThat(lb.get("name").asText()).isNotEmpty(); + } + + @Test + public void should_report_explicit_local_dc() throws Exception { + DefaultDriverConfigReporter r = + defaultsReporter(map -> map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_DATACENTER, "dc1")); + JsonNode nodeLocation = report(r).get("node-location-preference"); + assertThat(nodeLocation.get("type").asText()).isEqualTo("dc"); + assertThat(nodeLocation.get("local-dc").asText()).isEqualTo("dc1"); + assertThat(nodeLocation.has("local-rack")).isFalse(); + } + + @Test + public void should_report_explicit_local_dc_and_rack() throws Exception { + DefaultDriverConfigReporter r = + defaultsReporter( + map -> { + map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_DATACENTER, "dc1"); + map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_RACK, "rack1"); + }); + JsonNode nodeLocation = report(r).get("node-location-preference"); + assertThat(nodeLocation.get("type").asText()).isEqualTo("rack"); + assertThat(nodeLocation.get("local-dc").asText()).isEqualTo("dc1"); + assertThat(nodeLocation.get("local-rack").asText()).isEqualTo("rack1"); + } + + @Test + public void should_report_local_dc_set_via_session_builder() throws Exception { + // SessionBuilder.withLocalDatacenter(...), not the config option: surfaced through + // InternalDriverContext.getLocalDatacenter(), which the reporter must consult. + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + mock(ExponentialReconnectionPolicy.class), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + mock(DefaultLoadBalancingPolicy.class), + mock(TimestampGenerator.class), + Optional.empty(), + /* programmaticLocalDc= */ "dc-programmatic"); + JsonNode nodeLocation = report(r).get("node-location-preference"); + assertThat(nodeLocation.get("type").asText()).isEqualTo("dc"); + assertThat(nodeLocation.get("local-dc").asText()).isEqualTo("dc-programmatic"); + } + + @Test + public void should_prefer_programmatic_local_dc_over_config_option() throws Exception { + DefaultDriverConfigReporter r = + reporterWith( + defaults( + map -> map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_DATACENTER, "dc-config")), + mock(ExponentialReconnectionPolicy.class), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + mock(DefaultLoadBalancingPolicy.class), + mock(TimestampGenerator.class), + Optional.empty(), + /* programmaticLocalDc= */ "dc-programmatic"); + JsonNode nodeLocation = report(r).get("node-location-preference"); + assertThat(nodeLocation.get("type").asText()).isEqualTo("dc"); + assertThat(nodeLocation.get("local-dc").asText()).isEqualTo("dc-programmatic"); + } + + @Test + public void should_report_rack_auto_when_only_rack_is_configured() throws Exception { + // Rack configured explicitly, but no DC (neither programmatically nor via config): the DC will + // be inferred, so this must not silently drop the explicitly-configured rack. + DefaultDriverConfigReporter r = + defaultsReporter(map -> map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_RACK, "rack1")); + JsonNode nodeLocation = report(r).get("node-location-preference"); + assertThat(nodeLocation.get("type").asText()).isEqualTo("rack-auto"); + assertThat(nodeLocation.get("local-rack").asText()).isEqualTo("rack1"); + assertThat(nodeLocation.has("local-dc")).isFalse(); + } + + @Test + public void should_report_server_side_timestamps_as_disabled_client_timestamps() + throws Exception { + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + mock(ExponentialReconnectionPolicy.class), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + mock(DefaultLoadBalancingPolicy.class), + mock(ServerSideTimestampGenerator.class), + Optional.empty()); + assertThat(report(r).get("query-defaults").get("client-timestamps").asBoolean()).isFalse(); + } + + @Test + public void should_report_tls_enabled_with_hostname_verification() throws Exception { + // hostname-verification comes from the factory's own accessor, not the config option. + SslEngineFactory factory = mock(SslEngineFactory.class); + when(factory.isHostnameValidationRequired()).thenReturn(true); + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + mock(ExponentialReconnectionPolicy.class), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + mock(DefaultLoadBalancingPolicy.class), + mock(TimestampGenerator.class), + Optional.of(factory)); + JsonNode tls = report(r).get("tls"); + assertThat(tls.get("enabled").asBoolean()).isTrue(); + assertThat(tls.get("hostname-verification").asBoolean()).isTrue(); + } + + @Test + public void should_report_hostname_verification_from_factory_not_config_option() + throws Exception { + // Regression for the false-report bug: a ProgrammaticSslEngineFactory (as built by + // SessionBuilder.withSslContext(...)) does NO hostname validation by default and ignores the + // SSL_HOSTNAME_VALIDATION config option. The report must reflect the factory's real state + // (false), not the config option (true here) — otherwise it falsely claims validation is on. + SslEngineFactory programmatic = new ProgrammaticSslEngineFactory(SSLContext.getDefault()); + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> map.put(TypedDriverOption.SSL_HOSTNAME_VALIDATION, true)), + mock(ExponentialReconnectionPolicy.class), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + mock(DefaultLoadBalancingPolicy.class), + mock(TimestampGenerator.class), + Optional.of(programmatic)); + JsonNode tls = report(r).get("tls"); + assertThat(tls.get("enabled").asBoolean()).isTrue(); + assertThat(tls.get("hostname-verification").asBoolean()).isFalse(); + } + + @Test + public void should_report_tls_enabled_for_a_custom_ssl_handler_factory() throws Exception { + // Overriding DefaultDriverContext.buildSslHandlerFactory() is the driver's documented low-level + // SSL extension point (e.g. Netty's native OpenSSL), and such an override supplies no + // SslEngineFactory at all. That session is still encrypted, so tls.enabled must be read from + // the handler factory — the same reference ChannelFactory installs the SSL handler from — and + // not from getSslEngineFactory(). Host name validation is a property of the JDK SSLEngine and + // cannot be read on this path, so it stays conservatively false instead of over-reported. + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> map.put(TypedDriverOption.SSL_HOSTNAME_VALIDATION, true)), + mock(ExponentialReconnectionPolicy.class), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + mock(DefaultLoadBalancingPolicy.class), + mock(TimestampGenerator.class), + /* ssl= */ Optional.empty(), + Optional.of(mock(SslHandlerFactory.class)), + /* programmaticLocalDc= */ null); + JsonNode tls = report(r).get("tls"); + assertThat(tls.get("enabled").asBoolean()).isTrue(); + assertThat(tls.get("hostname-verification").asBoolean()).isFalse(); + } + + @Test + public void should_not_report_hostname_verification_from_an_unused_engine_factory() + throws Exception { + // The handler factory and the engine factory are independent: a context can override + // buildSslHandlerFactory() (so the pipeline gets a handler the driver knows nothing about) and + // still have advanced.ssl-engine-factory.class configured, leaving a fully built engine factory + // that nothing on the connection path ever consults. Reading it would claim host name + // validation the custom handler does not perform, so hostname-verification stays false unless + // the handler in force is the driver's own JdkSslHandlerFactory. + SslEngineFactory validating = mock(SslEngineFactory.class); + when(validating.isHostnameValidationRequired()).thenReturn(true); + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + mock(ExponentialReconnectionPolicy.class), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + mock(DefaultLoadBalancingPolicy.class), + mock(TimestampGenerator.class), + Optional.of(validating), + Optional.of(mock(SslHandlerFactory.class)), + /* programmaticLocalDc= */ null); + JsonNode tls = report(r).get("tls"); + assertThat(tls.get("enabled").asBoolean()).isTrue(); + assertThat(tls.get("hostname-verification").asBoolean()).isFalse(); + } + + @Test + public void should_report_socket_overrides() throws Exception { + DefaultDriverConfigReporter r = + defaultsReporter( + map -> { + map.put(TypedDriverOption.SOCKET_KEEP_ALIVE, true); + map.put(TypedDriverOption.SOCKET_RECEIVE_BUFFER_SIZE, 65535); + map.put(TypedDriverOption.SOCKET_LINGER_INTERVAL, 5); + }); + JsonNode socket = report(r).get("socket"); + assertThat(socket.get("keep-alive").asBoolean()).isTrue(); + assertThat(socket.get("receive-buffer").get("size-bytes").asInt()).isEqualTo(65535); + assertThat(socket.get("linger").get("interval-s").asInt()).isEqualTo(5); + } + + // ==================== Values the schema cannot express ==================== + // + // Options whose "disabled" value falls outside the schema's positive-integer constraint. Where + // the field is optional the group is omitted (as "page" already is when paging is unbounded); + // where it is required, the real value is reported even though that document fails validation — + // see the reporter's class javadoc. + + @Test + public void should_omit_linger_when_disabled() throws Exception { + // A negative interval means SO_LINGER is off, which the schema's non-negative interval-s + // cannot express. + DefaultDriverConfigReporter r = + defaultsReporter(map -> map.put(TypedDriverOption.SOCKET_LINGER_INTERVAL, -1)); + assertThat(report(r).get("socket").has("linger")).isFalse(); + } + + @Test + public void should_report_zero_linger_interval() throws Exception { + // 0 is a real setting (close immediately), not a disabled sentinel, and the schema allows + // it: it must survive the guard above. + DefaultDriverConfigReporter r = + defaultsReporter(map -> map.put(TypedDriverOption.SOCKET_LINGER_INTERVAL, 0)); + assertThat(report(r).get("socket").get("linger").get("interval-s").asInt()).isZero(); + } + + @Test + public void should_omit_socket_buffers_when_not_positive() throws Exception { + DefaultDriverConfigReporter r = + defaultsReporter( + map -> { + map.put(TypedDriverOption.SOCKET_RECEIVE_BUFFER_SIZE, 0); + map.put(TypedDriverOption.SOCKET_SEND_BUFFER_SIZE, 0); + }); + JsonNode socket = report(r).get("socket"); + assertThat(socket.has("receive-buffer")).isFalse(); + assertThat(socket.has("send-buffer")).isFalse(); + } + + @Test + public void should_omit_client_side_timeout_when_control_connection_timeout_is_disabled() + throws Exception { + DefaultDriverConfigReporter r = + defaultsReporter( + map -> map.put(TypedDriverOption.CONTROL_CONNECTION_TIMEOUT, Duration.ZERO)); + JsonNode systemQueries = report(r).get("control-plane").get("system-queries"); + // The field is optional, but its enclosing "timeout" object is required, so it stays present. + assertThat(systemQueries.has("timeout")).isTrue(); + assertThat(systemQueries.get("timeout").has("client-side-ms")).isFalse(); + } + + @Test + public void should_omit_client_side_timeout_when_it_rounds_down_to_zero() throws Exception { + // The limit is measured on the emitted milliseconds, so a sub-millisecond timeout — active as + // far as the driver is concerned — is indistinguishable from disabled and is omitted rather + // than rounded up to a value that was never configured. + DefaultDriverConfigReporter r = + defaultsReporter( + map -> + map.put(TypedDriverOption.CONTROL_CONNECTION_TIMEOUT, Duration.ofNanos(500_000))); + assertThat( + report(r) + .get("control-plane") + .get("system-queries") + .get("timeout") + .has("client-side-ms")) + .isFalse(); + } + + @Test + public void should_omit_server_side_timeout_when_schema_request_timeout_is_disabled() + throws Exception { + DefaultDriverConfigReporter r = + defaultsReporter( + map -> map.put(TypedDriverOption.METADATA_SCHEMA_REQUEST_TIMEOUT, Duration.ZERO)); + // Reported against ScyllaDB, so the field would otherwise be present. + assertThat( + report(r, shardingInfo(4)) + .get("control-plane") + .get("system-queries") + .get("timeout") + .has("server-side-ms")) + .isFalse(); + } + + @Test + public void should_clamp_negative_schema_agreement_timeout_to_zero() throws Exception { + // Required and non-negative in the schema. A negative timeout behaves exactly like 0 (the first + // pass is already past the deadline), so normalizing is exact rather than invented. + DefaultDriverConfigReporter r = + defaultsReporter( + map -> + map.put( + TypedDriverOption.CONTROL_CONNECTION_AGREEMENT_TIMEOUT, + Duration.ofSeconds(-1))); + assertThat(report(r).get("control-plane").get("schema-agreement").get("timeout-ms").asLong()) + .isZero(); + } + + @Test + public void should_omit_max_requests_when_not_positive() throws Exception { + DefaultDriverConfigReporter r = + defaultsReporter(map -> map.put(TypedDriverOption.CONNECTION_MAX_REQUESTS, 0)); + assertThat(report(r).get("connection-pool").has("connection")).isFalse(); + } + + @Test + public void should_report_a_disabled_request_timeout_as_zero_even_though_the_schema_forbids_it() + throws Exception { + // basic.request.timeout = 0 legally disables the request timeout, but query-defaults.request is + // required, its timeout-ms is required, and the schema constrains it to a positive integer — so + // there is no schema-valid way to say "disabled". The reporter tells the truth instead of + // fabricating a value or dropping the whole report, which means this one document is knowingly + // invalid. Pinned here so the trade-off is visible; when the schema gains a representation + // for a disabled timeout, update this test rather than the reporter's honesty. + DefaultDriverConfigReporter r = + defaultsReporter(map -> map.put(TypedDriverOption.REQUEST_TIMEOUT, Duration.ZERO)); + JsonNode report = report(r); + assertThat(report.get("query-defaults").get("request").get("timeout-ms").asLong()).isZero(); + assertThat(SCHEMA.validate(report)) + .as("the v1 schema cannot represent a disabled request timeout") + .isNotEmpty(); + } + + @Test + public void should_report_a_zero_constant_reconnection_delay_even_though_the_schema_forbids_it() + throws Exception { + // ConstantReconnectionPolicy rejects only a negative base delay, so 0 is a legal setting + // (reconnect immediately, no backoff) — unlike ExponentialReconnectionPolicy, which requires a + // strictly positive base. reconnection-policy is required, its delay-ms is required, and the + // schema constrains it to a positive integer, so there is no schema-valid way to say "no + // delay". Same trade-off as the disabled request timeout above: report the truth rather than + // fabricate a 1ms delay nobody configured, or drop the whole report over one field. + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> map.put(TypedDriverOption.RECONNECTION_BASE_DELAY, Duration.ZERO)), + mock(ConstantReconnectionPolicy.class), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + mock(DefaultLoadBalancingPolicy.class), + mock(TimestampGenerator.class), + Optional.empty()); + JsonNode report = report(r); + JsonNode reconnection = report.get("reconnection-policy"); + assertThat(reconnection.get("type").asText()).isEqualTo("constant"); + assertThat(reconnection.get("delay-ms").asLong()).isZero(); + assertThat(SCHEMA.validate(report)) + .as("the v1 schema cannot represent a zero constant reconnection delay") + .isNotEmpty(); + } + + @Test + public void should_report_a_zero_speculative_execution_delay_even_though_the_schema_forbids_it() + throws Exception { + // ConstantSpeculativeExecutionPolicy explicitly allows a zero delay ("Delay must be positive or + // 0"), meaning every speculative execution fires at once. Its delay-ms is required and + // positive-only in the schema, so this is the same knowingly-invalid-document trade-off as the + // constant reconnection delay above. + DefaultDriverConfigReporter r = + reporterWith( + defaults( + map -> { + map.put(TypedDriverOption.SPECULATIVE_EXECUTION_MAX, 3); + map.put(TypedDriverOption.SPECULATIVE_EXECUTION_DELAY, Duration.ZERO); + }), + mock(ExponentialReconnectionPolicy.class), + mock(DefaultRetryPolicy.class), + mock(ConstantSpeculativeExecutionPolicy.class), + mock(DefaultLoadBalancingPolicy.class), + mock(TimestampGenerator.class), + Optional.empty()); + JsonNode report = report(r); + JsonNode specExec = report.get("speculative-execution-policy"); + assertThat(specExec.get("type").asText()).isEqualTo("constant"); + assertThat(specExec.get("delay-ms").asLong()).isZero(); + assertThat(SCHEMA.validate(report)) + .as("the v1 schema cannot represent a zero speculative execution delay") + .isNotEmpty(); + } + + @Test + public void should_omit_page_when_unbounded() throws Exception { + DefaultDriverConfigReporter r = + defaultsReporter(map -> map.put(TypedDriverOption.REQUEST_PAGE_SIZE, 0)); + // The schema has no "unbounded" sentinel: the whole page group is omitted instead. + assertThat(report(r).get("query-defaults").has("page")).isFalse(); + } + + @Test + public void should_report_bounded_page_size() throws Exception { + DefaultDriverConfigReporter r = + defaultsReporter(map -> map.put(TypedDriverOption.REQUEST_PAGE_SIZE, 5000)); + assertThat(report(r).get("query-defaults").get("page").get("size").asInt()).isEqualTo(5000); + } + + @Test + public void should_report_host_keyed_pool_on_cassandra() throws Exception { + // No sharding info: one pool per host, and the configured size is that pool's size verbatim. + DefaultDriverConfigReporter r = + defaultsReporter(map -> map.put(TypedDriverOption.CONNECTION_POOL_LOCAL_SIZE, 8)); + JsonNode pool = report(r).get("connection-pool"); + assertThat(pool.get("type").asText()).isEqualTo("host"); + assertThat(pool.get("desired-connections-count").asInt()).isEqualTo(8); + } + + @Test + public void should_report_shard_keyed_pool_on_scylladb() throws Exception { + // ChannelPool allocates one ChannelSet per shard and spreads CONNECTION_POOL_LOCAL_SIZE over + // them, so the pool is shard-keyed and the reported count is the per-shard one: 8 over 4 shards + // is 2 each. Reporting "host" with 8 here would misstate both the unit and the number. + DefaultDriverConfigReporter r = + defaultsReporter(map -> map.put(TypedDriverOption.CONNECTION_POOL_LOCAL_SIZE, 8)); + JsonNode pool = report(r, shardingInfo(4)).get("connection-pool"); + assertThat(pool.get("type").asText()).isEqualTo("shard"); + assertThat(pool.get("desired-connections-count").asInt()).isEqualTo(2); + } + + @Test + public void should_round_the_per_shard_connection_count_up() throws Exception { + // Mirrors ChannelPool.connectionsPerShard: at least one connection per shard for any non-zero + // configured size, which is why the default size of 1 reports 1 (per shard) and not a fraction. + assertThat(perShardCount(1, 4)).isEqualTo(1); + assertThat(perShardCount(8, 3)).isEqualTo(3); + assertThat(perShardCount(8, 4)).isEqualTo(2); + assertThat(perShardCount(5, 4)).isEqualTo(2); + assertThat(perShardCount(1, 1)).isEqualTo(1); + } + + /** The reported {@code desired-connections-count} for a pool size over a shard count. */ + private int perShardCount(int localSize, int shardsCount) throws Exception { + DefaultDriverConfigReporter r = + defaultsReporter(map -> map.put(TypedDriverOption.CONNECTION_POOL_LOCAL_SIZE, localSize)); + return report(r, shardingInfo(shardsCount)) + .get("connection-pool") + .get("desired-connections-count") + .asInt(); + } + + // ==================== Schema conformance ==================== + // + // These build a config, serialize it via the reporter, and validate the produced JSON against the + // normative v1 JSON Schema (the same document ScyllaDB uses to interpret DRIVER_CONFIG). They + // cover every discriminated-union branch and optional-group case the reporter can emit, so that + // schema conformance is enforced rather than assumed. + // + // Conformance is not unconditional: where a required schema field is positive-only and its driver + // option legitimately admits 0, the reporter emits the real value and the document does not + // validate. Those cases are deliberate and pinned in the "Values the schema cannot express" + // section above, each asserting the violation explicitly — see the reporter's class javadoc. + + @Test + public void should_conform_to_schema_for_default_report() throws Exception { + assertConformsToSchema(report(defaultsReporter(map -> {}))); + } + + @Test + public void should_conform_to_schema_for_default_report_on_scylladb() throws Exception { + // Sharding info adds control-plane.system-queries.timeout.server-side-ms, and flips the + // connection pool to the shard-keyed variant. + assertConformsToSchema(report(defaultsReporter(map -> {}), shardingInfo(4))); + } + + @Test + public void should_conform_to_schema_for_constant_reconnection_policy() throws Exception { + assertConformsToSchema( + report( + reporterWith( + defaults(map -> {}), + mock(ConstantReconnectionPolicy.class), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + mock(DefaultLoadBalancingPolicy.class), + mock(TimestampGenerator.class), + Optional.empty()))); + } + + @Test + public void should_conform_to_schema_for_custom_reconnection_policy() throws Exception { + assertConformsToSchema( + report( + reporterWith( + defaults(map -> {}), + mock(ReconnectionPolicy.class), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + mock(DefaultLoadBalancingPolicy.class), + mock(TimestampGenerator.class), + Optional.empty()))); + } + + @Test + public void should_conform_to_schema_for_downgrading_consistency_retry_policy() throws Exception { + assertConformsToSchema( + report( + reporterWith( + defaults(map -> {}), + mock(ExponentialReconnectionPolicy.class), + mock(ConsistencyDowngradingRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + mock(DefaultLoadBalancingPolicy.class), + mock(TimestampGenerator.class), + Optional.empty()))); + } + + @Test + public void should_conform_to_schema_for_custom_retry_policy() throws Exception { + assertConformsToSchema( + report( + reporterWith( + defaults(map -> {}), + mock(ExponentialReconnectionPolicy.class), + mock(RetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + mock(DefaultLoadBalancingPolicy.class), + mock(TimestampGenerator.class), + Optional.empty()))); + } + + @Test + public void should_conform_to_schema_for_constant_speculative_execution_policy() + throws Exception { + assertConformsToSchema( + report( + reporterWith( + defaults( + map -> { + map.put(TypedDriverOption.SPECULATIVE_EXECUTION_MAX, 3); + map.put( + TypedDriverOption.SPECULATIVE_EXECUTION_DELAY, Duration.ofMillis(100)); + }), + mock(ExponentialReconnectionPolicy.class), + mock(DefaultRetryPolicy.class), + mock(ConstantSpeculativeExecutionPolicy.class), + mock(DefaultLoadBalancingPolicy.class), + mock(TimestampGenerator.class), + Optional.empty()))); + } + + @Test + public void should_conform_to_schema_for_basic_load_balancing_policy() throws Exception { + assertConformsToSchema( + report( + reporterWith( + defaults(map -> {}), + mock(ExponentialReconnectionPolicy.class), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + mock(BasicLoadBalancingPolicy.class), + mock(TimestampGenerator.class), + Optional.empty()))); + } + + @Test + public void should_conform_to_schema_for_custom_load_balancing_policy() throws Exception { + assertConformsToSchema( + report( + reporterWith( + defaults(map -> {}), + mock(ExponentialReconnectionPolicy.class), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + mock(LoadBalancingPolicy.class), + mock(TimestampGenerator.class), + Optional.empty()))); + } + + @Test + public void should_conform_to_schema_for_explicit_dc_and_rack() throws Exception { + assertConformsToSchema( + report( + defaultsReporter( + map -> { + map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_DATACENTER, "dc1"); + map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_RACK, "rack1"); + }))); + } + + @Test + public void should_conform_to_schema_for_rack_auto_node_location() throws Exception { + assertConformsToSchema( + report( + defaultsReporter( + map -> map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_RACK, "rack1")))); + } + + @Test + public void should_conform_to_schema_for_tls_enabled_with_hostname_verification() + throws Exception { + SslEngineFactory factory = mock(SslEngineFactory.class); + when(factory.isHostnameValidationRequired()).thenReturn(true); + assertConformsToSchema( + report( + reporterWith( + defaults(map -> {}), + mock(ExponentialReconnectionPolicy.class), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + mock(DefaultLoadBalancingPolicy.class), + mock(TimestampGenerator.class), + Optional.of(factory)))); + } + + @Test + public void should_conform_to_schema_for_socket_overrides() throws Exception { + assertConformsToSchema( + report( + defaultsReporter( + map -> { + map.put(TypedDriverOption.SOCKET_KEEP_ALIVE, true); + map.put(TypedDriverOption.SOCKET_RECEIVE_BUFFER_SIZE, 65535); + map.put(TypedDriverOption.SOCKET_SEND_BUFFER_SIZE, 65535); + map.put(TypedDriverOption.SOCKET_LINGER_INTERVAL, 5); + }))); + } + + @Test + public void should_conform_to_schema_for_shard_keyed_pool() throws Exception { + assertConformsToSchema( + report( + defaultsReporter(map -> map.put(TypedDriverOption.CONNECTION_POOL_LOCAL_SIZE, 8)), + shardingInfo(3))); + } + + @Test + public void should_conform_to_schema_when_all_optional_timeouts_are_disabled() throws Exception { + // The case the omission guards exist for: every control-plane timeout that can be turned off + // is, leaving system-queries.timeout an empty object — which validates, since that object has + // no required keys. Built with sharding info so the ScyllaDB-only branch is exercised too. + assertConformsToSchema( + report( + defaultsReporter( + map -> { + map.put(TypedDriverOption.CONTROL_CONNECTION_TIMEOUT, Duration.ZERO); + map.put(TypedDriverOption.METADATA_SCHEMA_REQUEST_TIMEOUT, Duration.ZERO); + map.put(TypedDriverOption.CONTROL_CONNECTION_AGREEMENT_TIMEOUT, Duration.ZERO); + }), + shardingInfo(4))); + } + + @Test + public void should_conform_to_schema_when_optional_socket_and_pool_values_are_disabled() + throws Exception { + assertConformsToSchema( + report( + defaultsReporter( + map -> { + map.put(TypedDriverOption.SOCKET_LINGER_INTERVAL, -1); + map.put(TypedDriverOption.SOCKET_RECEIVE_BUFFER_SIZE, 0); + map.put(TypedDriverOption.SOCKET_SEND_BUFFER_SIZE, 0); + map.put(TypedDriverOption.CONNECTION_MAX_REQUESTS, 0); + map.put(TypedDriverOption.REQUEST_PAGE_SIZE, 0); + }))); + } + + @Test + public void should_reject_a_report_that_violates_the_schema() throws Exception { + // Sanity check that the validator actually enforces the schema (rather than accepting + // anything): an unknown top-level key must be rejected, since the schema sets + // additionalProperties to false. + ObjectNode report = (ObjectNode) report(defaultsReporter(map -> {})); + report.put("bogus-unknown-key", "x"); + assertThat(SCHEMA.validate(report)).as("unknown top-level key must be rejected").isNotEmpty(); + } + + // ==================== helpers ==================== + + private void assertConformsToSchema(JsonNode report) { + Set errors = SCHEMA.validate(report); + assertThat(errors).as("schema violations in %s", report).isEmpty(); + } + + /** A report built as if the peer were not ScyllaDB (no sharding information advertised). */ + private JsonNode report(DefaultDriverConfigReporter reporter) throws Exception { + return report(reporter, /* shardingInfo= */ null); + } + + private JsonNode report(DefaultDriverConfigReporter reporter, NodeShardingInfo shardingInfo) + throws Exception { + return MAPPER.readTree(reporter.buildJson(shardingInfo)); + } + + /** + * Sharding information as ScyllaDB would advertise it, carrying the given shard count — the only + * thing the report reads from it. Mocking the public {@link NodeShardingInfo} interface rather + * than parsing a {@code SUPPORTED} map keeps these tests independent of the {@code SCYLLA_*} + * protocol keys, whose parsing and unwrapping {@code ProtocolInitHandlerTest} covers end to end. + */ + private static NodeShardingInfo shardingInfo(int shardsCount) { + NodeShardingInfo info = mock(NodeShardingInfo.class); + when(info.getShardsCount()).thenReturn(shardsCount); + return info; + } + + /** A real default execution profile with the given customizations applied. */ + private DriverExecutionProfile defaults(Consumer customizer) { + OptionsMap map = OptionsMap.driverDefaults(); + customizer.accept(map); + return DriverConfigLoader.fromMap(map).getInitialConfig().getDefaultProfile(); + } + + /** Reporter over default config + the Java-default policy set. */ + private DefaultDriverConfigReporter defaultsReporter(Consumer customizer) { + return reporterWith( + defaults(customizer), + mock(ExponentialReconnectionPolicy.class), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + mock(DefaultLoadBalancingPolicy.class), + mock(TimestampGenerator.class), + Optional.empty()); + } + + private DefaultDriverConfigReporter reporterWith( + DriverExecutionProfile profile, + ReconnectionPolicy reconnection, + RetryPolicy retry, + SpeculativeExecutionPolicy speculative, + LoadBalancingPolicy loadBalancing, + TimestampGenerator timestamps, + Optional ssl) { + return reporterWith( + profile, reconnection, retry, speculative, loadBalancing, timestamps, ssl, null); + } + + /** Same as the 7-arg overload, with an optional programmatic ({@code withLocalDatacenter}) DC. */ + private DefaultDriverConfigReporter reporterWith( + DriverExecutionProfile profile, + ReconnectionPolicy reconnection, + RetryPolicy retry, + SpeculativeExecutionPolicy speculative, + LoadBalancingPolicy loadBalancing, + TimestampGenerator timestamps, + Optional ssl, + String programmaticLocalDc) { + // tls.enabled reads the low-level handler factory, which DefaultDriverContext derives from the + // engine factory when SSL was configured through the public API; mirror that wrapping here. + return reporterWith( + profile, + reconnection, + retry, + speculative, + loadBalancing, + timestamps, + ssl, + ssl.map(JdkSslHandlerFactory::new), + programmaticLocalDc); + } + + /** + * Same as the 8-arg overload, with the SSL handler factory set independently of the engine + * factory — as an override of {@code DefaultDriverContext.buildSslHandlerFactory()} would. + */ + private DefaultDriverConfigReporter reporterWith( + DriverExecutionProfile profile, + ReconnectionPolicy reconnection, + RetryPolicy retry, + SpeculativeExecutionPolicy speculative, + LoadBalancingPolicy loadBalancing, + TimestampGenerator timestamps, + Optional ssl, + Optional sslHandler, + String programmaticLocalDc) { + InternalDriverContext ctx = mock(InternalDriverContext.class); + DriverConfig config = mock(DriverConfig.class); + when(ctx.getConfig()).thenReturn(config); + when(config.getDefaultProfile()).thenReturn(profile); + when(ctx.getReconnectionPolicy()).thenReturn(reconnection); + when(ctx.getRetryPolicy(DriverExecutionProfile.DEFAULT_NAME)).thenReturn(retry); + when(ctx.getSpeculativeExecutionPolicy(DriverExecutionProfile.DEFAULT_NAME)) + .thenReturn(speculative); + when(ctx.getLoadBalancingPolicy(DriverExecutionProfile.DEFAULT_NAME)).thenReturn(loadBalancing); + when(ctx.getTimestampGenerator()).thenReturn(timestamps); + when(ctx.getSslEngineFactory()).thenReturn(ssl); + when(ctx.getSslHandlerFactory()).thenReturn(sslHandler); + when(ctx.getLocalDatacenter(DriverExecutionProfile.DEFAULT_NAME)) + .thenReturn(programmaticLocalDc); + return new DefaultDriverConfigReporter(ctx); + } + + /** A minimal {@link DriverContext} good enough to construct a real built-in policy instance. */ + private DriverContext policyConstructionContext() { + DriverContext ctx = mock(DriverContext.class); + DriverConfig config = mock(DriverConfig.class); + DriverExecutionProfile profile = defaults(map -> {}); + when(ctx.getConfig()).thenReturn(config); + when(config.getDefaultProfile()).thenReturn(profile); + when(config.getProfile(DriverExecutionProfile.DEFAULT_NAME)).thenReturn(profile); + when(ctx.getSessionName()).thenReturn("test-session"); + return ctx; } } diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/context/StartupOptionsBuilderTest.java b/core/src/test/java/com/datastax/oss/driver/internal/core/context/StartupOptionsBuilderTest.java index 2f8f4174093..963e9954592 100644 --- a/core/src/test/java/com/datastax/oss/driver/internal/core/context/StartupOptionsBuilderTest.java +++ b/core/src/test/java/com/datastax/oss/driver/internal/core/context/StartupOptionsBuilderTest.java @@ -30,6 +30,7 @@ import com.tngtech.java.junit.dataprovider.DataProvider; import com.tngtech.java.junit.dataprovider.DataProviderRunner; import java.util.Optional; +import java.util.UUID; import org.junit.Test; import org.junit.runner.RunWith; @@ -54,6 +55,10 @@ private void assertDefaultStartupOptions(Startup startup) { assertThat(startup.options).containsKey(StartupOptionsBuilder.DRIVER_VERSION_KEY); Version version = Version.parse(startup.options.get(StartupOptionsBuilder.DRIVER_VERSION_KEY)); assertThat(version).isEqualByComparingTo(Session.OSS_DRIVER_COORDINATES.getVersion()); + // SESSION_ID is innate: sent on every connection, whatever the configuration says. + assertThat(startup.options).containsKey(StartupOptionsBuilder.SESSION_ID_KEY); + assertThat(UUID.fromString(startup.options.get(StartupOptionsBuilder.SESSION_ID_KEY))) + .isNotNull(); } @Test @@ -85,6 +90,35 @@ public void should_build_startup_options(String compression) { assertDefaultStartupOptions(startup); } + @Test + public void should_use_a_stable_session_id_for_the_whole_session() { + + // The startup options are built once per session and copied into every connection's STARTUP, so + // all of a session's connections report the same SESSION_ID. + DefaultDriverContext ctx = MockedDriverContextFactory.defaultDriverContext(); + assertThat(ctx.getStartupOptions().get(StartupOptionsBuilder.SESSION_ID_KEY)) + .isEqualTo(ctx.getStartupOptions().get(StartupOptionsBuilder.SESSION_ID_KEY)); + } + + @Test + public void should_use_a_distinct_session_id_per_session() { + + DefaultDriverContext ctx1 = MockedDriverContextFactory.defaultDriverContext(); + DefaultDriverContext ctx2 = MockedDriverContextFactory.defaultDriverContext(); + assertThat(ctx1.getStartupOptions().get(StartupOptionsBuilder.SESSION_ID_KEY)) + .isNotEqualTo(ctx2.getStartupOptions().get(StartupOptionsBuilder.SESSION_ID_KEY)); + } + + @Test + public void should_not_derive_session_id_from_client_id() { + + // SESSION_ID must be driver-generated, not the (user-settable) CLIENT_ID, so that it is + // guaranteed unique per session as the grouping key requires. + DefaultDriverContext ctx = MockedDriverContextFactory.defaultDriverContext(); + assertThat(ctx.getStartupOptions().get(StartupOptionsBuilder.SESSION_ID_KEY)) + .isNotEqualTo(ctx.getStartupOptions().get(StartupOptionsBuilder.CLIENT_ID_KEY)); + } + @Test public void should_fail_to_build_startup_options_with_invalid_compression() { diff --git a/core/src/test/resources/config/driver-config-report-v1.schema.json b/core/src/test/resources/config/driver-config-report-v1.schema.json new file mode 100644 index 00000000000..b2b5886bb7c --- /dev/null +++ b/core/src/test/resources/config/driver-config-report-v1.schema.json @@ -0,0 +1,846 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://scylladb.com/schemas/driver-client-options/v1.json", + "title": "ScyllaDB driver DRIVER_CONFIG configuration", + "description": "Schema for the JSON value sent under the STARTUP option key DRIVER_CONFIG, describing the effective client configuration. The top-level object must include `version` and the required configuration groups listed by this schema. Unknown top-level keys are rejected. Built-in groups reject unknown keys and require the keys listed in each group; custom policy objects may include additional implementation-specific public attributes where explicitly allowed.", + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "connection", + "socket", + "control-plane", + "reconnection-policy", + "retry-policy", + "load-balancing-policy", + "connection-pool", + "query-defaults", + "tls" + ], + "properties": { + "version": { + "description": "Major schema version. Adding keys is backward-compatible and does not bump this; only changing/removing the meaning of an existing key does.", + "type": "integer", + "const": 1 + }, + "connection": { + "$ref": "#/$defs/connection" + }, + "socket": { + "$ref": "#/$defs/socket" + }, + "control-plane": { + "$ref": "#/$defs/control-plane" + }, + "reconnection-policy": { + "$ref": "#/$defs/reconnection-policy" + }, + "retry-policy": { + "$ref": "#/$defs/retry-policy" + }, + "speculative-execution-policy": { + "$ref": "#/$defs/speculative-execution-policy" + }, + "load-balancing-policy": { + "$ref": "#/$defs/load-balancing-policy" + }, + "node-location-preference": { + "$ref": "#/$defs/node-location-preference" + }, + "connection-pool": { + "$ref": "#/$defs/connection-pool" + }, + "query-defaults": { + "$ref": "#/$defs/query-defaults" + }, + "tls": { + "$ref": "#/$defs/tls" + } + }, + "$defs": { + "positiveInteger": { + "type": "integer", + "minimum": 1 + }, + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "retryPolicyBackoff": { + "description": "Delay inserted between retry attempts of a retry policy. Discriminated union: when present, `type` selects the backoff algorithm and each algorithm carries only its own parameters. Absent when there is no delay between attempts.", + "oneOf": [ + { + "type": "object", + "description": "Exponential backoff: the delay starts at base-ms and doubles after each attempt (capped at max-ms), with a small random jitter to de-synchronize concurrent retries.", + "additionalProperties": false, + "required": [ + "type", + "base-ms" + ], + "properties": { + "type": { + "const": "exponential", + "description": "Exponential backoff algorithm." + }, + "base-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Initial delay between retries in milliseconds; the starting delay that doubles each attempt." + }, + "max-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Maximum delay between retries in milliseconds; the exponentially growing delay is capped here. Absent when no maximum delay is configured." + } + } + }, + { + "type": "object", + "description": "Constant backoff: a fixed delay is inserted between every retry attempt.", + "additionalProperties": false, + "required": [ + "type", + "delay-ms" + ], + "properties": { + "type": { + "const": "constant", + "description": "Constant (fixed-delay) backoff algorithm." + }, + "delay-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Fixed delay between retries in milliseconds." + } + } + } + ] + }, + "connection": { + "description": "Connection-level settings: socket read/write/connect timeouts plus the CQL-level idle heartbeat. Durations are in milliseconds. Optional duration fields are absent when unset or not applicable.", + "type": "object", + "required": [ + "connect" + ], + "additionalProperties": false, + "properties": { + "connect": { + "type": "object", + "description": "Settings for establishing a TCP/CQL connection to a node.", + "required": [ + "timeout-ms" + ], + "additionalProperties": false, + "properties": { + "timeout-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Timeout for establishing a TCP/CQL connection to a node." + } + } + }, + "read": { + "type": "object", + "description": "Settings for reading from a connection.", + "additionalProperties": false, + "properties": { + "timeout-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Read operation timeout." + } + } + }, + "write": { + "type": "object", + "description": "Settings for writing to a connection. Direction-specific options such as write coalescing are expected to be added here in a future schema version.", + "additionalProperties": false, + "properties": { + "coalescing": { + "type": "object", + "description": "Settings for write coalescing. It is a placeholder for v2", + "additionalProperties": false, + "properties": {} + }, + "timeout-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Write operation timeout." + } + } + }, + "heartbeat": { + "type": "object", + "description": "Reserved for CQL-level idle heartbeat settings. Optional and intentionally empty in this schema version. It is a placeholder for v2", + "additionalProperties": false, + "properties": {} + } + } + }, + "control-plane": { + "description": "Control-plane timeout settings for internal/system queries run over the control connection and for schema agreement. Each value is in milliseconds. Optional values are absent when unset or not applicable.", + "type": "object", + "required": [ + "system-queries", + "schema-agreement" + ], + "additionalProperties": false, + "properties": { + "system-queries": { + "type": "object", + "description": "Settings for internal/system queries run over the control connection.", + "additionalProperties": false, + "required": [ + "timeout" + ], + "properties": { + "timeout": { + "type": "object", + "description": "Timeouts applied to internal/system queries. Each value is in milliseconds. Optional values are absent when unset or not applicable.", + "additionalProperties": false, + "properties": { + "client-side-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "A client-side timeout for internal queries." + }, + "server-side-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "A server-side timeout for internal queries." + } + } + } + } + }, + "schema-agreement": { + "type": "object", + "description": "Settings for schema agreement across nodes.", + "additionalProperties": false, + "required": [ + "timeout-ms" + ], + "properties": { + "timeout-ms": { + "$ref": "#/$defs/nonNegativeInteger", + "description": "Maximum time to wait for schema agreement across nodes. Always a concrete value; 0 means do not wait for agreement." + } + } + } + } + }, + "socket": { + "description": "Low-level TCP socket options applied to client connections. Boolean options (tcp-no-delay, keep-alive, reuse-address) report the effective on/off state: when no explicit value is configured, the OS/platform default is reported. Buffer sizes are in bytes and linger is in seconds; these fields are absent when unset (kernel auto-tuned buffer / linger disabled).", + "type": "object", + "required": [ + "tcp-no-delay", + "keep-alive", + "reuse-address" + ], + "additionalProperties": false, + "properties": { + "tcp-no-delay": { + "type": "boolean", + "description": "TCP_NODELAY: disable Nagle's algorithm. Reports the effective value; when no explicit value is configured, the OS/platform default is reported." + }, + "keep-alive": { + "type": "boolean", + "description": "SO_KEEPALIVE: OS-level TCP keep-alive probes on idle connections. Reports the effective on/off state; when no explicit value is configured, the OS/platform default is reported." + }, + "reuse-address": { + "type": "boolean", + "description": "SO_REUSEADDR: allow reuse of a local address. Reports the effective on/off state; when no explicit value is configured, the OS/platform default is reported." + }, + "linger": { + "type": "object", + "required": [ + "interval-s" + ], + "additionalProperties": false, + "properties": { + "interval-s": { + "$ref": "#/$defs/nonNegativeInteger", + "description": "SO_LINGER lingering-close interval in seconds." + } + } + }, + "receive-buffer": { + "type": "object", + "required": [ + "size-bytes" + ], + "additionalProperties": false, + "properties": { + "size-bytes": { + "$ref": "#/$defs/positiveInteger", + "description": "SO_RCVBUF socket receive buffer size hint in bytes." + } + } + }, + "send-buffer": { + "type": "object", + "required": [ + "size-bytes" + ], + "additionalProperties": false, + "properties": { + "size-bytes": { + "$ref": "#/$defs/positiveInteger", + "description": "SO_SNDBUF socket send buffer size hint in bytes." + } + } + } + } + }, + "reconnection-policy": { + "description": "Defines how connection attempts to a node are retried after a connection failure.", + "oneOf": [ + { + "type": "object", + "description": "Exponential backoff reconnection policy.", + "additionalProperties": false, + "required": [ + "type", + "base-ms", + "max-ms" + ], + "properties": { + "type": { + "const": "exponential", + "description": "Reconnection policy type." + }, + "base-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Initial delay before the first reconnection attempt in milliseconds. Always a concrete value when this policy is reported." + }, + "max-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Maximum delay between reconnection attempts (in milliseconds). Always a concrete value when this policy is reported." + }, + "max-attempts": { + "$ref": "#/$defs/positiveInteger", + "description": "Maximum number of reconnection attempts before giving up. Absent when attempts are unlimited." + } + } + }, + { + "type": "object", + "description": "Constant delay reconnection policy.", + "additionalProperties": false, + "required": [ + "type", + "delay-ms" + ], + "properties": { + "type": { + "const": "constant", + "description": "Reconnection policy type." + }, + "delay-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Fixed delay between reconnection attempts (in milliseconds). Always a concrete value when this policy is reported." + }, + "max-attempts": { + "$ref": "#/$defs/positiveInteger", + "description": "Maximum number of reconnection attempts before giving up. Absent when attempts are unlimited." + } + } + }, + { + "type": "object", + "description": "A user-supplied reconnection policy that is not one of the built-ins. Identified by `name` only. Implementations that can introspect a policy instance MAY also serialize its public attributes as additional properties on this object.", + "additionalProperties": true, + "required": [ + "type", + "name" + ], + "properties": { + "type": { + "const": "custom", + "description": "Reconnection policy type: a user-supplied policy." + }, + "name": { + "type": "string", + "description": "Name of the custom policy (e.g. the public type name of the user-provided policy)." + } + } + }, + { + "type": "null", + "description": "No reconnection attempts will be made." + } + ] + }, + "retry-policy": { + "description": "Controls whether and how a failed query is retried. Discriminated on `type`; each policy only permits its own parameters.", + "oneOf": [ + { + "type": "object", + "description": "Error-type-aware retry policy with fixed, non-configurable rules: retry at most once on Unavailable (on the next node), retry on a read timeout only when enough replicas responded but the data was not retrieved, retry on a write timeout only for batch-log writes, retry the next node on Overloaded/ServerError/Bootstrapping/broken-connection when the request is idempotent, and never retry serial (LWT) reads. It can have backoff enabled.", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "const": "standard-error-aware", + "description": "Retry policy type." + }, + "backoff": { + "$ref": "#/$defs/retryPolicyBackoff", + "description": "Delay inserted between retries." + } + } + }, + { + "type": "object", + "description": "Simple retry policy with a fixed number of retries.", + "additionalProperties": false, + "required": [ + "type", + "max-retries" + ], + "properties": { + "type": { + "const": "simple", + "description": "Retry policy type." + }, + "backoff": { + "$ref": "#/$defs/retryPolicyBackoff", + "description": "Delay inserted between retries." + }, + "max-retries": { + "$ref": "#/$defs/nonNegativeInteger", + "description": "Maximum number of retries before giving up. Always a concrete value when this policy is reported; 0 means no retries." + } + } + }, + { + "type": "object", + "description": "Fall-through retry policy: never retries anything and always rethrows the original error to the caller. Every error type — read timeout, write timeout, unavailable, and unexpected request errors (connection errors, Overloaded, ServerError, Bootstrapping) — is propagated unchanged. This is a true no-op and is stricter than the 'never' policy, which still retries the next host on connection/server errors.", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "const": "fallthrough", + "description": "Retry policy type." + } + } + }, + { + "type": "object", + "description": "Downgrading-consistency retry policy: retries at a lower consistency level on failure.", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "const": "downgrading-consistency", + "description": "Retry policy type." + }, + "backoff": { + "$ref": "#/$defs/retryPolicyBackoff", + "description": "Delay inserted between retries." + } + } + }, + { + "type": "object", + "description": "A user-supplied retry policy that is not one of the built-ins. Identified by `name` only. Implementations that can introspect a policy instance MAY also serialize its public attributes as additional properties on this object.", + "additionalProperties": true, + "required": [ + "type", + "name" + ], + "properties": { + "type": { + "const": "custom", + "description": "Retry policy type: a user-supplied policy." + }, + "name": { + "type": "string", + "description": "Name of the custom policy (e.g. the public type name of the user-provided policy)." + }, + "description": { + "type": "string", + "description": "Textual description of what this policy does." + } + } + } + ] + }, + "speculative-execution-policy": { + "description": "Controls pre-emptive duplicate requests to other replicas. Discriminated on `type`; each policy only permits its own parameters.", + "oneOf": [ + { + "type": "object", + "description": "Constant-delay speculative execution: launch extra executions after a fixed delay.", + "additionalProperties": false, + "required": [ + "type", + "max-executions", + "delay-ms" + ], + "properties": { + "type": { + "const": "constant", + "description": "Speculative execution policy type." + }, + "max-executions": { + "$ref": "#/$defs/positiveInteger", + "description": "Maximum number of speculative executions per request." + }, + "delay-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Delay before launching each additional execution (in milliseconds)." + } + } + }, + { + "type": "object", + "description": "Percentile-based speculative execution: launch extra executions once latency exceeds a percentile threshold.", + "additionalProperties": false, + "required": [ + "type", + "max-executions", + "percentile" + ], + "properties": { + "type": { + "const": "percentile", + "description": "Speculative execution policy type." + }, + "max-executions": { + "$ref": "#/$defs/positiveInteger", + "description": "Maximum number of speculative executions per request." + }, + "percentile": { + "type": "number", + "exclusiveMinimum": 0, + "exclusiveMaximum": 100, + "description": "Latency percentile (0–100, exclusive; e.g. 99.0) that triggers an additional execution." + } + } + }, + { + "type": "object", + "description": "A user-supplied speculative execution policy that is not one of the built-ins. Identified by `name` only. Implementations that can introspect a policy instance MAY also serialize its public attributes as additional properties on this object.", + "additionalProperties": true, + "required": [ + "type", + "name" + ], + "properties": { + "type": { + "const": "custom", + "description": "Speculative execution policy type: a user-supplied policy." + }, + "name": { + "type": "string", + "description": "Name of the custom policy (e.g. the public type name of the user-provided policy)." + }, + "description": { + "type": "string", + "description": "Textual description of what this policy does." + } + } + } + ] + }, + "load-balancing-policy": { + "description": "Load balancing / host selection policy. Discriminated on `type`: a built-in policy reports the normalized flags below, while a user-supplied policy is reported as type 'custom' with a `name` and, optionally, serialized public attributes.", + "oneOf": [ + { + "type": "object", + "description": "A built-in load balancing policy, reported with normalized location/awareness flags.", + "additionalProperties": false, + "required": [ + "type", + "shuffle", + "token-aware", + "dc-failover", + "latency-awareness" + ], + "properties": { + "type": { + "enum": [ + "token-aware", + "round-robin", + "dc-aware", + "rack-aware", + "dc-inferring", + "basic", + "white-list", + "host-filter", + "default" + ], + "description": "Policy type one of e.g. token-aware, round-robin, dc-aware, rack-aware." + }, + "token-aware": { + "type": "boolean", + "description": "Whether queries are routed to token replicas." + }, + "shuffle": { + "type": "boolean", + "description": "Whether candidate order in a query plan is shuffled for regular queries. LWT and strongly consistent queries are not shuffled." + }, + "dc-failover": { + "type": "boolean", + "description": "Whether requests may fail over to remote datacenters." + }, + "latency-awareness": { + "type": "boolean", + "description": "Whether latency-aware host ordering is enabled." + } + } + }, + { + "type": "object", + "description": "A user-supplied load balancing policy that is not one of the built-ins. Identified by `name` only. Implementations that can introspect a policy instance MAY also serialize its public attributes as additional properties on this object.", + "additionalProperties": true, + "required": [ + "type", + "name" + ], + "properties": { + "type": { + "const": "custom", + "description": "Load balancing policy type: a user-supplied policy." + }, + "name": { + "type": "string", + "description": "Name of the custom policy (e.g. the public type name of the user-provided policy)." + }, + "description": { + "type": "string", + "description": "Textual description of what this policy does." + } + } + } + ] + }, + "node-location-preference": { + "description": "Session-level datacenter/rack preference, set independently of the load balancing policy. Some implementations let users set a preferred DC/rack directly on the session configuration; the load balancing policy and other components read this preference unless a policy overrides it. May be sourced from different places; if DC/rack preferences are specified in the load balancing policy, they should be reported here.", + "oneOf": [ + { + "type": "object", + "description": "Explicitly configured datacenter preference.", + "additionalProperties": false, + "required": [ + "type", + "local-dc" + ], + "properties": { + "type": { + "const": "dc", + "description": "Session-level location preference: explicit datacenter." + }, + "local-dc": { + "type": "string", + "description": "Explicitly configured preferred datacenter." + } + } + }, + { + "type": "object", + "description": "Explicitly configured datacenter and rack preference.", + "additionalProperties": false, + "required": [ + "type", + "local-dc", + "local-rack" + ], + "properties": { + "type": { + "const": "rack", + "description": "Session-level location preference: explicit datacenter and rack." + }, + "local-dc": { + "type": "string", + "description": "Explicitly configured preferred datacenter." + }, + "local-rack": { + "type": "string", + "description": "Explicitly configured preferred rack." + } + } + }, + { + "type": "object", + "description": "Datacenter preference inferred from the first node the client connects to.", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "const": "dc-auto", + "description": "Session-level location preference: inferred datacenter." + }, + "local-dc": { + "type": "string", + "description": "Inferred preferred datacenter. Absent when not yet known at report time." + } + } + }, + { + "type": "object", + "description": "Datacenter and rack preference inferred from the first node the client connects to.", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "const": "rack-auto", + "description": "Session-level location preference: inferred datacenter and rack." + }, + "local-dc": { + "type": "string", + "description": "Inferred preferred datacenter. Absent when not yet known at report time." + }, + "local-rack": { + "type": "string", + "description": "Inferred preferred rack. Absent when not yet known at report time." + } + } + } + ] + }, + "connection-pool": { + "description": "Connection pooling configuration.", + "type": "object", + "required": [ + "type", + "desired-connections-count", + "shard-aware" + ], + "additionalProperties": false, + "properties": { + "type": { + "enum": [ + "host", + "shard" + ], + "description": "What each pool is keyed by: per host or per shard." + }, + "desired-connections-count": { + "$ref": "#/$defs/positiveInteger", + "description": "Number of connections to open per host or per shard." + }, + "connection": { + "type": "object", + "description": "Per-connection pool settings.", + "required": [ + "max-requests" + ], + "additionalProperties": false, + "properties": { + "max-requests": { + "$ref": "#/$defs/positiveInteger", + "description": "Maximum number of in-flight requests per connection." + } + } + }, + "shard-aware": { + "type": "object", + "required": [ + "enabled" + ], + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether the client is configured to use ScyllaDB's dedicated shard-aware port (default 19042, TLS 19043) to reach a chosen shard in a single connect, versus the fallback of opening connections on the normal port and reading the server-assigned shard. Reports configuration intent; at runtime the port must also be advertised by the server and reachable, otherwise the client falls back transparently." + } + } + } + } + }, + "query-defaults": { + "description": "Default per-request settings applied to statements that do not override them.", + "type": "object", + "required": [ + "consistency", + "idempotence", + "client-timestamps", + "request" + ], + "additionalProperties": false, + "properties": { + "page": { + "type": "object", + "required": [ + "size" + ], + "additionalProperties": false, + "properties": { + "size": { + "$ref": "#/$defs/positiveInteger", + "description": "Default page (fetch) size for result sets. Absent when page is not limited." + } + } + }, + "consistency": { + "description": "Default consistency level applied to requests that do not override it. Always present when this group is reported.", + "type": "string", + "enum": [ + "ANY", + "ONE", + "TWO", + "THREE", + "QUORUM", + "ALL", + "LOCAL_QUORUM", + "EACH_QUORUM", + "LOCAL_ONE" + ] + }, + "serial-consistency": { + "description": "Default serial consistency for LWT/conditional statements. Absent when unset; the server default applies.", + "type": "string", + "enum": [ + "SERIAL", + "LOCAL_SERIAL" + ] + }, + "idempotence": { + "description": "Default idempotence flag applied to statements that do not set their own.", + "type": "boolean" + }, + "client-timestamps": { + "description": "True when the client assigns the write timestamp client-side (protocol-level/USING TIMESTAMP) instead of letting the coordinator assign it.", + "type": "boolean" + }, + "request": { + "type": "object", + "description": "Default request-level settings.", + "required": [ + "timeout-ms" + ], + "additionalProperties": false, + "properties": { + "timeout-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Client-side timeout for a single request/query in milliseconds. Always a concrete value when query defaults are reported." + } + } + } + } + }, + "tls": { + "description": "TLS/SSL transport settings. Reports only booleans; never credentials, keys, or host lists.", + "type": "object", + "required": [ + "enabled", + "hostname-verification" + ], + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether TLS is enabled for connections." + }, + "hostname-verification": { + "type": "boolean", + "description": "Whether the server hostname is verified against its certificate." + } + } + } + } +} diff --git a/integration-tests/src/test/java/com/datastax/oss/driver/core/config/DriverConfigReportingAssertions.java b/integration-tests/src/test/java/com/datastax/oss/driver/core/config/DriverConfigReportingAssertions.java new file mode 100644 index 00000000000..512b136f8ee --- /dev/null +++ b/integration-tests/src/test/java/com/datastax/oss/driver/core/config/DriverConfigReportingAssertions.java @@ -0,0 +1,66 @@ +/* + * 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.core.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; + +/** Shared assertions for the driver-config-reporting integration tests. */ +class DriverConfigReportingAssertions { + + // FAIL_ON_TRAILING_TOKENS rejects a valid JSON value followed by garbage; the payload is read + // below via readValue(..), which honors this feature reliably (readTree historically does not). + private static final ObjectMapper OBJECT_MAPPER = + JsonMapper.builder().enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).build(); + + private DriverConfigReportingAssertions() {} + + /** + * Asserts that a {@code DRIVER_CONFIG} value is a well-formed stage-2 report: valid JSON whose + * {@code version} is the integer {@code 1} and that carries the full configuration payload + * (checked here via the always-present, backend-agnostic {@code load-balancing-policy} group). + * Guards against an incorrect schema version, a malformed blob, or an empty/stage-1-only payload + * slipping through a mere key-presence check. + * + * @return the parsed report, so callers can assert on backend-specific groups too. + */ + static JsonNode assertDriverConfigPayload(String driverConfig) { + JsonNode root; + try { + root = OBJECT_MAPPER.readValue(driverConfig, JsonNode.class); + } catch (JsonProcessingException e) { + throw new AssertionError("DRIVER_CONFIG is not valid JSON: " + driverConfig, e); + } + assertThat(root.path("version").isInt()) + .as("version is an integer in %s", driverConfig) + .isTrue(); + assertThat(root.path("version").intValue()).isEqualTo(1); + assertThat(root.path("load-balancing-policy").isObject()) + .as("load-balancing-policy is an object in %s", driverConfig) + .isTrue(); + assertThat(root.path("load-balancing-policy").path("type").isTextual()) + .as("load-balancing-policy.type is present in %s", driverConfig) + .isTrue(); + return root; + } +} diff --git a/integration-tests/src/test/java/com/datastax/oss/driver/core/config/DriverConfigReportingCcmIT.java b/integration-tests/src/test/java/com/datastax/oss/driver/core/config/DriverConfigReportingCcmIT.java index 4710f9d4efe..d5d6612471d 100644 --- a/integration-tests/src/test/java/com/datastax/oss/driver/core/config/DriverConfigReportingCcmIT.java +++ b/integration-tests/src/test/java/com/datastax/oss/driver/core/config/DriverConfigReportingCcmIT.java @@ -17,6 +17,7 @@ */ package com.datastax.oss.driver.core.config; +import static com.datastax.oss.driver.core.config.DriverConfigReportingAssertions.assertDriverConfigPayload; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; @@ -30,14 +31,14 @@ import com.datastax.oss.driver.api.testinfra.session.SessionRule; import com.datastax.oss.driver.api.testinfra.session.SessionUtils; import com.datastax.oss.driver.categories.ParallelizableTests; -import com.fasterxml.jackson.core.JsonProcessingException; +import com.datastax.oss.driver.internal.core.context.InternalDriverContext; +import com.datastax.oss.driver.internal.core.context.StartupOptionsBuilder; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.InetSocketAddress; import java.time.Duration; import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @@ -55,11 +56,15 @@ * Simulacron only proves what the driver sends, this confirms that a real server * accepts the extra {@code STARTUP} keys and stores them, so that (a) {@code * SESSION_ID} is present on every one of the session's connections with a single shared value, and - * (b) {@code DRIVER_CONFIG} is stored for exactly one connection (the control connection). + * (b) {@code DRIVER_CONFIG} is stored for exactly one connection, which is the control connection — + * matched by address and port against the control connection's channel, so the check cannot be + * satisfied by a pooled connection — and (c) the stored report describes the backend it was + * actually built against, checked on the one group that legitimately differs between them ({@code + * connection-pool.type}, shard-keyed only on ScyllaDB). * - *

Runs on both backends, asserting identical behavior — only the table that exposes the stored - * options differs: ScyllaDB uses {@code system.clients.client_options}, while Apache Cassandra - * exposes it in {@code system_views.clients.client_options} (added in Cassandra 4.1). + *

Otherwise runs on both backends asserting identical behavior; the table that exposes the + * stored options differs too: ScyllaDB uses {@code system.clients.client_options}, while Apache + * Cassandra exposes it in {@code system_views.clients.client_options} (added in Cassandra 4.1). */ @Category(ParallelizableTests.class) @BackendRequirement( @@ -74,8 +79,6 @@ public class DriverConfigReportingCcmIT { private static final String DRIVER_NAME = "ScyllaDB Java Driver"; - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private static final CcmRule CCM_RULE = CcmRule.getInstance(); private static final SessionRule SESSION_RULE = @@ -125,48 +128,79 @@ public void should_store_session_id_on_all_connections_and_driver_config_on_cont row.getMap("client_options", String.class, String.class)); } - // (a) Every connection carries SESSION_ID, and all of them share a single value (one session). + // (a) Every row carries this session's SESSION_ID (that is what they were selected on), and + // there is more than one of them — otherwise (b) below would be vacuous. + assertThat(rows).hasSizeGreaterThanOrEqualTo(2); Set sessionIds = rows.stream().map(row -> clientOptions(row).get("SESSION_ID")).collect(Collectors.toSet()); - assertThat(sessionIds).doesNotContainNull().hasSize(1); + assertThat(sessionIds).containsExactly(sessionId(session)); - // (b) DRIVER_CONFIG is stored for exactly one connection (the control connection), and its - // value round-trips through the server intact as the stage-1 payload: valid JSON carrying - // exactly the schema version. - List driverConfigs = + // (b) DRIVER_CONFIG is stored for exactly one connection, and that connection is the control + // one — identified independently of the reported options, by the local address and port of the + // control connection's channel (which is what the server records as the client's address). + List withDriverConfig = rows.stream() - .map(row -> clientOptions(row).get("DRIVER_CONFIG")) - .filter(Objects::nonNull) + .filter(row -> clientOptions(row).get("DRIVER_CONFIG") != null) .collect(Collectors.toList()); - assertThat(driverConfigs).hasSize(1); - assertStageOnePayload(driverConfigs.get(0)); + assertThat(withDriverConfig).hasSize(1); + + Row controlRow = withDriverConfig.get(0); + InetSocketAddress controlAddress = controlConnectionAddress(session); + assertThat(controlRow.getInetAddress("address")).isEqualTo(controlAddress.getAddress()); + assertThat(controlRow.getInt("port")).isEqualTo(controlAddress.getPort()); + + // Its value round-trips through the server intact as the stage-2 payload: valid JSON carrying + // the schema version and the full configuration. + JsonNode report = assertDriverConfigPayload(clientOptions(controlRow).get("DRIVER_CONFIG")); + + // (c) The connection pool is reported against the backend actually reached: ScyllaDB advertises + // sharding info, so ChannelPool keys its pools per shard there and the report must say so. The + // count is 1 on both backends — advanced.connection.pool.local.size defaults to 1, and + // spreading + // 1 over any shard count rounds up to 1 per shard — so only "type" distinguishes them here. + JsonNode pool = report.path("connection-pool"); + assertThat(pool.path("type").asText()) + .isEqualTo(CcmBridge.isDistributionOf(BackendType.SCYLLA) ? "shard" : "host"); + assertThat(pool.path("desired-connections-count").asInt()).isEqualTo(1); } /** - * Asserts that a {@code DRIVER_CONFIG} value is the stage-1 payload: well-formed JSON whose - * {@code version} is the integer {@code 1}. Guards against an incorrect schema version or a - * malformed blob slipping through a mere key-presence check. + * The local address of the control connection's channel — the source address of that TCP + * connection, and therefore what the server records in the clients table's {@code address} and + * {@code port} columns (CCM connects directly, with no proxy or address translation in between). */ - private static void assertStageOnePayload(String driverConfig) { - JsonNode root; - try { - root = OBJECT_MAPPER.readTree(driverConfig); - } catch (JsonProcessingException e) { - throw new AssertionError("DRIVER_CONFIG is not valid JSON: " + driverConfig, e); - } - assertThat(root.path("version").isInt()) - .as("version is an integer in %s", driverConfig) - .isTrue(); - assertThat(root.path("version").intValue()).isEqualTo(1); + private InetSocketAddress controlConnectionAddress(CqlSession session) { + return (InetSocketAddress) + ((InternalDriverContext) session.getContext()) + .getControlConnection() + .channel() + .localAddress(); + } + + /** + * The {@code SESSION_ID} this session reports, read from the session-wide startup options — the + * same map the driver copies into every connection's {@code STARTUP}. + */ + private String sessionId(CqlSession session) { + String sessionId = + ((InternalDriverContext) session.getContext()) + .getStartupOptions() + .get(StartupOptionsBuilder.SESSION_ID_KEY); + assertThat(sessionId).isNotNull(); + return sessionId; } /** * The rows in the clients table that belong to this driver session's connections: this driver, in - * a {@code READY} state, and carrying the reporting {@code SESSION_ID}. Transient - * protocol-version negotiation attempts (no driver identity, closed immediately) are excluded, - * and their absence here is itself the confirmation that they leave no lingering session rows. + * a {@code READY} state, and carrying this session's {@code SESSION_ID}. + * + *

Scoping on the id value matters: {@code SESSION_ID} is sent unconditionally by every driver + * session, and this class shares its CCM cluster with the other parallelizable ITs, so a + * key-presence filter would also match their connections. The {@code READY} filter is what + * excludes the transient protocol-version negotiation attempts (closed immediately). */ private List driverConnections(CqlSession session) { + String sessionId = sessionId(session); return session .execute( "SELECT address, port, connection_stage, driver_name, client_options FROM " @@ -176,7 +210,7 @@ private List driverConnections(CqlSession session) { .filter(row -> DRIVER_NAME.equals(row.getString("driver_name"))) // connection_stage casing differs across backends; compare case-insensitively. .filter(row -> "READY".equalsIgnoreCase(row.getString("connection_stage"))) - .filter(row -> clientOptions(row).containsKey("SESSION_ID")) + .filter(row -> sessionId.equals(clientOptions(row).get("SESSION_ID"))) .collect(Collectors.toList()); } diff --git a/integration-tests/src/test/java/com/datastax/oss/driver/core/config/DriverConfigReportingSimulacronIT.java b/integration-tests/src/test/java/com/datastax/oss/driver/core/config/DriverConfigReportingSimulacronIT.java index f1306fc12c9..ff20e8c54fb 100644 --- a/integration-tests/src/test/java/com/datastax/oss/driver/core/config/DriverConfigReportingSimulacronIT.java +++ b/integration-tests/src/test/java/com/datastax/oss/driver/core/config/DriverConfigReportingSimulacronIT.java @@ -17,9 +17,10 @@ */ package com.datastax.oss.driver.core.config; +import static com.datastax.oss.driver.core.config.DriverConfigReportingAssertions.assertDriverConfigPayload; import static com.datastax.oss.driver.internal.core.context.DefaultDriverConfigReporter.DRIVER_CONFIG_KEY; -import static com.datastax.oss.driver.internal.core.context.DefaultDriverConfigReporter.SESSION_ID_KEY; import static com.datastax.oss.driver.internal.core.context.StartupOptionsBuilder.CLIENT_ID_KEY; +import static com.datastax.oss.driver.internal.core.context.StartupOptionsBuilder.SESSION_ID_KEY; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; @@ -33,9 +34,6 @@ import com.datastax.oss.protocol.internal.request.Startup; import com.datastax.oss.simulacron.common.cluster.ClusterSpec; import com.datastax.oss.simulacron.common.cluster.QueryLog; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import java.net.SocketAddress; import java.util.List; import java.util.Map; @@ -52,12 +50,14 @@ * CQL {@code STARTUP} frames the driver sends. * *

Simulacron records every inbound frame with its originating client connection, so we can - * verify that when {@code advanced.driver-config-reporting.enabled} is: + * verify that: * *

* *

The control connection is identified independently of the reported options: it is the only @@ -73,8 +73,6 @@ @Category(ParallelizableTests.class) public class DriverConfigReportingSimulacronIT { - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - // A single node yields one dedicated control connection plus a pool connection (local.size // defaults to 1), i.e. at least two distinct session connections of which only the control one // registers for events. @@ -116,31 +114,14 @@ public void should_report_session_id_on_all_connections_and_driver_config_only_o assertThat(withDriverConfig).hasSize(1); assertThat(withDriverConfig.get(0).getConnection()).isEqualTo(controlConnection); - // The payload is the stage-1 report: valid JSON carrying exactly the schema version. - assertStageOnePayload(options(withDriverConfig.get(0)).get(DRIVER_CONFIG_KEY)); - } - } - - /** - * Asserts that a {@code DRIVER_CONFIG} value is the stage-1 payload: well-formed JSON whose - * {@code version} is the integer {@code 1}. Guards against an incorrect schema version or a - * malformed blob slipping through a mere key-presence check. - */ - private static void assertStageOnePayload(String driverConfig) { - JsonNode root; - try { - root = OBJECT_MAPPER.readTree(driverConfig); - } catch (JsonProcessingException e) { - throw new AssertionError("DRIVER_CONFIG is not valid JSON: " + driverConfig, e); + // The payload is the stage-2 report: valid JSON carrying the schema version and the full + // configuration (checked here via the always-present load-balancing-policy group). + assertDriverConfigPayload(options(withDriverConfig.get(0)).get(DRIVER_CONFIG_KEY)); } - assertThat(root.path("version").isInt()) - .as("version is an integer in %s", driverConfig) - .isTrue(); - assertThat(root.path("version").intValue()).isEqualTo(1); } @Test - public void should_report_nothing_when_disabled() { + public void should_still_report_session_id_when_driver_config_reporting_is_disabled() { DriverConfigLoader loader = SessionUtils.configLoaderBuilder() .withBoolean(DefaultDriverOption.DRIVER_CONFIG_REPORTING_ENABLED, false) @@ -151,13 +132,32 @@ public void should_report_nothing_when_disabled() { List startups = sessionStartups(); assertThat(distinctConnections(startups)).isGreaterThanOrEqualTo(2); - // Neither option is sent on any session connection: zero change on the wire when disabled. + // SESSION_ID does not depend on the option: it is still sent, with a single shared value... + assertThat(startups).allSatisfy(log -> assertThat(options(log)).containsKey(SESSION_ID_KEY)); + assertThat(startups.stream().map(log -> options(log).get(SESSION_ID_KEY)).distinct()) + .hasSize(1); + // ... while the configuration itself is reported nowhere. assertThat(startups) - .allSatisfy( - log -> - assertThat(options(log)) - .doesNotContainKey(SESSION_ID_KEY) - .doesNotContainKey(DRIVER_CONFIG_KEY)); + .allSatisfy(log -> assertThat(options(log)).doesNotContainKey(DRIVER_CONFIG_KEY)); + } + } + + @Test + public void should_report_driver_config_by_default() { + // No override for advanced.driver-config-reporting.enabled: exercises the shipped default. + try (CqlSession session = SessionUtils.newSession(SIMULACRON_RULE)) { + awaitControlAndPoolConnected(); + + List startups = sessionStartups(); + assertThat(distinctConnections(startups)).isGreaterThanOrEqualTo(2); + + assertThat(startups).allSatisfy(log -> assertThat(options(log)).containsKey(SESSION_ID_KEY)); + List withDriverConfig = + startups.stream() + .filter(log -> options(log).containsKey(DRIVER_CONFIG_KEY)) + .collect(Collectors.toList()); + assertThat(withDriverConfig).hasSize(1); + assertDriverConfigPayload(options(withDriverConfig.get(0)).get(DRIVER_CONFIG_KEY)); } } diff --git a/pom.xml b/pom.xml index 44af8f1496d..99d054a7285 100644 --- a/pom.xml +++ b/pom.xml @@ -93,6 +93,7 @@ 1.1.4 2.2.21 4.3.0 + 1.5.9 2.0.0-M19 3.5.5 22.0.0.2 @@ -314,6 +315,11 @@ mockito-core 5.23.0 + + com.networknt + json-schema-validator + ${json-schema-validator.version} + io.reactivex.rxjava2 rxjava diff --git a/upgrade_guide/README.md b/upgrade_guide/README.md index 214399dacc7..b30042acb09 100644 --- a/upgrade_guide/README.md +++ b/upgrade_guide/README.md @@ -19,6 +19,34 @@ under the License. ## Upgrade guide +### 4.19.2.1 + +#### The driver reports a session identifier, and its configuration, at connection time + +Two CQL `STARTUP` options are new. The server stores them in its client-connection system table +(`system.clients` on ScyllaDB, `system_views.clients` on Cassandra 4.1+), so that operators can group +a client's connections and inspect its driver settings while investigating an incident. + +* `SESSION_ID` — a driver-generated identifier, shared by all of a session's connections. It is sent + on **every** connection, unconditionally: it is an innate behavior with no configuration option to + turn it off. It is not derived from `CLIENT_ID`, which remains user-settable and unchanged. +* `DRIVER_CONFIG` — a compact JSON description of the effective configuration of the session's + default execution profile (connection/socket settings, timeouts, + retry/reconnection/speculative-execution/load-balancing policies, connection pooling, query + defaults, and TLS). Only the control connection sends it, since it describes the whole session. + It reports settings only — never credentials, statements or data — and identifies non-built-in + policies by class simple name. Reporting it is best-effort: if the report cannot be built, or + would exceed 32 KiB, it is skipped (with a warning) rather than allowed to interfere with + connecting. + +Reporting the configuration is **enabled by default**. To turn it off: + +```properties +datastax-java-driver.advanced.driver-config-reporting.enabled = false +``` + +Note that this option does not affect `SESSION_ID`. + ### 4.19.0.7 #### Cloud private-endpoint support via client routes