diff --git a/driver-core/pom.xml b/driver-core/pom.xml index 8af93c038de..998f0a05b5f 100644 --- a/driver-core/pom.xml +++ b/driver-core/pom.xml @@ -187,6 +187,11 @@ test + + com.networknt + json-schema-validator + test + diff --git a/driver-core/src/main/java/com/datastax/driver/core/Cluster.java b/driver-core/src/main/java/com/datastax/driver/core/Cluster.java index 817fce39f31..727d8f52c99 100644 --- a/driver-core/src/main/java/com/datastax/driver/core/Cluster.java +++ b/driver-core/src/main/java/com/datastax/driver/core/Cluster.java @@ -40,6 +40,7 @@ import com.datastax.driver.core.policies.RetryPolicy; import com.datastax.driver.core.policies.SpeculativeExecutionPolicy; import com.datastax.driver.core.utils.MoreFutures; +import com.google.common.annotations.Beta; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Functions; import com.google.common.base.Predicates; @@ -1458,6 +1459,7 @@ public Builder withApplicationInfo(ApplicationInfo applicationInfo) { * * @param enabled whether driver configuration reporting is enabled. */ + @Beta public Builder withDriverConfigReporting(boolean enabled) { configurationBuilder.withDriverConfigReporting(enabled); return this; diff --git a/driver-core/src/main/java/com/datastax/driver/core/Configuration.java b/driver-core/src/main/java/com/datastax/driver/core/Configuration.java index 354d76f2b31..49c0db136e7 100644 --- a/driver-core/src/main/java/com/datastax/driver/core/Configuration.java +++ b/driver-core/src/main/java/com/datastax/driver/core/Configuration.java @@ -16,6 +16,7 @@ package com.datastax.driver.core; import com.datastax.driver.core.policies.Policies; +import com.google.common.annotations.Beta; import com.google.common.base.Joiner; import java.util.ArrayList; import java.util.List; @@ -236,6 +237,7 @@ public ApplicationInfo getApplicationInfo() { * * @return {@code true} if driver configuration reporting is enabled. */ + @Beta public boolean isDriverConfigReportingEnabled() { return driverConfigReportingEnabled; } @@ -288,6 +290,7 @@ public Builder withApplicationInfo(ApplicationInfo applicationInfo) { * @param driverConfigReportingEnabled whether driver configuration reporting is enabled. * @return this builder. */ + @Beta public Builder withDriverConfigReporting(boolean driverConfigReportingEnabled) { this.driverConfigReportingEnabled = driverConfigReportingEnabled; return this; diff --git a/driver-core/src/main/java/com/datastax/driver/core/Connection.java b/driver-core/src/main/java/com/datastax/driver/core/Connection.java index c8f021bf299..36e8eda364c 100644 --- a/driver-core/src/main/java/com/datastax/driver/core/Connection.java +++ b/driver-core/src/main/java/com/datastax/driver/core/Connection.java @@ -166,10 +166,10 @@ enum State { private final ApplicationInfo applicationInfo; private ProtocolFeatureStore protocolFeatureStore; - // The DRIVER_CONFIG blob this connection reports in its STARTUP options, or null to report none. - // Set only for the control connection, so the (potentially large) config blob is sent once per - // Cluster rather than on every connection. SESSION_ID is still sent on every connection. - private final String driverConfig; + // Whether this connection reports the DRIVER_CONFIG blob in its STARTUP options. Set only for the + // control connection, so the (potentially large) config blob is sent once per Cluster rather than + // on every connection. SESSION_ID is still sent on every connection. + private final boolean reportConfig; /** * Create a new connection to a Cassandra node and associate it with the given pool. @@ -181,11 +181,11 @@ enum State { * connection can also be associated to an owner later with {@link #setOwner(Owner)}. */ protected Connection(String name, EndPoint endPoint, Factory factory, Owner owner) { - this(name, endPoint, factory, owner, null); + this(name, endPoint, factory, owner, false); } private Connection( - String name, EndPoint endPoint, Factory factory, Owner owner, String driverConfig) { + String name, EndPoint endPoint, Factory factory, Owner owner, boolean reportConfig) { this.endPoint = endPoint; this.factory = factory; this.dispatcher = new Dispatcher(); @@ -195,7 +195,7 @@ private Connection( this.defaultKeyspaceAttempt = new SetKeyspaceAttempt(null, thisFuture); this.targetKeyspace = new AtomicReference(defaultKeyspaceAttempt); this.applicationInfo = factory.configuration.getApplicationInfo(); - this.driverConfig = driverConfig; + this.reportConfig = reportConfig; } /** Create a new connection to a Cassandra node. */ @@ -534,10 +534,16 @@ public ListenableFuture apply(Void input) throws Exception { // server can group all the connections opened from this Cluster. extraOptions.put(SESSION_ID_KEY, factory.sessionId.toString()); - // Built once when the Cluster initialized; non-null only on the control connection, and - // only when driver config reporting is enabled. - if (driverConfig != null) { - extraOptions.put(DefaultDriverConfigReporter.DRIVER_CONFIG_KEY, driverConfig); + // Built here, on the control connection only, and rebuilt for every one of them: the report + // must describe the objects in force at this handshake, not the ones that existed when the + // Cluster was constructed. A load balancing policy is initialized after the first control + // connection's STARTUP, so a datacenter or rack it infers can only reach the server on a + // later one. + if (reportConfig) { + String driverConfig = factory.buildDriverConfigReport(); + if (driverConfig != null) { + extraOptions.put(DefaultDriverConfigReporter.DRIVER_CONFIG_KEY, driverConfig); + } } if (protocolFeatureStore != null) { @@ -1318,21 +1324,19 @@ static class Factory { // affiliation, so Cluster-wide is the finest granularity available here). final UUID sessionId = UUID.randomUUID(); - // The DRIVER_CONFIG blob sent on the control connection, or null when driver config reporting - // is disabled (or the report could not be built). Built once here, as the Cluster initializes, - // and reused for every control connection this factory opens: it is never rebuilt while the - // session is in flight. - final String driverConfig; + // Whether this factory reports DRIVER_CONFIG at all: false when reporting is disabled, or when + // the reporter cannot be loaded on this classpath (see canBuildDriverConfigReport). The report + // itself is not cached — it is rebuilt for every control connection. + final boolean driverConfigReportable; Factory(Cluster.Manager manager, Configuration configuration) { this.defaultHandler = manager; this.manager = manager; this.reaper = manager.reaper; this.configuration = configuration; - this.driverConfig = + this.driverConfigReportable = configuration.isDriverConfigReportingEnabled() - ? new DefaultDriverConfigReporter(configuration).buildReport() - : null; + && canBuildDriverConfigReport(configuration); this.authProvider = configuration.getProtocolOptions().getAuthProvider(); this.protocolVersion = configuration.getProtocolOptions().initialProtocolVersion; this.nettyOptions = configuration.getNettyOptions(); @@ -1351,6 +1355,63 @@ static class Factory { .createThreadFactory(manager.clusterName, "timeouter")); } + /** + * The {@code DRIVER_CONFIG} report to send in this handshake's {@code STARTUP} options, or + * {@code null} when there is none to send. + * + *

Called once per control connection, from the {@code STARTUP} frame assembly, rather than + * cached: the report describes the objects that are in force, and one of them changes after the + * first handshake. {@code Cluster.Manager.init()} builds this factory before it initializes the + * load balancing policy, so a datacenter or rack the policy infers from the node it reaches is + * unknown while the first control connection is being opened and known on every later one. + * Rebuilding costs a few hundred microseconds on a connection that opens once per Cluster and + * then only on reconnect. + */ + String buildDriverConfigReport() { + return driverConfigReportable + ? new DefaultDriverConfigReporter(configuration).buildReport() + : null; + } + + /** + * Whether {@link DefaultDriverConfigReporter} can be loaded and run at all on this classpath. + * Also serves to load it here, on the {@link Cluster} initialization thread, rather than + * leaving a Netty event loop to be the first to touch Jackson; the report it builds is + * deliberately discarded, since every control connection builds its own. + * + *

Guarded because {@link DefaultDriverConfigReporter} serializes the report with Jackson, + * and holds an {@code ObjectMapper} in a static field: without Jackson, merely + * initializing that class raises {@link NoClassDefFoundError}. That is an {@code + * Error} raised while initializing the class rather than from any method it declares, so the + * reporter's own fail-safe cannot contain it and neither can anything it calls — and this runs + * on the {@link Cluster} initialization path, so it would take a classpath that merely lacks an + * optional serializer from "the report is skipped" to "no connection can be established at + * all". Choosing whether to touch the class is therefore the only place the check can live. + * + *

{@link LinkageError} rather than {@code NoClassDefFoundError} alone, so that a partially + * present or version-mismatched Jackson — which surfaces as {@code ExceptionInInitializerError} + * out of the static initializer — is covered too; probing for one class name would pass and + * then still fail here. This is the same fallback {@code SnappyCompressor} applies for its own + * optional library, and it does not contradict {@link + * DefaultDriverConfigReporter#buildReport()} deliberately not catching bare {@code Error}: that + * is about report building never masking a real JVM-level failure, while this is a call site + * tolerating a missing optional dependency. + */ + static boolean canBuildDriverConfigReport(Configuration configuration) { + try { + new DefaultDriverConfigReporter(configuration).buildReport(); + return true; + } catch (LinkageError e) { + // Logged unconditionally, and at WARN: reporting ships enabled, so nobody opted into it and + // nobody would think to look for a message saying it is off. + logger.warn( + "Cannot build the driver configuration report, so no DRIVER_CONFIG will be reported; " + + "this is expected if Jackson was excluded from the classpath ({})", + e.toString()); + return false; + } + } + int getPort() { return configuration.getProtocolOptions().getPort(); } @@ -1368,9 +1429,9 @@ Connection open(Host host) } /** - * Same as {@link #open(Host)}, but when {@code reportConfig} is true, hands the connection the - * {@code DRIVER_CONFIG} blob to report, marking it as the control connection (a no-op when - * driver config reporting is disabled, since there is then no blob to report). + * Same as {@link #open(Host)}, but when {@code reportConfig} is true, marks the connection as + * the control connection, which builds and reports a {@code DRIVER_CONFIG} blob of its own in + * its {@code STARTUP} options (a no-op when driver config reporting is disabled). */ Connection open(Host host, boolean reportConfig) throws ConnectionException, InterruptedException, UnsupportedProtocolVersionException, @@ -1381,8 +1442,7 @@ Connection open(Host host, boolean reportConfig) host.convictionPolicy.signalConnectionsOpening(1); Connection connection = - new Connection( - buildConnectionName(host), endPoint, this, null, reportConfig ? driverConfig : null); + new Connection(buildConnectionName(host), endPoint, this, null, reportConfig); // This method opens the connection synchronously, so wait until it's initialized try { connection.initAsync().get(); diff --git a/driver-core/src/main/java/com/datastax/driver/core/DefaultDriverConfigReporter.java b/driver-core/src/main/java/com/datastax/driver/core/DefaultDriverConfigReporter.java index 30c01848608..a985bd3db6e 100644 --- a/driver-core/src/main/java/com/datastax/driver/core/DefaultDriverConfigReporter.java +++ b/driver-core/src/main/java/com/datastax/driver/core/DefaultDriverConfigReporter.java @@ -15,9 +15,39 @@ */ package com.datastax.driver.core; +import com.datastax.driver.core.policies.ChainableLoadBalancingPolicy; +import com.datastax.driver.core.policies.ConstantReconnectionPolicy; +import com.datastax.driver.core.policies.ConstantSpeculativeExecutionPolicy; +import com.datastax.driver.core.policies.DCAwareRoundRobinPolicy; +import com.datastax.driver.core.policies.DefaultRetryPolicy; +import com.datastax.driver.core.policies.DowngradingConsistencyRetryPolicy; +import com.datastax.driver.core.policies.ErrorAwarePolicy; +import com.datastax.driver.core.policies.ExponentialReconnectionPolicy; +import com.datastax.driver.core.policies.FallthroughRetryPolicy; +import com.datastax.driver.core.policies.HostFilterPolicy; +import com.datastax.driver.core.policies.LatencyAwarePolicy; +import com.datastax.driver.core.policies.LoadBalancingPolicy; +import com.datastax.driver.core.policies.NoSpeculativeExecutionPolicy; +import com.datastax.driver.core.policies.PagingOptimizingLoadBalancingPolicy; +import com.datastax.driver.core.policies.PercentileSpeculativeExecutionPolicy; +import com.datastax.driver.core.policies.Policies; +import com.datastax.driver.core.policies.RackAwareRoundRobinPolicy; +import com.datastax.driver.core.policies.ReconnectionPolicy; +import com.datastax.driver.core.policies.RetryPolicy; +import com.datastax.driver.core.policies.RoundRobinPolicy; +import com.datastax.driver.core.policies.SpeculativeExecutionPolicy; +import com.datastax.driver.core.policies.TokenAwarePolicy; +import com.datastax.driver.core.policies.WhiteListPolicy; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.annotations.Beta; +import com.google.common.base.Strings; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,11 +56,110 @@ * {@code DRIVER_CONFIG} JSON shape, which {@link Connection.Factory} then sends in the control * connection's {@code STARTUP} options. * - *

The report is built once, when the {@link Cluster} initializes, and the resulting string is - * reused for the lifetime of that {@code Cluster} — it is never rebuilt while the session is in - * flight, so a control-connection reconnect costs nothing and always reports the same - * configuration. + *

The report is built afresh for every control connection, from inside its {@code STARTUP} frame + * assembly, and never cached: it describes the objects that are in force at that handshake rather + * than the configuration the {@link Cluster} was constructed from. That distinction is not + * theoretical. {@code Cluster.Manager.init()} builds the {@link Connection.Factory} before it calls + * {@link LoadBalancingPolicy#init}, and the first control connection's {@code STARTUP} goes out in + * between — so a datacenter or rack the policy infers from the node it reaches cannot be known on + * the first report and is known on every later one. A reconnect therefore costs one report build + * and may legitimately report more than the connection before it did. + * + *

The report follows the normative cross-driver schema (report {@code version} 1): kebab-case + * keys, nested objects, and omission of any key or group that has no value (nothing is ever + * emitted as {@code null}). The same applies where a configured value falls outside what the schema + * can express but the key is optional: a disabled connect timeout, a disabled read + * timeout, a disabled {@code SO_LINGER}, an unbounded page size and a non-serial default serial + * consistency level are omitted rather than emitted as a value the schema rejects. Two keys are + * omitted for a third reason — the schema admits only a boolean, and 3.x cannot observe which one + * applies: {@code query.defaults.client-timestamps} for a timestamp generator that is none of the + * driver's own, and {@code connection.tls.hostname-verification} for any {@link SSLOptions} other + * than {@link SniSSLOptions}. Both keys are documented as absent when the behavior is unknown, + * which is exactly the case here: a custom generator decides per call whether to assign a timestamp + * at all, and every other {@code SSLOptions} builds its engine from a user-supplied {@code + * SSLContext} (or hands the whole handler over to Netty), so nothing the reporter can read says + * whether the hostname is checked. Two more optional bounds are omitted for the opposite reason — + * 3.x simply has no such bound to report: {@code connection.reconnection.policy.max-attempts}, + * since its reconnection policies retry forever, and {@code query.retry.policy.max-retries}, since + * no single number describes a 3.x retry policy: {@link RetryPolicy#onRequestError} is the one path + * that does not stop after a single retry, and it is only reached for an idempotent statement, so + * the same policy bounds a non-idempotent request at one retry and an idempotent one at the length + * of the query plan. Which of the two a statement gets is not configuration — {@link + * Statement#setIdempotent} overrides the reported {@code query.defaults.idempotence} per statement. + * + *

Only a token-aware chain has a built-in shape in the schema, whose {@code + * query.load-balancing.policy.type} admits {@code token-aware} and {@code custom} and nothing else. + * That shape carries no name, so it is claimed only for a chain every element of which the group + * can describe; any other chain is {@code custom}, named after every policy in it — {@code + * WhiteListPolicy(TokenAwarePolicy(DCAwareRoundRobinPolicy))} — and keeps the capability keys it + * can still state, which the {@code custom} branch admits as additional properties. See {@link + * #loadBalancingPolicy}. A datacenter and rack are reported in {@code + * query.load-balancing.node-preference} whenever the chain prefers one: from a DC- or rack-aware + * policy, or from a filter restricting the session to a single datacenter. A bare {@link + * RoundRobinPolicy}, or a {@link WhiteListPolicy} over one, prefers neither and both preference + * keys are omitted. + * + *

The schema reports that preference in a second, optional place, {@code + * connection.node-preference}, which describes the part of the cluster the driver holds connections + * to rather than how a query is routed. One {@link LoadBalancingPolicy} decides both in 3.x, since + * {@link LoadBalancingPolicy#distance(Host)} is what governs whether a host is pooled at all, so + * that key is derived from the same chain — but from its datacenter half alone. A rack-aware + * policy's {@code distance()} returns {@code REMOTE}, never {@code IGNORED}, for a local-datacenter + * host in another rack, so those hosts are still pooled and the rack does not scope what the driver + * connects to. The datacenter does: a host outside the preferred one is {@code IGNORED} unless the + * policy is configured to use hosts there, and an ignored host gets no pool at all. + * + *

That last claim is exact only for the policy the preference was read from. A chainable policy + * above it can narrow pooling further: {@link HostFilterPolicy#distance(Host)} — and + * therefore {@link WhiteListPolicy}'s — returns {@code IGNORED} for any host failing its predicate, + * including one inside the reported datacenter, and a custom policy computes {@code distance()} + * itself and need honor nothing the chain below it prefers. The reported datacenter is thus + * necessary but not sufficient: it is what the located policy prefers, not the whole of what gets a + * pool. Reported all the same, deliberately, on the grounds that hiding a datacenter the operator + * really did configure is the worse failure mode — note the asymmetry with the inferred values + * above, in that nothing is inferred on a third party's behalf while what was explicitly configured + * is passed through even where a wrapper may override it. + * + *

A filter that restricts the session to exactly one datacenter is read the other way round: + * {@link HostFilterPolicy#fromDCWhiteList(LoadBalancingPolicy, Iterable)} retains the datacenters + * it was given, so when nothing in the chain prefers a datacenter of its own — {@code + * fromDCWhiteList(new RoundRobinPolicy(), ["dc1"])} — the restriction is what decides the + * preference and is reported as one. Anything a filter can express that names no single datacenter + * keeps the group omitted: a blacklist, several allowed datacenters, a blank name, a {@link + * WhiteListPolicy} (which filters on addresses), or a caller-supplied {@code Predicate}, + * which stays opaque. A filter the preference was not read from — because a location-aware + * policy below it won — narrows the chain without being stated anywhere, so it costs the chain its + * built-in shape and is named instead, exactly like any other restricting wrapper. + * + *

Known limitations: omission is not always available, because these fields are + * required by the schema. + * + *

+ * + *

{@code connection.requests.orphaned.max} used to belong to that list and no longer does: 3.x + * has no orphaned-request setting to report it from — a request the driver stopped waiting for + * keeps its stream identifier until the response arrives, with no configurable bound and no + * connection replacement (only 4.x has {@code advanced.connection.max-orphan-requests}) — and the + * schema now makes the group optional for exactly that case, so omission is the schema-valid + * answer. + * + *

Such a value is reported as-is, and a key with no equivalent stays omitted: the + * reporter deliberately neither fabricates an in-range value — which would misreport a setting an + * operator may have chosen on purpose, or a policy 3.x does not implement — nor drops the whole + * report over one field, so the document is accurate but fails schema validation. Tracked as a + * cross-driver schema gap: the fix is to let those fields express the value, the way {@code + * control-plane.schema.agreement.timeout-ms} already admits 0. + * + *

The read timeout feeds three places, all of them optional, so disabling it omits all three: + * {@code connection.read}, {@code control-plane.queries.system.timeout.client-side-ms} and {@code + * query.defaults.request}. */ +@Beta public class DefaultDriverConfigReporter implements DriverConfigReporter { private static final Logger LOGGER = LoggerFactory.getLogger(DefaultDriverConfigReporter.class); @@ -44,6 +173,38 @@ public class DefaultDriverConfigReporter implements DriverConfigReporter { */ 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 CBUtil.writeStringMap}, which writes each + * value with a 16-bit length prefix and no bounds check: 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. Note that nothing throws on that path, so it is not a failure + * the {@code try/catch} in {@link #buildReport()} could contain. + * + *

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; + + /** + * Upper bound on the number of policies visited while walking a load balancing policy chain. + * + *

The walk follows {@code ChainableLoadBalancingPolicy.getChildPolicy()} on arbitrary + * user-supplied policy objects, so a policy that returns itself — or any cycle — would otherwise + * spin forever on the {@link Cluster} initialization path. That is the one failure mode the + * {@code try/catch} in {@link #buildReport()} cannot contain, because it hangs rather than + * throws. + * + *

The built-in chains are a handful of policies deep at most, so hitting this bound means a + * malformed chain rather than a legitimately deep one. + */ + private static final int MAX_POLICY_CHAIN_LENGTH = 16; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); protected final Configuration configuration; @@ -57,10 +218,28 @@ public String buildReport() { // Configuration reporting is a best-effort diagnostic aid, so any failure here (a bad config // read, a misbehaving policy while introspecting, a serialization error) must be swallowed // rather than allowed to propagate: it is built on the Cluster-initialization path, which must - // not fail because of a diagnostic. + // not fail because of a diagnostic. 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 { - return buildJson(); - } catch (RuntimeException e) { + String json = buildJson(); + if (json == null) { + return null; + } + // 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) { + LOGGER.warn( + "The driver configuration report is {} bytes long, which exceeds the {} byte limit; " + + "skipping DRIVER_CONFIG", + length, + MAX_DRIVER_CONFIG_LENGTH); + return null; + } + return json; + } catch (InternalError | RuntimeException e) { LOGGER.warn( "Error while building the driver configuration report; skipping driver config reporting", e); @@ -68,12 +247,7 @@ public String buildReport() { } } - /** - * 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)} in a later stage. - */ + /** Builds the compact, single-line JSON configuration report. */ protected String buildJson() { ObjectNode root = OBJECT_MAPPER.createObjectNode(); root.put("version", SCHEMA_VERSION); @@ -88,11 +262,730 @@ 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. from {@link #configuration}. + * Populates the three top-level configuration groups onto the report root from {@link + * #configuration} and its policies. Keys the driver has no equivalent for (or cannot introspect + * in 3.x) are omitted rather than emitted as {@code null}. */ protected void populateConfig(ObjectNode root) { - // Stage 2: populate configuration groups from `configuration`. + Policies policies = configuration.getPolicies(); + // The load balancing policy chain feeds three keys across two groups, so it is walked once here + // and handed to both: the policy object and the full node preference under "query", and the + // datacenter half of that preference under "connection". + List lbChain = policyChain(policies.getLoadBalancingPolicy()); + NodeLocation nodeLocation = nodeLocation(lbChain); + root.set("connection", connection(policies, nodeLocation)); + root.set("control-plane", controlPlane()); + root.set("query", query(policies, lbChain, nodeLocation)); + } + + private ObjectNode connection(Policies policies, NodeLocation nodeLocation) { + SocketOptions socketOptions = configuration.getSocketOptions(); + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + // The "connect" group is required but its timeout is not, and a non-positive connect timeout + // disables it: omit the timeout rather than report a number the schema rejects, leaving the + // group empty. + ObjectNode connect = OBJECT_MAPPER.createObjectNode(); + int connectTimeoutMillis = socketOptions.getConnectTimeoutMillis(); + if (connectTimeoutMillis > 0) { + connect.put("timeout-ms", connectTimeoutMillis); + } + n.set("connect", connect); + // Optional group, positive-only: a non-positive read timeout disables read timeouts, so omit + // the group rather than report a number the schema rejects. + int readTimeoutMillis = socketOptions.getReadTimeoutMillis(); + if (readTimeoutMillis > 0) { + n.set("read", OBJECT_MAPPER.createObjectNode().put("timeout-ms", readTimeoutMillis)); + } + // No socket-level write timeout in 3.x -> omit "write". "heartbeat" is a reserved-empty + // placeholder in this schema version (the heartbeat interval has no home yet) -> omit. + n.set("requests", requests()); + n.set("pool", pool()); + n.set("socket", socket()); + n.set("reconnection", wrap("policy", reconnectionPolicy(policies))); + // Optional, and absent when TLS is disabled rather than reported as off. + ObjectNode tls = tls(); + if (tls != null) { + n.set("tls", tls); + } + // Optional, and the datacenter half of the preference only -- the rack does not scope which + // hosts are pooled; see the class javadoc. + if (nodeLocation != null) { + n.set("node-preference", nodeLocation.toDatacenterPreference()); + } + return n; + } + + /** + * The {@code query} group: the per-request defaults plus the three policies that act on a query, + * each nested under the group the schema gives it. + */ + private ObjectNode query( + Policies policies, List lbChain, NodeLocation nodeLocation) { + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + n.set("defaults", queryDefaults(policies)); + // 3.x retries immediately: no built-in retry policy inserts a delay between attempts, and a + // custom one exposes no schedule to introspect -> omit the optional "backoff". + n.set("retry", wrap("policy", retryPolicy(policies))); + + ObjectNode loadBalancing = + wrap( + "policy", + loadBalancingPolicy(lbChain, policies.getLoadBalancingPolicy(), nodeLocation)); + // Optional: omitted when the LB policy carries no DC/rack notion. + if (nodeLocation != null) { + loadBalancing.set("node-preference", nodeLocation.toFullPreference()); + } + n.set("load-balancing", loadBalancing); + + // Optional: omitted when there is no speculative execution. + ObjectNode specEx = speculativeExecutionPolicy(policies); + if (specEx != null) { + n.set("speculative-execution", wrap("policy", specEx)); + } + return n; + } + + private ObjectNode requests() { + PoolingOptions pooling = configuration.getPoolingOptions(); + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + n.set( + "in-flight", + OBJECT_MAPPER + .createObjectNode() + .put( + "max", + effective( + pooling.getMaxRequestsPerConnection(HostDistance.LOCAL), + poolDefault(PoolingOptions.MAX_REQUESTS_PER_CONNECTION_LOCAL_KEY)))); + // "orphaned" has no 3.x equivalent, so it is omitted; the schema makes the group optional for + // exactly that case — see the class javadoc. + return n; + } + + private ObjectNode pool() { + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + // The schema carries no pool size or keying: 3.x has a single per-host pool type, and a + // per-shard count could not be reported anyway, since this report is built before the control + // connection is up and no node's shard count is known yet. + n.set( + "shard-aware", + OBJECT_MAPPER + .createObjectNode() + .put("enabled", configuration.getProtocolOptions().isUseAdvancedShardAwareness())); + return n; + } + + private ObjectNode socket() { + SocketOptions o = configuration.getSocketOptions(); + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + // Boolean options report the effective on/off state; when the driver leaves them unset the + // OS/platform default applies, approximated here (tcp-no-delay defaults on, the others off). + // Best-effort: Connection.Factory applies each option only when SocketOptions has a value for + // it, then hands the bootstrap to NettyOptions.afterBootstrapInitialized, which can set any + // ChannelOption afterwards without this being able to see it. + n.put("tcp-no-delay", boolOrDefault(o.getTcpNoDelay(), true)); + n.put("keep-alive", boolOrDefault(o.getKeepAlive(), false)); + n.put("reuse-address", boolOrDefault(o.getReuseAddress(), false)); + // All three groups below are optional, so a value the schema cannot express is omitted rather + // than emitted: a negative SO_LINGER means lingering close is disabled (the schema takes a + // non-negative interval, so 0 is still reported), and a non-positive buffer size leaves the + // JDK/OS default in place (the schema takes a positive size). + Integer soLinger = o.getSoLinger(); + if (soLinger != null && soLinger >= 0) { + n.set("linger", OBJECT_MAPPER.createObjectNode().put("interval-s", soLinger)); + } + Integer receiveBufferSize = o.getReceiveBufferSize(); + if (receiveBufferSize != null && receiveBufferSize > 0) { + n.set( + "receive-buffer", OBJECT_MAPPER.createObjectNode().put("size-bytes", receiveBufferSize)); + } + Integer sendBufferSize = o.getSendBufferSize(); + if (sendBufferSize != null && sendBufferSize > 0) { + n.set("send-buffer", OBJECT_MAPPER.createObjectNode().put("size-bytes", sendBufferSize)); + } + return n; + } + + private ObjectNode controlPlane() { + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + // 3.x has no dedicated control-connection timeout; internal/system queries use the read + // timeout. + // There is no client-configurable server-side ("USING TIMEOUT") timeout -> omit server-side-ms. + ObjectNode timeout = OBJECT_MAPPER.createObjectNode(); + // Optional and positive-only, and a non-positive read timeout disables read timeouts: omit + // rather than report a number the schema rejects. The enclosing "timeout" object is required, + // so it stays (empty). + int clientSideMs = configuration.getSocketOptions().getReadTimeoutMillis(); + if (clientSideMs > 0) { + timeout.put("client-side-ms", clientSideMs); + } + n.set("queries", wrap("system", wrap("timeout", timeout))); + // Required and non-negative in the schema, and 0 is meaningful (do not wait for agreement). + // Cluster.Builder rejects a non-positive wait, but ProtocolOptions can be constructed with one + // directly, and a negative wait behaves exactly like 0 — so normalizing it is exact rather than + // invented, and keeps the required field in range. + long schemaAgreementMs = + Math.max(0L, configuration.getProtocolOptions().getMaxSchemaAgreementWaitSeconds() * 1000L); + n.set( + "schema", + wrap("agreement", OBJECT_MAPPER.createObjectNode().put("timeout-ms", schemaAgreementMs))); + return n; + } + + private ObjectNode reconnectionPolicy(Policies policies) { + ReconnectionPolicy policy = policies.getReconnectionPolicy(); + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + if (policy instanceof ExponentialReconnectionPolicy) { + ExponentialReconnectionPolicy p = (ExponentialReconnectionPolicy) policy; + n.put("type", "exponential"); + n.put("base-ms", p.getBaseDelayMs()); + n.put("max-ms", p.getMaxDelayMs()); + // Optional, and omitted because 3.x built-in reconnection policies never give up. The + // maxAttempts field ExponentialReconnectionPolicy carries is not that bound: it is an + // overflow + // guard on the doubling, and past it nextDelayMs() keeps returning maxDelayMs forever. + } else if (policy instanceof ConstantReconnectionPolicy) { + n.put("type", "constant"); + n.put("delay-ms", ((ConstantReconnectionPolicy) policy).getConstantDelayMs()); + } else { + customPolicy(n, policy); + } + return n; + } + + private ObjectNode retryPolicy(Policies policies) { + RetryPolicy policy = policies.getRetryPolicy(); + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + // No 3.x policy has a retry count to report, so the optional "max-retries" is always omitted; + // both built-ins below are parameterless singletons, and the schema's own wording for the key + // is "absent when no explicit retry limit is configured". Nor could one be derived: they stop + // after one attempt on a read timeout, a write timeout or an unavailable error -- all three + // sharing one counter, so one retry between them, not one each -- while onRequestError leaves + // nbRetry unread and keeps trying the next host until the query plan runs out. Which of those + // applies is decided per statement rather than by configuration: RequestHandler only consults + // onRequestError and onWriteTimeout for an idempotent statement, so the same policy bounds a + // non-idempotent request at one retry and an idempotent one at the length of the query plan. + // FallthroughRetryPolicy retries nothing, and the schema's "fallthrough" admits no such key + // anyway; a wrapper reported as custom cannot be introspected at all. + if (policy instanceof DefaultRetryPolicy) { + n.put("type", "standard-error-aware"); + } else if (policy instanceof DowngradingConsistencyRetryPolicy) { + n.put("type", "downgrading-consistency"); + } else if (policy instanceof FallthroughRetryPolicy) { + n.put("type", "fallthrough"); + } else { + // LoggingRetryPolicy / IdempotenceAwareRetryPolicy wrap a child but expose no getter, so only + // the outer type can be reported. + customPolicy(n, policy); + } + return n; + } + + private ObjectNode speculativeExecutionPolicy(Policies policies) { + SpeculativeExecutionPolicy policy = policies.getSpeculativeExecutionPolicy(); + if (policy instanceof NoSpeculativeExecutionPolicy) { + return null; + } + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + if (policy instanceof ConstantSpeculativeExecutionPolicy) { + ConstantSpeculativeExecutionPolicy p = (ConstantSpeculativeExecutionPolicy) policy; + n.put("type", "constant"); + n.put("max-executions", p.getMaxSpeculativeExecutions()); + n.put("delay-ms", p.getConstantDelayMillis()); + } else if (policy instanceof PercentileSpeculativeExecutionPolicy) { + PercentileSpeculativeExecutionPolicy p = (PercentileSpeculativeExecutionPolicy) policy; + n.put("type", "percentile"); + n.put("max-executions", p.getMaxSpeculativeExecutions()); + // Required, so a percentile of 0 — which the schema's range does not admit but the policy + // accepts — is reported as-is; see the class javadoc. + n.put("percentile", p.getPercentile()); + } else { + customPolicy(n, policy); + } + return n; + } + + /** + * The {@code query.load-balancing.policy} group for a chain, discriminated on whether the schema + * can describe every element of it. + * + *

The built-in {@code token-aware} shape has no room for a name, so claiming it means claiming + * the chain is exactly that shape. Each element is therefore classified: {@link + * TokenAwarePolicy} and {@link LatencyAwarePolicy} are capabilities the shape carries as {@code + * load-distribution} and {@code adaptive-ordering}; {@link DCAwareRoundRobinPolicy}, {@link + * RackAwareRoundRobinPolicy} and {@link RoundRobinPolicy} are described by {@code + * node-preference} and {@code fallback-to-non-preferred-nodes} beside it; {@link + * PagingOptimizingLoadBalancingPolicy} is an internal wrapper that describes nothing and is + * dropped; the {@link HostFilterPolicy} the reported {@code node-preference} was read from is + * described by that preference (see {@link #nodeLocation}). Anything else — {@link + * WhiteListPolicy}, {@link ErrorAwarePolicy}, an opaque filter, a user policy — restricts or + * reorders candidates in a way nothing in the group states. + * + *

A filter is only described by the preference when the preference is its + * restriction, which is why this is tested against {@code nodeLocation} rather than against the + * filter alone. A location-aware policy below it wins that slot, and the filter then narrows the + * chain further without appearing anywhere: {@code fromDCWhiteList(DCAware("dc1"), ["dc2"])} + * reports {@code dc1} while nothing outside {@code dc2} is reachable. Treating such a filter as + * described would leave it invisible behind an inner {@link TokenAwarePolicy} and visible without + * one — the nesting-dependent blind spot this whole classification exists to remove. + * + *

One such element and the whole chain is reported as {@code custom}, whose {@code name} names + * every policy involved, outermost first: {@code + * WhiteListPolicy(TokenAwarePolicy(DCAwareRoundRobinPolicy))}. The capability keys are kept + * alongside it — the {@code custom} branch admits additional properties precisely so a driver + * that can introspect a policy may serialize what it knows — so naming the wrapper costs nothing + * that was previously reported. It was previously invisible: an outer {@code WhiteListPolicy} + * over a token-aware chain reported plain {@code token-aware}, so whether an operator could see + * that the client is pinned to a host list depended on an unrelated nesting choice. + */ + private ObjectNode loadBalancingPolicy( + List chain, LoadBalancingPolicy policy, NodeLocation nodeLocation) { + TokenAwarePolicy tokenAware = null; + boolean latencyAware = false; + DCAwareRoundRobinPolicy dcAware = null; + RackAwareRoundRobinPolicy rackAware = null; + boolean describedByTheSchema = true; + for (LoadBalancingPolicy current : chain) { + if (current instanceof TokenAwarePolicy) { + tokenAware = (TokenAwarePolicy) current; + } else if (current instanceof LatencyAwarePolicy) { + latencyAware = true; + } else if (current instanceof DCAwareRoundRobinPolicy) { + dcAware = (DCAwareRoundRobinPolicy) current; + } else if (current instanceof RackAwareRoundRobinPolicy) { + rackAware = (RackAwareRoundRobinPolicy) current; + } else if (!(current instanceof PagingOptimizingLoadBalancingPolicy + || current instanceof RoundRobinPolicy + || (nodeLocation != null && current == nodeLocation.source))) { + describedByTheSchema = false; + } + } + + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + if (describedByTheSchema && tokenAware != null) { + n.put("type", "token-aware"); + } else { + n.put("type", "custom"); + n.put("name", chainName(chain, policy)); + } + if (tokenAware != null) { + n.put("load-distribution", loadDistribution(tokenAware.getReplicaOrdering())); + // A rack-aware policy always reaches the other racks of its local datacenter -- the second + // tier of a normal query plan, once the local rack is exhausted, and folded into the first + // tier for an LWT or serial-consistency statement, which skips rack prioritization altogether + // -- and its distance() returns REMOTE, not IGNORED, for them, so its preference is always + // fallen back on either way. A DC-aware policy only leaves the preferred datacenter when + // configured to use hosts there. + boolean fallback = + rackAware != null || (dcAware != null && dcAware.getUsedHostsPerRemoteDc() > 0); + n.put("fallback-to-non-preferred-nodes", fallback); + } + // Optional, and its "signals" cannot be empty: the group is omitted altogether when nothing + // reorders candidates at runtime. Latency, via LatencyAwarePolicy, is the only observation a + // 3.x policy can reorder them on, and it is reported wherever that policy appears -- adaptive + // ordering is a property of the chain, not of token awareness, so LatencyAwarePolicy over a + // bare RoundRobinPolicy carries it just as it does over a token-aware one. + if (latencyAware) { + ObjectNode adaptiveOrdering = OBJECT_MAPPER.createObjectNode(); + adaptiveOrdering.putArray("signals").add("latency"); + n.set("adaptive-ordering", adaptiveOrdering); + } + return n; + } + + /** + * Every policy in {@code chain} rendered outermost-first as {@code Outer(Inner(Innermost))}, + * skipping the internal {@code PagingOptimizingLoadBalancingPolicy} wrapper {@code + * Cluster.Manager} puts around every session's policy — reporting that would tell an operator + * nothing about what the client runs. + */ + private static String chainName(List chain, LoadBalancingPolicy fallback) { + StringBuilder name = new StringBuilder(); + int open = 0; + for (LoadBalancingPolicy current : chain) { + if (current instanceof PagingOptimizingLoadBalancingPolicy) { + continue; + } + if (open > 0) { + name.append('('); + } + name.append(policyName(current)); + open++; + } + if (open == 0) { + return policyName(fallback); + } + for (int i = 1; i < open; i++) { + name.append(')'); + } + return name.toString(); + } + + /** + * The schema's normalized name for how a token-aware policy distributes requests over the + * replicas it considers equally preferred. + */ + private static String loadDistribution(TokenAwarePolicy.ReplicaOrdering replicaOrdering) { + switch (replicaOrdering) { + case RANDOM: + // A different random order for each query plan. + return "shuffle"; + case TOPOLOGICAL: + // The replica set's own order around the token ring, never reordered. + return "replica-set"; + case NEUTRAL: + default: + // Whatever order the child policy's query plan has, i.e. round-robin for every built-in + // child, each of which rotates its starting host across successive plans. + return "round-robin"; + } + } + + /** + * The node location {@code chain} prefers, or {@code null} when no policy in it carries one — a + * bare {@link RoundRobinPolicy}, or a custom policy with no introspectable location — in which + * case both of the schema's node preference keys are omitted. + * + *

A location-aware policy wins over a filter above it: {@code + * fromDCWhiteList(DCAwareRoundRobinPolicy(...))} reports the policy's own datacenter, which is + * what routes its query plans. The filter is consulted only when nothing in the chain prefers a + * datacenter of its own, and only when it restricts the session to exactly one — see {@link + * #singleWhiteListedDatacenter}. + */ + private static NodeLocation nodeLocation(List chain) { + DCAwareRoundRobinPolicy dcAware = null; + RackAwareRoundRobinPolicy rackAware = null; + HostFilterPolicy dcFilter = null; + String filteredDc = null; + for (LoadBalancingPolicy current : chain) { + if (current instanceof DCAwareRoundRobinPolicy) { + dcAware = (DCAwareRoundRobinPolicy) current; + } else if (current instanceof RackAwareRoundRobinPolicy) { + rackAware = (RackAwareRoundRobinPolicy) current; + } else if (current instanceof HostFilterPolicy) { + String dc = singleWhiteListedDatacenter((HostFilterPolicy) current); + if (dc != null) { + dcFilter = (HostFilterPolicy) current; + filteredDc = dc; + } + } + } + if (rackAware != null) { + return NodeLocation.ofRack( + rackAware, + rackAware.getLocalDc(), + rackAware.isLocalDcExplicit(), + rackAware.getLocalRack(), + rackAware.isLocalRackExplicit()); + } + if (dcAware != null) { + return NodeLocation.ofDatacenter(dcAware, dcAware.getLocalDc(), dcAware.isLocalDcExplicit()); + } + if (dcFilter != null) { + // Explicit: the datacenter was named by the caller, not inferred from the node the driver + // happened to reach first. + return NodeLocation.ofDatacenter(dcFilter, filteredDc, true); + } + return null; + } + + /** + * The one datacenter {@code policy} restricts the session to, or {@code null} when it names none, + * several, or something that is not a datacenter at all: a denied datacenter names no preferred + * one, several allowed datacenters name no single one, and a {@link WhiteListPolicy} (or a + * caller-supplied predicate) filters on something else entirely — {@link + * HostFilterPolicy#getWhiteListedDatacenters()} is empty for all of those. + * + *

A blank name is no name either. Nothing validates the strings handed to {@link + * HostFilterPolicy#fromDCWhiteList(LoadBalancingPolicy, Iterable)}, and the schema requires a + * non-empty {@code local-dc} beside {@code type: "dc"} — so the whole preference is omitted + * rather than reported as a value the schema rejects, which is also how {@link + * DCAwareRoundRobinPolicy#getLocalDc()} and {@link RackAwareRoundRobinPolicy#getLocalRack()} + * treat one. A merely padded name is not blank and is reported verbatim: hiding the whitespace + * would hide the typo the report exists to expose. + */ + private static String singleWhiteListedDatacenter(HostFilterPolicy policy) { + Set dcs = policy.getWhiteListedDatacenters(); + if (dcs.size() != 1) { + return null; + } + String dc = dcs.iterator().next(); + return Strings.isNullOrEmpty(dc) ? null : dc; + } + + /** + * The datacenter and rack a load balancing policy chain prefers, and whether each of them was + * configured or is left for the policy to infer. + * + *

Extracted once and rendered twice, because the schema's two node preference keys make + * different claims about the same policy: {@link #toFullPreference()} for the preference the + * policy routes queries by, {@link #toDatacenterPreference()} for the part of it that decides + * which hosts are pooled at all. + */ + private static final class NodeLocation { + + /** + * The policy in the chain this location was read from. {@link #loadBalancingPolicy} needs it to + * tell a filter whose restriction the report states from one that narrows the chain further + * without being stated anywhere; only the former can be left out of the policy's name. + */ + private final LoadBalancingPolicy source; + + private final String dc; + private final boolean dcExplicit; + private final String rack; + private final boolean rackExplicit; + private final boolean rackAware; + + static NodeLocation ofDatacenter(LoadBalancingPolicy source, String dc, boolean dcExplicit) { + return new NodeLocation(source, dc, dcExplicit, null, false, false); + } + + static NodeLocation ofRack( + LoadBalancingPolicy source, + String dc, + boolean dcExplicit, + String rack, + boolean rackExplicit) { + return new NodeLocation(source, dc, dcExplicit, rack, rackExplicit, true); + } + + private NodeLocation( + LoadBalancingPolicy source, + String dc, + boolean dcExplicit, + String rack, + boolean rackExplicit, + boolean rackAware) { + this.source = source; + this.dc = dc; + this.dcExplicit = dcExplicit; + this.rack = rack; + this.rackExplicit = rackExplicit; + this.rackAware = rackAware; + } + + /** The datacenter half alone, for {@code connection.node-preference}. */ + ObjectNode toDatacenterPreference() { + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + // "dc" requires the name and always has one: both policies derive the explicitness flag from + // the configured string, so an explicit datacenter is never blank. "dc-auto" carries an + // inferred name under plain "local-dc" rather than an "inferred-" prefixed key, unlike + // "rack-auto" below; that asymmetry is the schema's. + n.put("type", dcExplicit ? "dc" : "dc-auto"); + putIfNotNull(n, "local-dc", dc); + return n; + } + + /** The whole preference, rack included, for {@code query.load-balancing.node-preference}. */ + ObjectNode toFullPreference() { + if (!rackAware) { + // A DC-aware policy has no rack notion, so its whole preference is the datacenter. + return toDatacenterPreference(); + } + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + if (dcExplicit && rackExplicit) { + n.put("type", "rack"); + putIfNotNull(n, "local-dc", dc); + putIfNotNull(n, "local-rack", rack); + return n; + } + // At least one part is inferred, and the schema reports configured and inferred values under + // separate keys — of which it admits only one per part. An inferred value is only known once + // the policy has been initialized, i.e. never at report time, so in practice its key is + // absent. + n.put("type", "rack-auto"); + putIfNotNull(n, dcExplicit ? "local-dc" : "inferred-local-dc", dc); + putIfNotNull(n, rackExplicit ? "local-rack" : "inferred-local-rack", rack); + return n; + } + } + + private ObjectNode queryDefaults(Policies policies) { + QueryOptions q = configuration.getQueryOptions(); + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + // Paging is unbounded either way the driver can express it, and the schema has no sentinel for + // that ("absent when page is not limited"), so this optional group is omitted entirely. + // Integer.MAX_VALUE is the documented way to disable paging (QueryOptions#setFetchSize), and is + // the value that actually reaches a session; a non-positive size is rejected by that setter and + // only a QueryOptions subclass overriding the getter can still produce one. + int fetchSize = q.getFetchSize(); + if (fetchSize > 0 && fetchSize != Integer.MAX_VALUE) { + n.set("page", OBJECT_MAPPER.createObjectNode().put("size", fetchSize)); + } + // Required, and the schema's enum admits every level QueryOptions accepts, serial ones + // included. The setter rejects null, so only a QueryOptions subclass overriding the getter can + // still return one; guarded because letting it through would throw here and cost the whole + // report, not just this key. + ConsistencyLevel consistency = q.getConsistencyLevel(); + if (consistency != null) { + n.put("consistency", consistency.name()); + } + // Optional, and unlike Statement.setSerialConsistencyLevel, QueryOptions does not check that + // the level is serial: omit one the schema's enum cannot express rather than emit it. + ConsistencyLevel serialConsistency = q.getSerialConsistencyLevel(); + if (serialConsistency != null && serialConsistency.isSerial()) { + n.put("serial-consistency", serialConsistency.name()); + } + n.put("idempotence", q.getDefaultIdempotence()); + // Optional, and reported only for the driver's own generators, whose answer is fixed: + // ServerSideTimestampGenerator always returns Long.MIN_VALUE and so always leaves the timestamp + // to the coordinator, while an AbstractMonotonicTimestampGenerator always computes one + // client-side (its documented extension point is onDrift, not next). Any other generator + // decides per call, so nothing here can say which happens -- the "absent when this behavior is + // unknown" case the schema's optional key is for. + TimestampGenerator timestampGenerator = policies.getTimestampGenerator(); + if (timestampGenerator instanceof ServerSideTimestampGenerator) { + n.put("client-timestamps", false); + } else if (timestampGenerator instanceof AbstractMonotonicTimestampGenerator) { + n.put("client-timestamps", true); + } + // 3.x has no per-request timeout of its own: the read timeout bounds every request, so it is + // also what connection.read and control-plane.queries.system report. Both the timeout and its + // enclosing group are optional and the timeout is positive-only, so a disabled read timeout + // omits the group entirely rather than reporting it empty. + int requestTimeoutMs = configuration.getSocketOptions().getReadTimeoutMillis(); + if (requestTimeoutMs > 0) { + n.set("request", OBJECT_MAPPER.createObjectNode().put("timeout-ms", requestTimeoutMs)); + } + return n; + } + + /** The {@code tls} group, or {@code null} when TLS is disabled and the group is omitted. */ + private ObjectNode tls() { + SSLOptions sslOptions = configuration.getProtocolOptions().getSSLOptions(); + if (sslOptions == null) { + return null; + } + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + // 3.x exposes no hostname-verification flag. SniSSLOptions is the one implementation that + // verifies the hostname and it hard-codes it on, so it is the only case that can be reported. + // Every other SSLOptions builds its engine from a user-supplied SSLContext, or hands the whole + // handler to Netty, and neither says whether endpoint identification is enabled -- which is the + // "absent when this behavior is unknown" case the schema's optional key is for. The group can + // therefore be empty: its presence is what reports that TLS is on. + if (sslOptions instanceof SniSSLOptions) { + n.put("hostname-verification", true); + } + return n; + } + + /** + * Returns the load balancing policy chain, outermost policy first, by following {@code + * ChainableLoadBalancingPolicy.getChildPolicy()} for at most {@link #MAX_POLICY_CHAIN_LENGTH} + * policies. The chain is walked rather than the configured policy read directly because {@code + * Cluster.Manager} wraps that policy at runtime; {@code query.load-balancing.policy}, {@code + * query.load-balancing.node-preference} and {@code connection.node-preference} are all derived + * from it. + */ + private static List policyChain(LoadBalancingPolicy policy) { + List chain = new ArrayList(); + LoadBalancingPolicy current = policy; + while (current != null && chain.size() < MAX_POLICY_CHAIN_LENGTH) { + chain.add(current); + current = + current instanceof ChainableLoadBalancingPolicy + ? ((ChainableLoadBalancingPolicy) current).getChildPolicy() + : null; + } + if (current != null) { + // Only reachable from a user policy whose getChildPolicy() chain is cyclic or absurdly deep; + // report what was seen rather than walking forever or dropping the whole report. + LOGGER.warn( + "Stopped walking the load balancing policy chain after {} policies; reporting only those. " + + "Does a ChainableLoadBalancingPolicy in the chain return a cyclic child policy?", + MAX_POLICY_CHAIN_LENGTH); + } + return chain; + } + + private static void customPolicy(ObjectNode node, Object policy) { + node.put("type", "custom"); + node.put("name", policyName(policy)); + } + + /** + * The name to report a policy object under: its simple class name, or its binary name when the + * simple name is empty. An anonymous class has no simple name, and the schema requires a + * non-empty one. + */ + private static String policyName(Object policy) { + Class policyClass = policy.getClass(); + String simpleName = policyClass.getSimpleName(); + return simpleName.isEmpty() ? policyClass.getName() : simpleName; + } + + /** + * A new object node holding {@code value} under {@code key}. The schema nests most values one or + * two single-key objects deep — {@code connection.reconnection.policy}, {@code + * control-plane.queries.system.timeout} — which this keeps readable. + */ + private static ObjectNode wrap(String key, JsonNode value) { + ObjectNode node = OBJECT_MAPPER.createObjectNode(); + node.set(key, value); + return node; + } + + private static void putIfNotNull(ObjectNode node, String key, String value) { + if (value != null) { + node.put(key, value); + } + } + + private static boolean boolOrDefault(Boolean value, boolean defaultValue) { + return value == null ? defaultValue : value; + } + + /** + * The effective pooling default for {@code key}. Needed as a fallback because {@link + * PoolingOptions} returns {@link PoolingOptions#UNSET} until the protocol version is known, which + * only happens once the control connection is up — after this report is built. A value the user + * configured explicitly takes precedence. + * + *

Resolved with the same walk {@code PoolingOptions.setProtocolVersion} applies once the + * version is negotiated: the highest {@link PoolingOptions#DEFAULTS} key that does not exceed it. + * The version is the one the user pinned with {@link + * Cluster.Builder#withProtocolVersion(ProtocolVersion)}, which is known here, and otherwise + * {@link ProtocolVersion#V3} — the lowest version ScyllaDB negotiates, and the reference row for + * every version above it. Without the pinned version this would report the v3 limit for a cluster + * deliberately pinned to v2, whose pools are sized from the v1 row instead. + * + *

That fallback is an assumption rather than an observation, and it is the one way this field + * can be wrong: an unpinned cluster negotiates downward from the highest version the + * driver supports, so one that settles on v2 — a Cassandra 2.0 cluster, no ScyllaDB being that + * old — is sized from the v1 row (128) while this has already reported 1024. Pinning is the only + * part of negotiation knowable before the control connection is up, and the report is built once, + * so there is nothing later to correct it with. + * + *

Looked up here rather than in a static field so that a failure stays inside {@link + * #buildReport()}'s fail-safe handling instead of breaking class initialization on the {@link + * Connection.Factory} path. + */ + private int poolDefault(String key) { + ProtocolVersion pinned = configuration.getProtocolOptions().initialProtocolVersion; + ProtocolVersion version = pinned == null ? ProtocolVersion.V3 : pinned; + ProtocolVersion reference = null; + for (ProtocolVersion candidate : PoolingOptions.DEFAULTS.keySet()) { + if (candidate.compareTo(version) > 0) { + break; + } + reference = candidate; + } + // V1 is a key, so it is always at or below any version; a null would only mean DEFAULTS lost + // it, + // and the NPE is then contained by buildReport()'s fail-safe. + return PoolingOptions.DEFAULTS.get(reference).get(key); + } + + /** + * {@code value} unless it is {@link PoolingOptions#UNSET}, in which case {@code defaultValue}. + * + *

Only {@code UNSET} falls back: {@link + * PoolingOptions#setMaxRequestsPerConnection(HostDistance, int)} accepts 0, and reporting the + * protocol default for it would misreport a limit the operator set deliberately. 0 is below the + * schema's minimum and reported as-is — see the class javadoc. + */ + private static int effective(int value, int defaultValue) { + return value == PoolingOptions.UNSET ? defaultValue : value; } } diff --git a/driver-core/src/main/java/com/datastax/driver/core/DriverConfigReporter.java b/driver-core/src/main/java/com/datastax/driver/core/DriverConfigReporter.java index 7ada1c34a2b..a0aa2b02dad 100644 --- a/driver-core/src/main/java/com/datastax/driver/core/DriverConfigReporter.java +++ b/driver-core/src/main/java/com/datastax/driver/core/DriverConfigReporter.java @@ -15,6 +15,8 @@ */ package com.datastax.driver.core; +import com.google.common.annotations.Beta; + /** * Builds the {@code DRIVER_CONFIG} payload that the control connection sends in its CQL {@code * STARTUP} options, so ScyllaDB can store it in {@code system.clients.client_options} and operators @@ -28,18 +30,31 @@ * connection stores {@code DRIVER_CONFIG}; other nodes only see {@code SESSION_ID}-bearing pooled- * connection rows. Consumers must query and aggregate across all nodes to see the full picture. */ +@Beta public interface DriverConfigReporter { /** * Builds the configuration report, or returns {@code null} if it could not be built (in which * case no {@code DRIVER_CONFIG} option is sent). * - *

Called once per {@link Cluster}, while it initializes; the returned string is then reused - * for every control connection that cluster opens. + *

Called once per control connection, from its {@code STARTUP} frame assembly, and not cached: + * the report describes the objects in force at the handshake that sends it, rather than the + * configuration the {@link Cluster} was constructed from. Implementations must therefore tolerate + * being called on a Netty event loop, and repeatedly over a cluster's lifetime. * *

Implementations must not throw: a failure to build the report must be swallowed (and * logged) rather than propagated, so that a diagnostic aid can never break cluster - * initialization. + * initialization. For the same reason they must bound the report's size and return {@code null} + * rather than an oversized one: {@code STARTUP} option values carry an unchecked 16-bit length + * prefix, so a value over 65535 encoded bytes corrupts the frame instead of merely being useless. + * The built-in reporter caps the report at 32KiB of UTF-8, the limit the other ScyllaDB drivers + * apply. + * + *

An implementation that cannot even be loaded is handled one level up rather than + * here: {@link DefaultDriverConfigReporter} needs Jackson, and on a classpath without it the + * failure is a {@link LinkageError} raised while initializing the class, which no method of this + * interface could catch. {@code Connection.Factory.buildDriverConfigReport} contains that and + * reports nothing, so the connection is still established. * * @return the report to send under the {@code DRIVER_CONFIG} startup option, or {@code null} to * send nothing. diff --git a/driver-core/src/main/java/com/datastax/driver/core/QueryOptions.java b/driver-core/src/main/java/com/datastax/driver/core/QueryOptions.java index ee55dfcf381..a93d8976491 100644 --- a/driver-core/src/main/java/com/datastax/driver/core/QueryOptions.java +++ b/driver-core/src/main/java/com/datastax/driver/core/QueryOptions.java @@ -18,6 +18,7 @@ import com.datastax.driver.core.exceptions.UnsupportedFeatureException; import com.datastax.driver.core.utils.MoreFutures; import com.datastax.driver.core.utils.MoreObjects; +import com.google.common.base.Preconditions; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.MoreExecutors; @@ -101,10 +102,14 @@ void register(Cluster.Manager manager) { *

The consistency level set through this method will be use for queries that don't explicitly * have a consistency level, i.e. when {@link Statement#getConsistencyLevel} returns {@code null}. * - * @param consistencyLevel the new consistency level to set as default. + * @param consistencyLevel the new consistency level to set as default. It must not be {@code + * null}: every query needs a consistency level, so a null default would fail any statement + * that does not set one of its own. * @return this {@code QueryOptions} instance. + * @throws NullPointerException if {@code consistencyLevel} is {@code null}. */ public QueryOptions setConsistencyLevel(ConsistencyLevel consistencyLevel) { + Preconditions.checkNotNull(consistencyLevel, "consistencyLevel cannot be null"); this.consistencySet = true; this.consistency = consistencyLevel; return this; diff --git a/driver-core/src/main/java/com/datastax/driver/core/policies/ConstantSpeculativeExecutionPolicy.java b/driver-core/src/main/java/com/datastax/driver/core/policies/ConstantSpeculativeExecutionPolicy.java index 93e70a197fc..ee61a9d7ad9 100644 --- a/driver-core/src/main/java/com/datastax/driver/core/policies/ConstantSpeculativeExecutionPolicy.java +++ b/driver-core/src/main/java/com/datastax/driver/core/policies/ConstantSpeculativeExecutionPolicy.java @@ -18,6 +18,7 @@ import com.datastax.driver.core.Cluster; import com.datastax.driver.core.Host; import com.datastax.driver.core.Statement; +import com.google.common.annotations.Beta; import com.google.common.base.Preconditions; import java.util.concurrent.atomic.AtomicInteger; @@ -52,6 +53,28 @@ public ConstantSpeculativeExecutionPolicy( this.maxSpeculativeExecutions = maxSpeculativeExecutions; } + /** + * The number of speculative executions this policy schedules for a request, as specified at + * instantiation. This does not include the initial, normal request. + * + * @return the number of speculative executions, always strictly positive. + */ + @Beta + public int getMaxSpeculativeExecutions() { + return maxSpeculativeExecutions; + } + + /** + * The delay between each speculative execution, as specified at instantiation. Zero means all + * executions are sent immediately, along with the original request. + * + * @return the delay in milliseconds, never negative. + */ + @Beta + public long getConstantDelayMillis() { + return constantDelayMillis; + } + @Override public SpeculativeExecutionPlan newPlan(String loggedKeyspace, Statement statement) { return new SpeculativeExecutionPlan() { diff --git a/driver-core/src/main/java/com/datastax/driver/core/policies/DCAwareRoundRobinPolicy.java b/driver-core/src/main/java/com/datastax/driver/core/policies/DCAwareRoundRobinPolicy.java index a1274f6b458..dd24a56b47d 100644 --- a/driver-core/src/main/java/com/datastax/driver/core/policies/DCAwareRoundRobinPolicy.java +++ b/driver-core/src/main/java/com/datastax/driver/core/policies/DCAwareRoundRobinPolicy.java @@ -21,6 +21,7 @@ import com.datastax.driver.core.Host; import com.datastax.driver.core.HostDistance; import com.datastax.driver.core.Statement; +import com.google.common.annotations.Beta; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Joiner; import com.google.common.base.Preconditions; @@ -77,6 +78,7 @@ public static Builder builder() { private final int usedHostsPerRemoteDc; private final boolean dontHopForLocalCL; + private final boolean localDcExplicit; private volatile Configuration configuration; @@ -90,6 +92,43 @@ private DCAwareRoundRobinPolicy( this.localDc = localDc == null ? UNSET : localDc; this.usedHostsPerRemoteDc = usedHostsPerRemoteDc; this.dontHopForLocalCL = !allowRemoteDCsForLocalConsistencyLevel; + this.localDcExplicit = !Strings.isNullOrEmpty(localDc); + } + + /** + * The datacenter this policy considers local, or {@code null} if it has neither been configured + * explicitly nor inferred yet. When {@link #isLocalDcExplicit()} is {@code false}, this is the + * datacenter inferred from the first contacted node, which is only available once the policy has + * been initialized. + * + * @return the local datacenter name, or {@code null}. + */ + @Beta + public String getLocalDc() { + String dc = localDc; + return Strings.isNullOrEmpty(dc) ? null : dc; + } + + /** + * Whether the local datacenter was configured explicitly (as opposed to being inferred from the + * first contacted node). + * + * @return {@code true} if the local datacenter was set explicitly. + */ + @Beta + public boolean isLocalDcExplicit() { + return localDcExplicit; + } + + /** + * The number of hosts per remote datacenter that this policy considers for failover (0 means no + * remote failover). + * + * @return the number of used hosts per remote datacenter. + */ + @Beta + public int getUsedHostsPerRemoteDc() { + return usedHostsPerRemoteDc; } @Override diff --git a/driver-core/src/main/java/com/datastax/driver/core/policies/HostFilterPolicy.java b/driver-core/src/main/java/com/datastax/driver/core/policies/HostFilterPolicy.java index 8bb1a5e6573..d2546157202 100644 --- a/driver-core/src/main/java/com/datastax/driver/core/policies/HostFilterPolicy.java +++ b/driver-core/src/main/java/com/datastax/driver/core/policies/HostFilterPolicy.java @@ -19,6 +19,7 @@ import com.datastax.driver.core.Host; import com.datastax.driver.core.HostDistance; import com.datastax.driver.core.Statement; +import com.google.common.annotations.Beta; import com.google.common.base.Predicate; import com.google.common.base.Predicates; import com.google.common.collect.ImmutableSet; @@ -26,6 +27,7 @@ import java.util.Collection; import java.util.Iterator; import java.util.List; +import java.util.Set; /** * A load balancing policy wrapper that ensures that only hosts matching the predicate will ever be @@ -39,6 +41,7 @@ public class HostFilterPolicy implements ChainableLoadBalancingPolicy { private final LoadBalancingPolicy childPolicy; private final Predicate predicate; + private final ImmutableSet whiteListedDcs; /** * Create a new policy that wraps the provided child policy but only "allows" hosts matching the @@ -49,8 +52,16 @@ public class HostFilterPolicy implements ChainableLoadBalancingPolicy { * (whether they will get connected to or not depends on the child policy). */ public HostFilterPolicy(LoadBalancingPolicy childPolicy, Predicate predicate) { + this(childPolicy, predicate, ImmutableSet.of()); + } + + private HostFilterPolicy( + LoadBalancingPolicy childPolicy, + Predicate predicate, + ImmutableSet whiteListedDcs) { this.childPolicy = childPolicy; this.predicate = predicate; + this.whiteListedDcs = whiteListedDcs; } @Override @@ -58,6 +69,24 @@ public LoadBalancingPolicy getChildPolicy() { return childPolicy; } + /** + * The datacenters this policy restricts the session to, or an empty set if that is not something + * it can say. + * + *

A {@link Predicate} is opaque once built, so only {@link + * #fromDCWhiteList(LoadBalancingPolicy, Iterable)} populates this: it is the one factory whose + * argument is the set of allowed datacenters. An instance built from {@link + * #fromDCBlackList(LoadBalancingPolicy, Iterable)} or from a caller-supplied predicate returns an + * empty set — a denied datacenter names no preferred one, and an arbitrary predicate cannot be + * read back at all. + * + * @return an immutable set of allowed datacenter names, empty when unknown. + */ + @Beta + public Set getWhiteListedDatacenters() { + return whiteListedDcs; + } + /** * {@inheritDoc} * @@ -137,7 +166,8 @@ public void close() { */ public static HostFilterPolicy fromDCWhiteList( LoadBalancingPolicy childPolicy, Iterable dcs) { - return new HostFilterPolicy(childPolicy, hostDCPredicate(dcs, true)); + ImmutableSet whiteListedDcs = ImmutableSet.copyOf(dcs); + return new HostFilterPolicy(childPolicy, hostDCPredicate(whiteListedDcs, true), whiteListedDcs); } /** diff --git a/driver-core/src/main/java/com/datastax/driver/core/policies/PagingOptimizingLoadBalancingPolicy.java b/driver-core/src/main/java/com/datastax/driver/core/policies/PagingOptimizingLoadBalancingPolicy.java index edfe690900e..961b56b1290 100644 --- a/driver-core/src/main/java/com/datastax/driver/core/policies/PagingOptimizingLoadBalancingPolicy.java +++ b/driver-core/src/main/java/com/datastax/driver/core/policies/PagingOptimizingLoadBalancingPolicy.java @@ -8,11 +8,12 @@ import com.datastax.driver.core.Host; import com.datastax.driver.core.HostDistance; import com.datastax.driver.core.Statement; +import com.google.common.annotations.Beta; import java.util.Collection; import java.util.Iterator; import java.util.concurrent.CopyOnWriteArrayList; -public class PagingOptimizingLoadBalancingPolicy implements LoadBalancingPolicy { +public class PagingOptimizingLoadBalancingPolicy implements ChainableLoadBalancingPolicy { private final LoadBalancingPolicy wrapped; private volatile CopyOnWriteArrayList hosts; @@ -20,6 +21,12 @@ public PagingOptimizingLoadBalancingPolicy(LoadBalancingPolicy loadBalancingPoli wrapped = loadBalancingPolicy; } + @Override + @Beta + public LoadBalancingPolicy getChildPolicy() { + return wrapped; + } + @Override public void init(Cluster cluster, Collection hosts) { this.hosts = new CopyOnWriteArrayList(hosts); diff --git a/driver-core/src/main/java/com/datastax/driver/core/policies/PercentileSpeculativeExecutionPolicy.java b/driver-core/src/main/java/com/datastax/driver/core/policies/PercentileSpeculativeExecutionPolicy.java index ecad11826c9..a7d7068b50e 100644 --- a/driver-core/src/main/java/com/datastax/driver/core/policies/PercentileSpeculativeExecutionPolicy.java +++ b/driver-core/src/main/java/com/datastax/driver/core/policies/PercentileSpeculativeExecutionPolicy.java @@ -22,6 +22,7 @@ import com.datastax.driver.core.LatencyTracker; import com.datastax.driver.core.PercentileTracker; import com.datastax.driver.core.Statement; +import com.google.common.annotations.Beta; import java.util.concurrent.atomic.AtomicInteger; /** @@ -58,6 +59,28 @@ public PercentileSpeculativeExecutionPolicy( this.maxSpeculativeExecutions = maxSpeculativeExecutions; } + /** + * The maximum number of speculative executions this policy triggers for a request, as specified + * at instantiation. This does not include the initial, normal request. + * + * @return the maximum number of speculative executions, always strictly positive. + */ + @Beta + public int getMaxSpeculativeExecutions() { + return maxSpeculativeExecutions; + } + + /** + * The latency percentile a request must fall into to be considered slow, as specified at + * instantiation. + * + * @return the percentile, in the range 0 (inclusive) to 100 (exclusive). + */ + @Beta + public double getPercentile() { + return percentile; + } + @Override public SpeculativeExecutionPlan newPlan(String loggedKeyspace, Statement statement) { return new SpeculativeExecutionPlan() { diff --git a/driver-core/src/main/java/com/datastax/driver/core/policies/RackAwareRoundRobinPolicy.java b/driver-core/src/main/java/com/datastax/driver/core/policies/RackAwareRoundRobinPolicy.java index ab3d019cbb8..04f4f4bf858 100644 --- a/driver-core/src/main/java/com/datastax/driver/core/policies/RackAwareRoundRobinPolicy.java +++ b/driver-core/src/main/java/com/datastax/driver/core/policies/RackAwareRoundRobinPolicy.java @@ -27,6 +27,7 @@ import com.datastax.driver.core.Host; import com.datastax.driver.core.HostDistance; import com.datastax.driver.core.Statement; +import com.google.common.annotations.Beta; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Joiner; import com.google.common.base.Preconditions; @@ -90,6 +91,8 @@ public static Builder builder() { private final int usedHostsPerRemoteDc; private final boolean dontHopForLocalCL; + private final boolean localDcExplicit; + private final boolean localRackExplicit; private volatile Configuration configuration; @@ -109,6 +112,56 @@ public RackAwareRoundRobinPolicy( this.localRack = localRack == null ? UNSET : localRack; this.usedHostsPerRemoteDc = usedHostsPerRemoteDc; this.dontHopForLocalCL = !allowRemoteDCsForLocalConsistencyLevel; + this.localDcExplicit = !Strings.isNullOrEmpty(localDc); + this.localRackExplicit = !Strings.isNullOrEmpty(localRack); + } + + /** + * The datacenter this policy considers local, or {@code null} if it has neither been configured + * explicitly nor inferred yet. When {@link #isLocalDcExplicit()} is {@code false}, this is the + * datacenter inferred from the first contacted node, which is only available once the policy has + * been initialized. + * + * @return the local datacenter name, or {@code null}. + */ + @Beta + public String getLocalDc() { + String dc = localDc; + return Strings.isNullOrEmpty(dc) ? null : dc; + } + + /** + * The rack this policy considers local, or {@code null} if it has neither been configured + * explicitly nor inferred yet. + * + * @return the local rack name, or {@code null}. + */ + @Beta + public String getLocalRack() { + String rack = localRack; + return Strings.isNullOrEmpty(rack) ? null : rack; + } + + /** + * Whether the local datacenter was configured explicitly (as opposed to being inferred from the + * first contacted node). + * + * @return {@code true} if the local datacenter was set explicitly. + */ + @Beta + public boolean isLocalDcExplicit() { + return localDcExplicit; + } + + /** + * Whether the local rack was configured explicitly (as opposed to being inferred from the first + * contacted node). + * + * @return {@code true} if the local rack was set explicitly. + */ + @Beta + public boolean isLocalRackExplicit() { + return localRackExplicit; } @Override diff --git a/driver-core/src/main/java/com/datastax/driver/core/policies/TokenAwarePolicy.java b/driver-core/src/main/java/com/datastax/driver/core/policies/TokenAwarePolicy.java index afb7a526c75..ae138f9d8f1 100644 --- a/driver-core/src/main/java/com/datastax/driver/core/policies/TokenAwarePolicy.java +++ b/driver-core/src/main/java/com/datastax/driver/core/policies/TokenAwarePolicy.java @@ -35,6 +35,7 @@ import com.datastax.driver.core.QueryOptions; import com.datastax.driver.core.Statement; import com.datastax.driver.core.Token; +import com.google.common.annotations.Beta; import com.google.common.collect.AbstractIterator; import java.nio.ByteBuffer; import java.util.ArrayList; @@ -392,6 +393,17 @@ public LoadBalancingPolicy getChildPolicy() { return childPolicy; } + /** + * The strategy this policy uses to order the replicas of a query's partition, as specified at + * instantiation. + * + * @return the replica ordering strategy. + */ + @Beta + public ReplicaOrdering getReplicaOrdering() { + return replicaOrdering; + } + @Override public void init(Cluster cluster, Collection hosts) { clusterMetadata = cluster.getMetadata(); diff --git a/driver-core/src/test/java/com/datastax/driver/core/DefaultDriverConfigReporterTest.java b/driver-core/src/test/java/com/datastax/driver/core/DefaultDriverConfigReporterTest.java index cabc6a591a5..f70dc56e886 100644 --- a/driver-core/src/test/java/com/datastax/driver/core/DefaultDriverConfigReporterTest.java +++ b/driver-core/src/test/java/com/datastax/driver/core/DefaultDriverConfigReporterTest.java @@ -16,11 +16,85 @@ package com.datastax.driver.core; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import com.datastax.driver.core.policies.ConstantReconnectionPolicy; +import com.datastax.driver.core.policies.ConstantSpeculativeExecutionPolicy; +import com.datastax.driver.core.policies.DCAwareRoundRobinPolicy; +import com.datastax.driver.core.policies.DelegatingLoadBalancingPolicy; +import com.datastax.driver.core.policies.DowngradingConsistencyRetryPolicy; +import com.datastax.driver.core.policies.ErrorAwarePolicy; +import com.datastax.driver.core.policies.FallthroughRetryPolicy; +import com.datastax.driver.core.policies.HostFilterPolicy; +import com.datastax.driver.core.policies.LatencyAwarePolicy; +import com.datastax.driver.core.policies.LoadBalancingPolicy; +import com.datastax.driver.core.policies.LoggingRetryPolicy; +import com.datastax.driver.core.policies.PagingOptimizingLoadBalancingPolicy; +import com.datastax.driver.core.policies.PercentileSpeculativeExecutionPolicy; +import com.datastax.driver.core.policies.RackAwareRoundRobinPolicy; +import com.datastax.driver.core.policies.ReconnectionPolicy; +import com.datastax.driver.core.policies.RoundRobinPolicy; +import com.datastax.driver.core.policies.SpeculativeExecutionPolicy; +import com.datastax.driver.core.policies.TokenAwarePolicy; +import com.datastax.driver.core.policies.WhiteListPolicy; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.base.Predicate; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion; +import com.networknt.schema.ValidationMessage; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Set; import org.testng.annotations.Test; public class DefaultDriverConfigReporterTest { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + // The normative 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. + // + // "v1" throughout is the report's "version" field, which the schema pins to 1. The design doc has + // been revised several times without bumping it — only a key whose meaning changes does that, and + // nothing has shipped yet — so the doc's revision is deliberately not a label here. + private static final JsonSchema SCHEMA = loadSchema(); + + // The one gap left between the 3.x configuration and the schema, carried only by a report of a + // PercentileSpeculativeExecutionPolicy configured with a percentile of 0: the policy accepts it + // and the schema's exclusiveMinimum does not. See should_report_a_zero_percentile_as_is and the + // reporter's class javadoc. + private static final String PERCENTILE_ZERO_GAP = + "$.query.speculative-execution.policy.percentile: must have an exclusive minimum value of 0"; + + private static final String SPECULATIVE_EXECUTION_POLICY_PATH = + "$.query.speculative-execution.policy"; + + 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); + } + } + + // ---- Stage 1: default / fail-safe ----------------------------------------------------------- + private static Configuration config() { return Configuration.builder().build(); } @@ -37,10 +111,11 @@ public void should_enable_driver_config_reporting_by_default() { } @Test(groups = "unit") - public void should_report_schema_version() { - // Stage 1 emits only the schema version. - assertThat(new DefaultDriverConfigReporter(config()).buildReport()) - .isEqualTo("{\"version\":1}"); + public void should_report_schema_version_and_config_groups() throws Exception { + JsonNode report = MAPPER.readTree(new DefaultDriverConfigReporter(config()).buildReport()); + + assertThat(report.path("version").asInt()).isEqualTo(1); + assertThat(report.has("connection")).isTrue(); } @Test(groups = "unit") @@ -57,4 +132,1578 @@ protected String buildJson() { // no DRIVER_CONFIG option is sent, and nothing else about the connection is affected. assertThat(reporter.buildReport()).isNull(); } + + @Test(groups = "unit") + public void should_be_fail_safe_when_report_build_throws_internal_error() { + DefaultDriverConfigReporter reporter = + new DefaultDriverConfigReporter(config()) { + @Override + protected String buildJson() { + // customPolicy() calls getClass().getSimpleName() on arbitrary user-supplied policy + // objects, which has a documented JDK edge case throwing InternalError for certain + // synthetic classes. + throw new InternalError("simulated getSimpleName() JDK edge case"); + } + }; + + assertThat(reporter.buildReport()).isNull(); + } + + @Test(groups = "unit") + public void should_build_the_report_through_the_connection_factory_guard() throws Exception { + // Connection.Factory never touches DefaultDriverConfigReporter on the connection path: on a + // classpath without Jackson, initializing that class raises a LinkageError from its static + // ObjectMapper field -- an Error raised while initializing the class, so no fail-safe inside it + // could catch it, and the Cluster would fail to initialize at all rather than merely skip the + // report. The guard runs once, as the Cluster initializes, and decides whether any control + // connection may build a report at all; on a normal classpath it is transparent, which is what + // this pins. + assertThat(Connection.Factory.canBuildDriverConfigReport(Cluster.builder().getConfiguration())) + .isTrue(); + } + + @Test(groups = "unit") + 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. + assertThat(reporting(oversizedReport()).buildReport()).isNull(); + } + + @Test(groups = "unit") + public void should_return_driver_config_that_is_just_within_the_size_limit() { + String atLimit = padTo(DefaultDriverConfigReporter.MAX_DRIVER_CONFIG_LENGTH); + + assertThat(reporting(atLimit).buildReport()).isEqualTo(atLimit); + } + + @Test(groups = "unit") + public void should_report_default_configuration_within_the_size_limit() { + // Tripwire: the real report is nowhere near the limit today. If it ever grows past it, this + // fails loudly instead of DRIVER_CONFIG silently disappearing from the wire. + String report = + new DefaultDriverConfigReporter(Cluster.builder().getConfiguration()).buildReport(); + + assertThat(report).isNotNull(); + assertThat(report.getBytes(StandardCharsets.UTF_8).length) + .isLessThanOrEqualTo(DefaultDriverConfigReporter.MAX_DRIVER_CONFIG_LENGTH); + } + + @Test(groups = "unit", timeOut = 30000) + public void should_not_follow_a_cyclic_load_balancing_policy_chain_forever() throws Exception { + // getChildPolicy() is walked on arbitrary user policies, so a cyclic chain would spin forever + // on the cluster-initialization path. That is the one failure mode the reporter's try/catch + // cannot contain, since it hangs rather than throws. The walk is bounded, so the report is + // still produced, describing the outermost policy. + JsonNode report = + report(Cluster.builder().withLoadBalancingPolicy(new CyclicLoadBalancingPolicy())); + + assertThat(lbPolicy(report).path("type").asText()).isEqualTo("custom"); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_name_the_configured_policy_not_the_internal_wrapper() throws Exception { + // Cluster.Manager wraps every configured policy in PagingOptimizingLoadBalancingPolicy, so + // naming the outermost policy of the chain would report that internal wrapper for every custom + // policy, telling an operator nothing about what the client actually runs. + JsonNode report = + report( + Cluster.builder() + .withLoadBalancingPolicy( + new PagingOptimizingLoadBalancingPolicy(new CustomLoadBalancingPolicy()))); + + JsonNode policy = lbPolicy(report); + assertThat(policy.path("type").asText()).isEqualTo("custom"); + assertThat(policy.path("name").asText()).isEqualTo("CustomLoadBalancingPolicy"); + assertConformsToSchema(report); + } + + // ---- Stage 2: the full report ---------------------------------------------------------------- + + @Test(groups = "unit") + public void should_report_default_configuration_shape() throws Exception { + JsonNode report = report(Cluster.builder()); + + assertThat(report.path("version").asInt()).isEqualTo(1); + // Everything hangs off exactly three groups: the schema keeps connection-scoped settings under + // "connection", control-connection ones under "control-plane", and everything acting on a query + // under "query". + assertThat(fieldNames(report)).containsOnly("version", "connection", "control-plane", "query"); + + // connection: connect + read only (no write timeout, no heartbeat in this schema version), plus + // the per-connection request capacity, the pool, the socket and the reconnection policy. + JsonNode connection = report.path("connection"); + assertThat(connection.path("connect").path("timeout-ms").asInt()).isEqualTo(5000); + assertThat(connection.path("read").path("timeout-ms").asInt()).isEqualTo(12000); + assertThat(connection.has("write")).isFalse(); + assertThat(connection.has("heartbeat")).isFalse(); + // Effective v3+ default; "orphaned" has no 3.x equivalent and is omitted. + assertThat(connection.path("requests").path("in-flight").path("max").asInt()).isEqualTo(1024); + assertThat(connection.path("requests").has("orphaned")).isFalse(); + // shard-aware is on by default via Cluster.Builder; the pool carries nothing else. + assertThat(connection.path("pool").path("shard-aware").path("enabled").asBoolean()).isTrue(); + // The datacenter half of the node preference, which is what scopes pooling. Nothing is + // configured by default, and nothing is inferred before the policy is initialized, so the + // name is absent — but dc-auto is the accurate claim, the default policy being DC-aware. + assertThat(connection.path("node-preference").path("type").asText()).isEqualTo("dc-auto"); + assertThat(connection.path("node-preference").has("local-dc")).isFalse(); + // TLS is off by default, and the group is absent rather than reporting "off". + assertThat(connection.has("tls")).isFalse(); + + // connection.socket: booleans present; buffers/linger omitted when unset. + JsonNode socket = connection.path("socket"); + assertThat(socket.path("tcp-no-delay").asBoolean()).isTrue(); + assertThat(socket.path("keep-alive").asBoolean()).isFalse(); + assertThat(socket.path("reuse-address").asBoolean()).isFalse(); + assertThat(socket.has("linger")).isFalse(); + assertThat(socket.has("receive-buffer")).isFalse(); + assertThat(socket.has("send-buffer")).isFalse(); + + // connection.reconnection: exponential, unbounded (no max-attempts). + JsonNode reconnection = connection.path("reconnection").path("policy"); + assertThat(reconnection.path("type").asText()).isEqualTo("exponential"); + assertThat(reconnection.path("base-ms").asInt()).isEqualTo(1000); + assertThat(reconnection.path("max-ms").asInt()).isEqualTo(600000); + assertThat(reconnection.has("max-attempts")).isFalse(); + + // control-plane. + JsonNode systemTimeout = + report.path("control-plane").path("queries").path("system").path("timeout"); + assertThat(systemTimeout.path("client-side-ms").asInt()).isEqualTo(12000); + assertThat(systemTimeout.has("server-side-ms")).isFalse(); + assertThat( + report + .path("control-plane") + .path("schema") + .path("agreement") + .path("timeout-ms") + .asInt()) + .isEqualTo(10000); + + JsonNode query = report.path("query"); + + // query.defaults. + JsonNode defaults = query.path("defaults"); + assertThat(defaults.path("page").path("size").asInt()).isEqualTo(5000); + assertThat(defaults.path("consistency").asText()).isEqualTo("LOCAL_ONE"); + assertThat(defaults.path("serial-consistency").asText()).isEqualTo("SERIAL"); + assertThat(defaults.path("idempotence").asBoolean()).isFalse(); + assertThat(defaults.path("client-timestamps").asBoolean()).isTrue(); + assertThat(defaults.path("request").path("timeout-ms").asInt()).isEqualTo(12000); + + // query.retry: no built-in 3.x retry policy delays its attempts, so there is no backoff, and + // none bounds every one of its error paths, so there is no retry count either. + assertThat(query.path("retry").path("policy").path("type").asText()) + .isEqualTo("standard-error-aware"); + assertThat(query.path("retry").has("backoff")).isFalse(); + assertThat(query.path("retry").path("policy").has("max-retries")).isFalse(); + + // query.speculative-execution: absent, since there is none by default. + assertThat(query.has("speculative-execution")).isFalse(); + + // query.load-balancing: the default is token-aware over DC-aware (auto DC), with RANDOM replica + // ordering. + JsonNode lb = query.path("load-balancing").path("policy"); + assertThat(lb.path("type").asText()).isEqualTo("token-aware"); + assertThat(lb.path("load-distribution").asText()).isEqualTo("shuffle"); + assertThat(lb.path("fallback-to-non-preferred-nodes").asBoolean()).isFalse(); + // Nothing reorders candidates at runtime, and the group's "signals" cannot be empty, so it is + // omitted rather than reported as disabled. + assertThat(lb.has("adaptive-ordering")).isFalse(); + + // query.load-balancing.node-preference: inferred DC, not yet resolved. + JsonNode nodePreference = query.path("load-balancing").path("node-preference"); + assertThat(nodePreference.path("type").asText()).isEqualTo("dc-auto"); + assertThat(nodePreference.has("local-dc")).isFalse(); + } + + @Test(groups = "unit") + public void should_report_constant_reconnection_policy() throws Exception { + JsonNode report = + report(Cluster.builder().withReconnectionPolicy(new ConstantReconnectionPolicy(2500))); + + JsonNode reconnection = report.path("connection").path("reconnection").path("policy"); + assertThat(reconnection.path("type").asText()).isEqualTo("constant"); + assertThat(reconnection.path("delay-ms").asInt()).isEqualTo(2500); + assertThat(reconnection.has("max-attempts")).isFalse(); + } + + @Test(groups = "unit") + public void should_discriminate_retry_policies() throws Exception { + assertThat(retryPolicyType(Cluster.builder().withRetryPolicy(FallthroughRetryPolicy.INSTANCE))) + .isEqualTo("fallthrough"); + + JsonNode downgrading = + retryPolicy( + report(Cluster.builder().withRetryPolicy(DowngradingConsistencyRetryPolicy.INSTANCE))); + assertThat(downgrading.path("type").asText()).isEqualTo("downgrading-consistency"); + // This policy stops after one attempt on the errors it downgrades, but tries the next host + // without a bound on request errors, so there is no single retry count to report. + assertThat(downgrading.has("max-retries")).isFalse(); + } + + @Test(groups = "unit") + public void should_report_constant_speculative_execution() throws Exception { + JsonNode report = + report( + Cluster.builder() + .withSpeculativeExecutionPolicy(new ConstantSpeculativeExecutionPolicy(100L, 2))); + + JsonNode specEx = speculativeExecution(report); + assertThat(specEx.path("type").asText()).isEqualTo("constant"); + assertThat(specEx.path("max-executions").asInt()).isEqualTo(2); + assertThat(specEx.path("delay-ms").asLong()).isEqualTo(100L); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_report_percentile_speculative_execution() throws Exception { + JsonNode report = report(Cluster.builder().withSpeculativeExecutionPolicy(percentile(99.0))); + + JsonNode specEx = speculativeExecution(report); + assertThat(specEx.path("type").asText()).isEqualTo("percentile"); + assertThat(specEx.path("max-executions").asInt()).isEqualTo(3); + assertThat(specEx.path("percentile").asDouble()).isEqualTo(99.0); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_report_a_custom_speculative_execution_policy_by_name() throws Exception { + JsonNode report = + report(Cluster.builder().withSpeculativeExecutionPolicy(new CustomSpeculativeExecution())); + + JsonNode specEx = speculativeExecution(report); + assertThat(specEx.path("type").asText()).isEqualTo("custom"); + assertThat(specEx.path("name").asText()).isEqualTo("CustomSpeculativeExecution"); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_report_explicit_datacenter_node_location_preference() throws Exception { + JsonNode report = + report( + Cluster.builder() + .withLoadBalancingPolicy( + new TokenAwarePolicy( + DCAwareRoundRobinPolicy.builder().withLocalDc("dc1").build()))); + + JsonNode nodePreference = nodePreference(report); + assertThat(nodePreference.path("type").asText()).isEqualTo("dc"); + assertThat(nodePreference.path("local-dc").asText()).isEqualTo("dc1"); + assertThat(nodePreference.has("local-rack")).isFalse(); + // The same preference scopes pooling, so it is reported under connection as well. A DC-aware + // policy has nothing beyond the datacenter, so the two slots agree exactly here. + assertThat(connectionNodePreference(report)).isEqualTo(nodePreference); + // token-aware wrapper is still reflected in the policy next to it. + assertThat(lbPolicy(report).path("type").asText()).isEqualTo("token-aware"); + } + + @Test(groups = "unit") + public void should_unwrap_paging_optimizing_load_balancing_policy() throws Exception { + // At runtime Cluster.Manager wraps the configured LB policy in a + // PagingOptimizingLoadBalancingPolicy, so the reporter must unwrap it to recover the real + // policy's flags and location preference (rather than reporting it as a custom policy). + JsonNode report = + report( + Cluster.builder() + .withLoadBalancingPolicy( + new PagingOptimizingLoadBalancingPolicy( + new TokenAwarePolicy( + DCAwareRoundRobinPolicy.builder().withLocalDc("dc1").build())))); + + assertThat(lbPolicy(report).path("type").asText()).isEqualTo("token-aware"); + assertThat(lbPolicy(report).path("load-distribution").asText()).isEqualTo("shuffle"); + JsonNode nodePreference = nodePreference(report); + assertThat(nodePreference.path("type").asText()).isEqualTo("dc"); + assertThat(nodePreference.path("local-dc").asText()).isEqualTo("dc1"); + } + + @Test(groups = "unit") + public void should_report_rack_node_location_preference() throws Exception { + JsonNode report = + report( + Cluster.builder() + .withLoadBalancingPolicy( + new RackAwareRoundRobinPolicy("dc1", "rack1", 0, false, false, false))); + + JsonNode nodePreference = nodePreference(report); + assertThat(nodePreference.path("type").asText()).isEqualTo("rack"); + assertThat(nodePreference.path("local-dc").asText()).isEqualTo("dc1"); + assertThat(nodePreference.path("local-rack").asText()).isEqualTo("rack1"); + // The rack never reaches the connection slot: a local-datacenter host in another rack + // is REMOTE, not IGNORED, so it is still pooled and the rack scopes no pooling at all. + // Reporting "rack" here would claim a restriction the driver does not apply. + JsonNode connectionPreference = connectionNodePreference(report); + assertThat(connectionPreference.path("type").asText()).isEqualTo("dc"); + assertThat(connectionPreference.path("local-dc").asText()).isEqualTo("dc1"); + assertThat(connectionPreference.has("local-rack")).isFalse(); + // Not token-aware, so the policy itself can only be reported by name. + assertThat(lbPolicy(report).path("name").asText()).isEqualTo("RackAwareRoundRobinPolicy"); + } + + @Test(groups = "unit") + public void should_report_configured_and_inferred_rack_location_under_separate_keys() + throws Exception { + // The datacenter is configured and the rack is left to be inferred, which the schema reports as + // rack-auto with the configured part under local-dc — it admits only one key per part, and + // rejects a configured DC and rack together (that is the "rack" type). + JsonNode report = + report( + Cluster.builder() + .withLoadBalancingPolicy( + new RackAwareRoundRobinPolicy("dc1", null, 0, false, false, true))); + + JsonNode nodePreference = nodePreference(report); + assertThat(nodePreference.path("type").asText()).isEqualTo("rack-auto"); + assertThat(nodePreference.path("local-dc").asText()).isEqualTo("dc1"); + assertThat(nodePreference.has("inferred-local-dc")).isFalse(); + // Nothing has been inferred yet: the policy is only initialized once the cluster connects. + assertThat(nodePreference.has("local-rack")).isFalse(); + assertThat(nodePreference.has("inferred-local-rack")).isFalse(); + // The connection slot takes the configured datacenter, and the pending rack leaves no trace in + // it — rack-auto is a claim about routing only. + JsonNode connectionPreference = connectionNodePreference(report); + assertThat(connectionPreference.path("type").asText()).isEqualTo("dc"); + assertThat(connectionPreference.path("local-dc").asText()).isEqualTo("dc1"); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_report_the_datacenter_the_policy_has_already_inferred() throws Exception { + DCAwareRoundRobinPolicy policy = DCAwareRoundRobinPolicy.builder().build(); + Cluster.Builder builder = Cluster.builder().withLoadBalancingPolicy(policy); + + // Cluster.Manager.init() builds the Connection.Factory, and the first control connection sends + // its STARTUP, before it calls LoadBalancingPolicy#init -- so the report that first handshake + // carries can only say that a datacenter will be inferred, not which one. + JsonNode first = report(builder); + assertThat(nodePreference(first).path("type").asText()).isEqualTo("dc-auto"); + assertThat(nodePreference(first).has("local-dc")).isFalse(); + assertThat(connectionNodePreference(first).has("local-dc")).isFalse(); + assertConformsToSchema(first); + + initWithNode(policy, "dc-inferred", "rack1"); + + // Every later control connection builds its own report, so the datacenter the policy has since + // inferred reaches the server on the first reconnect. Nothing was cached in between. + JsonNode second = report(builder); + assertThat(nodePreference(second).path("type").asText()).isEqualTo("dc-auto"); + assertThat(nodePreference(second).path("local-dc").asText()).isEqualTo("dc-inferred"); + assertThat(connectionNodePreference(second).path("local-dc").asText()).isEqualTo("dc-inferred"); + assertConformsToSchema(second); + } + + @Test(groups = "unit") + public void should_report_the_rack_the_policy_has_already_inferred() throws Exception { + // Same for a rack-aware policy, which infers both halves -- and this is the case that puts the + // schema's inferred-* keys on the wire at all: rack-auto names the inferred datacenter and rack + // under their own keys, so that a consumer can tell them from configured ones. + RackAwareRoundRobinPolicy policy = + new RackAwareRoundRobinPolicy(null, null, 0, false, true, true); + Cluster.Builder builder = Cluster.builder().withLoadBalancingPolicy(policy); + + JsonNode first = report(builder); + assertThat(nodePreference(first).path("type").asText()).isEqualTo("rack-auto"); + assertThat(nodePreference(first).has("inferred-local-dc")).isFalse(); + assertThat(nodePreference(first).has("inferred-local-rack")).isFalse(); + assertConformsToSchema(first); + + initWithNode(policy, "dc-inferred", "rack-inferred"); + + JsonNode second = report(builder); + assertThat(nodePreference(second).path("type").asText()).isEqualTo("rack-auto"); + assertThat(nodePreference(second).path("inferred-local-dc").asText()).isEqualTo("dc-inferred"); + assertThat(nodePreference(second).path("inferred-local-rack").asText()) + .isEqualTo("rack-inferred"); + assertThat(nodePreference(second).has("local-dc")).isFalse(); + assertThat(nodePreference(second).has("local-rack")).isFalse(); + // The connection slot takes the datacenter half only, and reports an inferred one under the + // plain local-dc key -- dc-auto has no inferred- prefix, which is the schema's own asymmetry. + assertThat(connectionNodePreference(second).path("type").asText()).isEqualTo("dc-auto"); + assertThat(connectionNodePreference(second).path("local-dc").asText()).isEqualTo("dc-inferred"); + assertConformsToSchema(second); + } + + @Test(groups = "unit") + public void should_report_server_side_timestamps_as_disabled_client_timestamps() + throws Exception { + JsonNode report = + report(Cluster.builder().withTimestampGenerator(ServerSideTimestampGenerator.INSTANCE)); + + assertThat(queryDefaults(report).path("client-timestamps").asBoolean()).isFalse(); + } + + @Test(groups = "unit") + public void should_omit_client_timestamps_for_a_custom_timestamp_generator() throws Exception { + // next() returning Long.MIN_VALUE is documented as "let Cassandra generate the timestamp", and + // a custom generator decides that per call — so whether timestamps are assigned client-side is + // not a property of the configuration at all. Optional, and documented as absent exactly when + // the behavior is unknown, so it is omitted rather than guessed from the class. + JsonNode report = report(Cluster.builder().withTimestampGenerator(new CustomTimestamps())); + + assertThat(queryDefaults(report).has("client-timestamps")).isFalse(); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_report_client_timestamps_for_both_built_in_monotonic_generators() + throws Exception { + // Neither of the two can return Long.MIN_VALUE, so both always assign the timestamp + // client-side. The default is the atomic one, pinned by + // should_report_default_configuration_shape. + for (TimestampGenerator generator : + new TimestampGenerator[] { + new AtomicMonotonicTimestampGenerator(), new ThreadLocalMonotonicTimestampGenerator() + }) { + JsonNode report = report(Cluster.builder().withTimestampGenerator(generator)); + + assertThat(queryDefaults(report).path("client-timestamps").asBoolean()).isTrue(); + assertConformsToSchema(report); + } + } + + @Test(groups = "unit") + public void should_report_the_configured_page_size() throws Exception { + JsonNode report = + report(Cluster.builder().withQueryOptions(new QueryOptions().setFetchSize(1234))); + + assertThat(queryDefaults(report).path("page").path("size").asInt()).isEqualTo(1234); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_omit_the_page_size_when_paging_is_disabled() throws Exception { + // Integer.MAX_VALUE is how QueryOptions#setFetchSize documents "disable paging", and it is the + // only way a session can end up unpaged: the setter rejects anything <= 0. The schema says page + // is absent when paging is not limited, so the group goes rather than carrying the sentinel as + // if it were a page size somebody chose. + JsonNode report = + report( + Cluster.builder().withQueryOptions(new QueryOptions().setFetchSize(Integer.MAX_VALUE))); + + assertThat(queryDefaults(report).has("page")).isFalse(); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_report_a_serial_default_consistency() throws Exception { + // QueryOptions accepts a serial level as the default *request* consistency, which is legal for + // a serial read, and the schema's enum admits both of them — so this needs no special handling + // and the document stays valid. It used to be one of the gaps the reporter knowingly violated. + for (ConsistencyLevel level : + new ConsistencyLevel[] {ConsistencyLevel.SERIAL, ConsistencyLevel.LOCAL_SERIAL}) { + JsonNode report = + report(Cluster.builder().withQueryOptions(new QueryOptions().setConsistencyLevel(level))); + + assertThat(queryDefaults(report).path("consistency").asText()).isEqualTo(level.name()); + assertConformsToSchema(report); + } + } + + @Test(groups = "unit") + public void should_omit_a_non_serial_default_serial_consistency() throws Exception { + // QueryOptions, unlike Statement, does not check that the level is serial, so a non-serial one + // reaches the reporter. serial-consistency is optional, so it is omitted rather than emitted as + // a value the schema's SERIAL/LOCAL_SERIAL enum rejects. + JsonNode report = + report( + Cluster.builder() + .withQueryOptions( + new QueryOptions().setSerialConsistencyLevel(ConsistencyLevel.QUORUM))); + + assertThat(queryDefaults(report).has("serial-consistency")).isFalse(); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_reject_a_null_default_consistency() { + // Every query needs a level, so a null default fails any statement that does not set one of + // its own: RoundRobinPolicy and DCAwareRoundRobinPolicy call isDCLocal() on it while building + // a query plan. Rejecting it here turns a driver that breaks on its first query into one that + // breaks while being configured. + try { + new QueryOptions().setConsistencyLevel(null); + fail("Expected a NullPointerException"); + } catch (NullPointerException e) { + assertThat(e).hasMessage("consistencyLevel cannot be null"); + } + } + + @Test(groups = "unit") + public void should_omit_a_default_consistency_that_a_subclass_nulls_out() throws Exception { + // With the setter rejecting null, only a QueryOptions subclass overriding the getter can still + // hand the reporter one. The key stays omitted even though the schema requires it — that beats + // losing every other group to an NPE caught by the fail-safe. + QueryOptions queryOptions = + new QueryOptions() { + @Override + public ConsistencyLevel getConsistencyLevel() { + return null; + } + }; + JsonNode report = report(Cluster.builder().withQueryOptions(queryOptions)); + + assertThat(queryDefaults(report).has("consistency")).isFalse(); + assertConformsToSchema(report, "$.query.defaults: required property 'consistency' not found"); + } + + @Test(groups = "unit") + public void should_report_tls_only_when_it_is_enabled() throws Exception { + // The group carries no "enabled" flag: its presence is what says TLS is on, so with TLS off it + // is omitted rather than reported as disabled. + assertThat(report(Cluster.builder()).path("connection").has("tls")).isFalse(); + + JsonNode tls = report(Cluster.builder().withSSL()).path("connection").path("tls"); + assertThat(tls.isObject()).isTrue(); + } + + @Test(groups = "unit") + public void should_report_hostname_verification_for_sni_ssl_options() throws Exception { + // SniSSLOptions is the one 3.x SSLOptions that verifies the hostname, and it hard-codes it on, + // so it is the only case the reporter can state. + JsonNode report = report(Cluster.builder().withSSL(SniSSLOptions.builder().build())); + + assertThat(report.path("connection").path("tls").path("hostname-verification").asBoolean()) + .isTrue(); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_omit_hostname_verification_it_cannot_observe() throws Exception { + // withSSL() installs RemoteEndpointAwareJdkSSLOptions, which builds its engine from a user + // SSLContext without touching endpoint identification; NettySSLOptions hands the whole handler + // to Netty. Neither can be read for it, so the key is omitted rather than asserted false — the + // schema documents it as absent exactly when the behavior is unknown. The group itself stays, + // since its presence is what reports that TLS is on. + JsonNode tls = report(Cluster.builder().withSSL()).path("connection").path("tls"); + + assertThat(tls.isObject()).isTrue(); + assertThat(tls.has("hostname-verification")).isFalse(); + assertThat(fieldNames(tls)).isEmpty(); + } + + @Test(groups = "unit") + public void should_report_socket_overrides() throws Exception { + SocketOptions socketOptions = + new SocketOptions() + .setKeepAlive(true) + .setReuseAddress(true) + .setSoLinger(15) + .setReceiveBufferSize(4096) + .setSendBufferSize(8192); + + JsonNode socket = socket(report(Cluster.builder().withSocketOptions(socketOptions))); + + assertThat(socket.path("keep-alive").asBoolean()).isTrue(); + assertThat(socket.path("reuse-address").asBoolean()).isTrue(); + assertThat(socket.path("linger").path("interval-s").asInt()).isEqualTo(15); + assertThat(socket.path("receive-buffer").path("size-bytes").asInt()).isEqualTo(4096); + assertThat(socket.path("send-buffer").path("size-bytes").asInt()).isEqualTo(8192); + } + + @Test(groups = "unit") + public void should_report_fallback_to_non_preferred_nodes_when_remote_hosts_are_used() + throws Exception { + // Leaving the preferred datacenter is the only way a DC-aware policy reaches a node outside + // the reported node-preference, so the flag follows usedHostsPerRemoteDc. It needs a + // token-aware wrapper to be reported at all: that is the only built-in shape the schema + // defines, and the only one carrying the flag. The false case is pinned by + // should_report_default_configuration_shape. + JsonNode report = + report( + Cluster.builder() + .withLoadBalancingPolicy( + new TokenAwarePolicy( + DCAwareRoundRobinPolicy.builder() + .withLocalDc("dc1") + .withUsedHostsPerRemoteDc(2) + .build()))); + + assertThat(lbPolicy(report).path("fallback-to-non-preferred-nodes").asBoolean()).isTrue(); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_always_report_fallback_to_non_preferred_nodes_for_a_rack_aware_policy() + throws Exception { + // A rack-aware policy reports a rack preference, and the other racks of its local datacenter + // are outside it yet are the second tier of every query plan — distance() returns REMOTE, not + // IGNORED, for them. So the flag is true even with no remote datacenter host configured, which + // is the default. + JsonNode report = + report( + Cluster.builder() + .withLoadBalancingPolicy( + new TokenAwarePolicy( + new RackAwareRoundRobinPolicy("dc1", "rack1", 0, false, false, false)))); + + assertThat(nodePreference(report).path("type").asText()).isEqualTo("rack"); + assertThat(lbPolicy(report).path("fallback-to-non-preferred-nodes").asBoolean()).isTrue(); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_report_fallback_to_non_preferred_nodes_as_false_without_a_preference() + throws Exception { + // A token-aware policy over a child with no locality notion has no preference to report, so the + // flag has nothing to be relative to: the schema defines it against + // query.load-balancing.node-preference, and that key is absent here. The flag is required on + // the + // token-aware branch, so it has to carry some value; false is reported because RoundRobinPolicy + // has no tiering to fall back *from*, not because anything is restricted. Pinned so the pairing + // cannot change silently while it is an open question for the schema owner. + JsonNode report = + report( + Cluster.builder() + .withLoadBalancingPolicy(new TokenAwarePolicy(new RoundRobinPolicy()))); + + assertThat(lbPolicy(report).path("type").asText()).isEqualTo("token-aware"); + assertThat(lbPolicy(report).path("fallback-to-non-preferred-nodes").asBoolean()).isFalse(); + assertThat(loadBalancing(report).has("node-preference")).isFalse(); + assertThat(report.path("connection").has("node-preference")).isFalse(); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_report_the_configured_in_flight_request_limit() throws Exception { + JsonNode report = + report( + Cluster.builder() + .withPoolingOptions( + new PoolingOptions().setMaxRequestsPerConnection(HostDistance.LOCAL, 512))); + + assertThat(report.path("connection").path("requests").path("in-flight").path("max").asInt()) + .isEqualTo(512); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_report_a_configured_zero_in_flight_request_limit_as_is() throws Exception { + // PoolingOptions rejects only negatives, so 0 is a limit an operator can set deliberately. + // Falling back to the protocol default for it would misreport the pool; the key is required and + // positive-only, so it is reported as-is and shows up as a violation instead. The other end + // needs no such treatment — see should_report_the_maximal_in_flight_request_limit. + JsonNode report = + report( + Cluster.builder() + .withPoolingOptions( + new PoolingOptions().setMaxRequestsPerConnection(HostDistance.LOCAL, 0))); + + assertThat(report.path("connection").path("requests").path("in-flight").path("max").asInt()) + .isEqualTo(0); + assertConformsToSchema( + report, "$.connection.requests.in-flight.max: must have a minimum value of 1"); + } + + @Test(groups = "unit") + public void should_report_the_maximal_in_flight_request_limit() throws Exception { + // The largest limit PoolingOptions accepts is the 32768 stream identifiers protocol v3 + // provides, and the schema bounds the key only from below, so the largest legal configuration + // is still a valid document. + JsonNode report = + report( + Cluster.builder() + .withPoolingOptions( + new PoolingOptions().setMaxRequestsPerConnection(HostDistance.LOCAL, 32768))); + + assertThat(report.path("connection").path("requests").path("in-flight").path("max").asInt()) + .isEqualTo(32768); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_report_the_in_flight_request_limit_of_the_pinned_protocol_version() + throws Exception { + // PoolingOptions is still UNSET when the report is built, so the limit comes from the DEFAULTS + // row the pools will eventually be sized from. That row is not always the v3 one: a user who + // pinned the protocol version gets the highest row not above it, and DEFAULTS only has v1 and + // v3 + // entries, so a v2 cluster is sized from v1's 128. Pinning the version is the one part of + // negotiation that is knowable at report time. + JsonNode report = report(Cluster.builder().withProtocolVersion(ProtocolVersion.V2)); + + assertThat(report.path("connection").path("requests").path("in-flight").path("max").asInt()) + .isEqualTo(128); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_omit_the_orphaned_request_limit_it_has_no_equivalent_for() throws Exception { + // A request the 3.x driver stopped waiting for keeps its stream identifier until the response + // arrives; there is no configurable bound on those and no connection replacement, so there is + // nothing to report. The group is optional as of the schema revision that added "absent only + // when this bound is unknown", so omitting it is now the schema-valid answer rather than the + // one violation every report carried — pinned here so the key cannot silently come back. + JsonNode report = report(Cluster.builder()); + + JsonNode requests = report.path("connection").path("requests"); + assertThat(requests.has("in-flight")).isTrue(); + assertThat(requests.has("orphaned")).isFalse(); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_report_load_distribution_from_the_replica_ordering() throws Exception { + assertThat(loadDistribution(TokenAwarePolicy.ReplicaOrdering.RANDOM)).isEqualTo("shuffle"); + assertThat(loadDistribution(TokenAwarePolicy.ReplicaOrdering.TOPOLOGICAL)) + .isEqualTo("replica-set"); + // NEUTRAL keeps the child policy's plan order, which for every built-in child rotates the first + // host across successive query plans. + assertThat(loadDistribution(TokenAwarePolicy.ReplicaOrdering.NEUTRAL)).isEqualTo("round-robin"); + } + + @Test(groups = "unit") + public void should_report_latency_awareness_as_adaptive_ordering() throws Exception { + // The wrapper contributes a capability, not a type: the type still comes from the token-aware + // policy it wraps. + JsonNode report = + report( + Cluster.builder() + .withLoadBalancingPolicy( + LatencyAwarePolicy.builder( + new TokenAwarePolicy( + DCAwareRoundRobinPolicy.builder().withLocalDc("dc1").build())) + .build())); + + JsonNode lb = lbPolicy(report); + assertThat(lb.path("type").asText()).isEqualTo("token-aware"); + JsonNode adaptiveOrdering = lb.path("adaptive-ordering"); + // The group has no "enabled" flag; its presence is what says candidates get reordered, and its + // signals cannot be empty. + assertThat(fieldNames(adaptiveOrdering)).containsOnly("signals"); + assertThat(adaptiveOrdering.path("signals").size()).isEqualTo(1); + assertThat(adaptiveOrdering.path("signals").get(0).asText()).isEqualTo("latency"); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_report_a_built_in_policy_that_is_not_token_aware_as_custom() throws Exception { + // "token-aware" is the only built-in shape the schema defines, so any other policy can only be + // reported by name — its datacenter still shows up in the node preference beside it. + JsonNode report = + report( + Cluster.builder() + .withLoadBalancingPolicy( + DCAwareRoundRobinPolicy.builder().withLocalDc("dc1").build())); + + JsonNode lb = lbPolicy(report); + assertThat(lb.path("type").asText()).isEqualTo("custom"); + assertThat(lb.path("name").asText()).isEqualTo("DCAwareRoundRobinPolicy"); + assertThat(nodePreference(report).path("local-dc").asText()).isEqualTo("dc1"); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_keep_reporting_token_aware_for_a_fully_representable_chain() throws Exception { + // The default chain, and every chain built only from policies the group can describe: token + // awareness is claimed, and no name appears -- the built-in shape has no room for one. + for (LoadBalancingPolicy policy : + new LoadBalancingPolicy[] { + new TokenAwarePolicy(DCAwareRoundRobinPolicy.builder().withLocalDc("dc1").build()), + new TokenAwarePolicy(new RoundRobinPolicy()), + LatencyAwarePolicy.builder( + new TokenAwarePolicy( + DCAwareRoundRobinPolicy.builder().withLocalDc("dc1").build())) + .build(), + // The internal wrapper Cluster.Manager adds describes nothing, so it does not cost the + // chain its built-in shape any more than it earns a mention in a name. + new PagingOptimizingLoadBalancingPolicy( + new TokenAwarePolicy(DCAwareRoundRobinPolicy.builder().withLocalDc("dc1").build())), + // A filter naming one datacenter is described by the node preference it produces. + HostFilterPolicy.fromDCWhiteList( + new TokenAwarePolicy(new RoundRobinPolicy()), Collections.singletonList("dc1")) + }) { + JsonNode report = report(Cluster.builder().withLoadBalancingPolicy(policy)); + + assertThat(lbPolicy(report).path("type").asText()).as("%s", policy).isEqualTo("token-aware"); + assertThat(lbPolicy(report).has("name")).as("%s", policy).isFalse(); + assertConformsToSchema(report); + } + } + + @Test(groups = "unit") + public void should_name_a_restricting_wrapper_over_a_token_aware_chain() throws Exception { + // The bug this closes: an outer restriction used to vanish behind an inner TokenAwarePolicy, so + // whether an operator could see that the client is pinned to a host list depended on an + // unrelated nesting choice -- WhiteListPolicy(RoundRobinPolicy) reported the wrapper by name + // while WhiteListPolicy(TokenAwarePolicy(...)) reported plain token-aware. Now the chain that + // cannot be described as a built-in says so, and names every policy in it. + JsonNode report = + report( + Cluster.builder() + .withLoadBalancingPolicy( + new WhiteListPolicy( + new TokenAwarePolicy( + DCAwareRoundRobinPolicy.builder().withLocalDc("dc1").build()), + Collections.singletonList(new InetSocketAddress("127.0.0.1", 9042))))); + + JsonNode lb = lbPolicy(report); + assertThat(lb.path("type").asText()).isEqualTo("custom"); + assertThat(lb.path("name").asText()) + .isEqualTo("WhiteListPolicy(TokenAwarePolicy(DCAwareRoundRobinPolicy))"); + // Naming the wrapper costs nothing that was reported before it: what the chain can still state + // about itself stays, as additional properties the custom branch admits. + assertThat(lb.path("load-distribution").asText()).isEqualTo("shuffle"); + assertThat(lb.path("fallback-to-non-preferred-nodes").asBoolean()).isFalse(); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_name_an_error_aware_wrapper_that_has_no_other_representation() + throws Exception { + // ErrorAwarePolicy excludes hosts over an error-rate threshold -- a restriction, not a + // reordering -- so adaptive-ordering would be the wrong home for it despite its inviting + // response-rate signal. The name is the only place it can appear, and now it does. + JsonNode report = + report( + Cluster.builder() + .withLoadBalancingPolicy( + ErrorAwarePolicy.builder( + new TokenAwarePolicy( + DCAwareRoundRobinPolicy.builder().withLocalDc("dc1").build())) + .build())); + + JsonNode lb = lbPolicy(report); + assertThat(lb.path("type").asText()).isEqualTo("custom"); + assertThat(lb.path("name").asText()) + .isEqualTo("ErrorAwarePolicy(TokenAwarePolicy(DCAwareRoundRobinPolicy))"); + assertThat(lb.has("adaptive-ordering")).isFalse(); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_report_an_anonymous_custom_policy_under_its_binary_name() throws Exception { + // An anonymous class has no simple name, and the schema requires a non-empty one. + JsonNode report = + report(Cluster.builder().withLoadBalancingPolicy(new CustomLoadBalancingPolicy() {})); + + assertThat(lbPolicy(report).path("name").asText()) + .startsWith(DefaultDriverConfigReporterTest.class.getName() + "$"); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_omit_every_field_a_disabled_read_timeout_feeds() throws Exception { + // A non-positive read timeout disables read timeouts. It feeds three places, all optional and + // positive-only, so all three are omitted rather than emitted as a value the schema rejects. + JsonNode report = + report(Cluster.builder().withSocketOptions(new SocketOptions().setReadTimeoutMillis(0))); + + assertThat(report.path("connection").has("read")).isFalse(); + // The enclosing "timeout" object here is required, so it stays behind, empty. + JsonNode systemTimeout = + report.path("control-plane").path("queries").path("system").path("timeout"); + assertThat(systemTimeout.isObject()).isTrue(); + assertThat(systemTimeout.has("client-side-ms")).isFalse(); + // "request" is optional, so the whole group goes rather than being reported empty. + assertThat(queryDefaults(report).has("request")).isFalse(); + + // Nothing beyond the gap every report carries: making the request timeout and its group + // optional + // removed the one violation this configuration used to add. + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_omit_a_disabled_connect_timeout() throws Exception { + // Non-positive means "no timeout". The timeout is optional now, so it is omitted rather than + // reported as a value the schema rejects; its enclosing group is required, so it stays (empty). + JsonNode connect = + report(Cluster.builder().withSocketOptions(new SocketOptions().setConnectTimeoutMillis(0))) + .path("connection") + .path("connect"); + + assertThat(connect.isObject()).isTrue(); + assertThat(connect.has("timeout-ms")).isFalse(); + } + + @Test(groups = "unit") + public void should_omit_socket_values_the_schema_cannot_express() throws Exception { + // A negative SO_LINGER disables lingering close and a non-positive buffer size leaves the + // JDK/OS default in place; all three groups are optional, so they are omitted. + JsonNode socket = + socket( + report( + Cluster.builder() + .withSocketOptions( + new SocketOptions() + .setSoLinger(-1) + .setReceiveBufferSize(0) + .setSendBufferSize(-1)))); + + assertThat(socket.has("linger")).isFalse(); + assertThat(socket.has("receive-buffer")).isFalse(); + assertThat(socket.has("send-buffer")).isFalse(); + } + + @Test(groups = "unit") + public void should_report_a_zero_linger_interval() throws Exception { + // 0 means "close immediately, discarding unsent data", which the schema does admit. + JsonNode report = + report(Cluster.builder().withSocketOptions(new SocketOptions().setSoLinger(0))); + + assertThat(socket(report).path("linger").path("interval-s").asInt()).isEqualTo(0); + assertConformsToSchema(report); + } + + // ==================== Schema conformance ==================== + // + // These build a config, serialize it via the reporter, and validate the produced JSON against the + // normative JSON Schema (the same document ScyllaDB uses to interpret DRIVER_CONFIG). They cover + // every discriminated-union branch and optional-group case the 3.x reporter can emit, turning the + // "no emitted document violates the schema beyond the documented gaps" invariant into an enforced + // test. + // + // There is one documented gap left, PERCENTILE_ZERO_GAP, which only a percentile of 0 provokes; + // see the reporter's class javadoc and should_report_a_zero_percentile_as_is, which pins it. + + @Test(groups = "unit") + public void should_conform_to_schema_for_default_report() throws Exception { + assertConformsToSchema(report(Cluster.builder())); + } + + @Test(groups = "unit") + public void should_conform_to_schema_for_constant_reconnection_policy() throws Exception { + assertConformsToSchema( + report(Cluster.builder().withReconnectionPolicy(new ConstantReconnectionPolicy(2500)))); + } + + @Test(groups = "unit") + public void should_conform_to_schema_for_zero_delay_constant_reconnection_policy() + throws Exception { + // A zero delay means "reconnect immediately", which the schema admits. + JsonNode report = + report(Cluster.builder().withReconnectionPolicy(new ConstantReconnectionPolicy(0))); + + assertThat( + report.path("connection").path("reconnection").path("policy").path("delay-ms").asInt()) + .isEqualTo(0); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_conform_to_schema_for_a_disabled_connect_timeout() throws Exception { + assertConformsToSchema( + report( + Cluster.builder().withSocketOptions(new SocketOptions().setConnectTimeoutMillis(0)))); + } + + @Test(groups = "unit") + public void should_conform_to_schema_for_custom_reconnection_policy() throws Exception { + assertConformsToSchema( + report(Cluster.builder().withReconnectionPolicy(new CustomReconnectionPolicy()))); + } + + @Test(groups = "unit") + public void should_conform_to_schema_for_custom_retry_policy() throws Exception { + assertConformsToSchema( + report( + Cluster.builder() + .withRetryPolicy(new LoggingRetryPolicy(FallthroughRetryPolicy.INSTANCE)))); + } + + @Test(groups = "unit") + public void should_conform_to_schema_for_downgrading_consistency_retry_policy() throws Exception { + assertConformsToSchema( + report(Cluster.builder().withRetryPolicy(DowngradingConsistencyRetryPolicy.INSTANCE))); + } + + @Test(groups = "unit") + public void should_conform_to_schema_for_fallthrough_retry_policy() throws Exception { + assertConformsToSchema( + report(Cluster.builder().withRetryPolicy(FallthroughRetryPolicy.INSTANCE))); + } + + @Test(groups = "unit") + public void should_conform_to_schema_for_a_zero_delay_constant_speculative_execution() + throws Exception { + // A zero delay means every extra execution is sent immediately, which the schema's + // nonNegativeInteger admits. + JsonNode report = + report( + Cluster.builder() + .withSpeculativeExecutionPolicy(new ConstantSpeculativeExecutionPolicy(0L, 1))); + + assertThat(speculativeExecution(report).path("delay-ms").asLong()).isEqualTo(0L); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_report_a_zero_percentile_as_is() throws Exception { + // PercentileSpeculativeExecutionPolicy accepts a percentile of 0, which the schema's + // exclusiveMinimum rejects, and the key is required by the percentile branch. Reported as-is; + // see the reporter's class javadoc. + JsonNode report = report(Cluster.builder().withSpeculativeExecutionPolicy(percentile(0.0))); + + assertThat(speculativeExecution(report).path("percentile").asDouble()).isEqualTo(0.0); + + Set violations = violations(report); + assertThat(violations).contains(PERCENTILE_ZERO_GAP); + // Failing the percentile branch drops the object out of the discriminated union, so the + // validator goes on to explain why it matches neither of the other two branches either. Those + // messages all follow from this one gap, so rather than pin the cascade, assert that it stays + // inside this one object — nothing else in the report is affected. + for (String violation : violations) { + assertThat(violation).startsWith(SPECULATIVE_EXECUTION_POLICY_PATH); + } + } + + @Test(groups = "unit") + public void should_conform_to_schema_for_explicit_datacenter_node_location() throws Exception { + assertConformsToSchema( + report( + Cluster.builder() + .withLoadBalancingPolicy( + new TokenAwarePolicy( + DCAwareRoundRobinPolicy.builder().withLocalDc("dc1").build())))); + } + + @Test(groups = "unit") + public void should_conform_to_schema_for_rack_node_location() throws Exception { + assertConformsToSchema( + report( + Cluster.builder() + .withLoadBalancingPolicy( + new RackAwareRoundRobinPolicy("dc1", "rack1", 0, false, false, false)))); + } + + @Test(groups = "unit") + public void should_conform_to_schema_for_inferred_rack_node_location() throws Exception { + // Neither DC nor rack configured: reported as rack-auto, with both names absent because the + // policy has not been initialized (and so has inferred nothing) at report time. + JsonNode report = + report( + Cluster.builder() + .withLoadBalancingPolicy( + new RackAwareRoundRobinPolicy(null, null, 0, false, true, true))); + + assertThat(nodePreference(report).path("type").asText()).isEqualTo("rack-auto"); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_conform_to_schema_for_configured_rack_and_inferred_datacenter() + throws Exception { + // The mirror of should_report_configured_and_inferred_rack_location_under_separate_keys: the + // configured part is the rack this time, which exercises the schema's "not local-dc together + // with local-rack" guard from the other side. + JsonNode report = + report( + Cluster.builder() + .withLoadBalancingPolicy( + new RackAwareRoundRobinPolicy(null, "rack1", 0, false, true, false))); + + JsonNode nodePreference = nodePreference(report); + assertThat(nodePreference.path("type").asText()).isEqualTo("rack-auto"); + assertThat(nodePreference.path("local-rack").asText()).isEqualTo("rack1"); + assertThat(nodePreference.has("local-dc")).isFalse(); + // The connection slot degrades to dc-auto with no name: the configured rack says nothing about + // which datacenter is pooled, and the datacenter itself is still to be inferred. + JsonNode connectionPreference = connectionNodePreference(report); + assertThat(connectionPreference.path("type").asText()).isEqualTo("dc-auto"); + assertThat(connectionPreference.has("local-dc")).isFalse(); + assertThat(connectionPreference.has("local-rack")).isFalse(); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_conform_to_schema_for_round_robin_load_balancing_policy() throws Exception { + JsonNode report = report(Cluster.builder().withLoadBalancingPolicy(new RoundRobinPolicy())); + + assertThat(lbPolicy(report).path("name").asText()).isEqualTo("RoundRobinPolicy"); + // No DC/rack notion at all, so neither of the schema's two node preference keys is reported. + assertThat(loadBalancing(report).has("node-preference")).isFalse(); + assertThat(report.path("connection").has("node-preference")).isFalse(); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_conform_to_schema_for_white_list_load_balancing_policy() throws Exception { + JsonNode report = + report( + Cluster.builder() + .withLoadBalancingPolicy( + new WhiteListPolicy( + new RoundRobinPolicy(), + Collections.singletonList(new InetSocketAddress("127.0.0.1", 9042))))); + + assertThat(lbPolicy(report).path("name").asText()) + .isEqualTo("WhiteListPolicy(RoundRobinPolicy)"); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_report_the_preferred_datacenter_under_a_filtering_wrapper() throws Exception { + // A filtering wrapper narrows pooling below the datacenter reported beside it: WhiteListPolicy + // extends HostFilterPolicy, whose distance() returns IGNORED for any host failing the + // predicate, including one inside dc1. The configured datacenter is reported anyway -- hiding a + // setting the operator really did make is the worse failure mode -- so this pins the + // approximation the class javadoc documents rather than leaving the shape to change silently. + JsonNode report = + report( + Cluster.builder() + .withLoadBalancingPolicy( + new WhiteListPolicy( + DCAwareRoundRobinPolicy.builder().withLocalDc("dc1").build(), + Collections.singletonList(new InetSocketAddress("127.0.0.1", 9042))))); + + // The whitelist has no home in the schema's built-in shape, so the chain cannot claim it; the + // name says the whole of what the client runs rather than only its outermost policy. + assertThat(lbPolicy(report).path("type").asText()).isEqualTo("custom"); + assertThat(lbPolicy(report).path("name").asText()) + .isEqualTo("WhiteListPolicy(DCAwareRoundRobinPolicy)"); + // Both preference slots still carry the datacenter of the DC-aware child inside the chain. + assertThat(nodePreference(report).path("type").asText()).isEqualTo("dc"); + assertThat(nodePreference(report).path("local-dc").asText()).isEqualTo("dc1"); + assertThat(connectionNodePreference(report).path("type").asText()).isEqualTo("dc"); + assertThat(connectionNodePreference(report).path("local-dc").asText()).isEqualTo("dc1"); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_conform_to_schema_for_host_filter_load_balancing_policy() throws Exception { + JsonNode report = + report( + Cluster.builder() + .withLoadBalancingPolicy( + HostFilterPolicy.fromDCWhiteList( + new RoundRobinPolicy(), Collections.singletonList("dc1")))); + + assertThat(lbPolicy(report).path("name").asText()) + .isEqualTo("HostFilterPolicy(RoundRobinPolicy)"); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_report_the_datacenter_a_filter_restricts_the_session_to() throws Exception { + // fromDCWhiteList over a RoundRobinPolicy is the one restriction the driver ships that names a + // datacenter without any policy in the chain preferring one. Its distance() returns IGNORED for + // every host outside dc1, so the restriction really is what decides which hosts are pooled and + // which appear in a query plan -- both preference slots, in schema terms. + JsonNode report = + report( + Cluster.builder() + .withLoadBalancingPolicy( + HostFilterPolicy.fromDCWhiteList( + new RoundRobinPolicy(), Collections.singletonList("dc1")))); + + assertThat(nodePreference(report).path("type").asText()).isEqualTo("dc"); + assertThat(nodePreference(report).path("local-dc").asText()).isEqualTo("dc1"); + assertThat(connectionNodePreference(report).path("type").asText()).isEqualTo("dc"); + assertThat(connectionNodePreference(report).path("local-dc").asText()).isEqualTo("dc1"); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_prefer_a_location_aware_policy_over_the_filter_above_it() throws Exception { + // The filter and the policy disagree here, which is a misconfiguration -- but the policy is + // what builds the query plan, so its datacenter is the one reported. The filter is only ever + // consulted when nothing in the chain prefers a datacenter of its own. + JsonNode report = + report( + Cluster.builder() + .withLoadBalancingPolicy( + HostFilterPolicy.fromDCWhiteList( + DCAwareRoundRobinPolicy.builder().withLocalDc("dc1").build(), + Collections.singletonList("dc2")))); + + assertThat(nodePreference(report).path("local-dc").asText()).isEqualTo("dc1"); + assertThat(connectionNodePreference(report).path("local-dc").asText()).isEqualTo("dc1"); + // The filter names a datacenter, but not the one reported -- so it restricts the session + // without being stated anywhere, and the chain cannot claim to be fully described. Pinned + // beside the token-aware case below, which is the same chain with one more wrapper. + assertThat(lbPolicy(report).path("type").asText()).isEqualTo("custom"); + assertThat(lbPolicy(report).path("name").asText()) + .isEqualTo("HostFilterPolicy(DCAwareRoundRobinPolicy)"); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_name_a_datacenter_filter_that_disagrees_with_the_policy_below_it() + throws Exception { + // The chain of should_prefer_a_location_aware_policy_over_the_filter_above_it plus a + // token-aware wrapper, which must not make the filter disappear. A filter is only described by + // the node preference when that preference is *its* restriction; here the DC-aware policy owns + // it, so the whitelist narrows the session on top of what is reported -- to a datacenter + // disjoint from it, in fact, leaving nothing reachable at all. Claiming the built-in + // token-aware shape would say none of that, and would say it only because of the wrapper: the + // same nesting-dependent blind spot should_name_a_restricting_wrapper_over_a_token_aware_chain + // closes for WhiteListPolicy. + JsonNode report = + report( + Cluster.builder() + .withLoadBalancingPolicy( + new TokenAwarePolicy( + HostFilterPolicy.fromDCWhiteList( + DCAwareRoundRobinPolicy.builder().withLocalDc("dc1").build(), + Collections.singletonList("dc2"))))); + + JsonNode lb = lbPolicy(report); + assertThat(lb.path("type").asText()).isEqualTo("custom"); + assertThat(lb.path("name").asText()) + .isEqualTo("TokenAwarePolicy(HostFilterPolicy(DCAwareRoundRobinPolicy))"); + // What the chain can still state about itself stays, as additional properties. + assertThat(lb.path("load-distribution").asText()).isEqualTo("shuffle"); + assertThat(lb.path("fallback-to-non-preferred-nodes").asBoolean()).isFalse(); + // The preference is still the policy's, which is what builds the query plan. + assertThat(nodePreference(report).path("local-dc").asText()).isEqualTo("dc1"); + assertThat(connectionNodePreference(report).path("local-dc").asText()).isEqualTo("dc1"); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_omit_the_node_preference_for_restrictions_that_name_no_single_datacenter() + throws Exception { + // Everything a filter can express other than "exactly these hosts, in exactly this one + // datacenter". A blacklist denies a datacenter without naming a preferred one; two allowed + // datacenters name no single one; a WhiteListPolicy filters on addresses, and a caller-supplied + // predicate on whatever it likes. None of those has a schema-valid form, so the group goes. + // Nor does a blank name: nothing validates the strings fromDCWhiteList is handed, and local-dc + // is a non-empty string the "dc" type requires -- so the whole group goes rather than one key, + // the same way the DC- and rack-aware policies treat a blank name of their own. + LoadBalancingPolicy[] policies = { + HostFilterPolicy.fromDCWhiteList(new RoundRobinPolicy(), Arrays.asList("dc1", "dc2")), + HostFilterPolicy.fromDCBlackList(new RoundRobinPolicy(), Collections.singletonList("dc2")), + HostFilterPolicy.fromDCWhiteList(new RoundRobinPolicy(), Collections.singletonList("")), + new HostFilterPolicy( + new RoundRobinPolicy(), + new Predicate() { + @Override + public boolean apply(Host host) { + return true; + } + }), + new WhiteListPolicy( + new RoundRobinPolicy(), + Collections.singletonList(new InetSocketAddress("127.0.0.1", 9042))) + }; + + for (LoadBalancingPolicy policy : policies) { + JsonNode report = report(Cluster.builder().withLoadBalancingPolicy(policy)); + + assertThat(loadBalancing(report).has("node-preference")).as("%s", policy).isFalse(); + assertThat(report.path("connection").has("node-preference")).as("%s", policy).isFalse(); + assertConformsToSchema(report); + } + } + + @Test(groups = "unit") + public void should_report_adaptive_ordering_without_token_awareness() throws Exception { + // Adaptive ordering is a property of the chain, not of token awareness: LatencyAwarePolicy + // reorders the candidates its child produces whatever that child is, so it is reported over a + // bare RoundRobinPolicy exactly as it is over a token-aware policy. The custom branch admits + // additional properties, which is what lets the capability be stated next to the name. + JsonNode report = + report( + Cluster.builder() + .withLoadBalancingPolicy( + LatencyAwarePolicy.builder(new RoundRobinPolicy()).build())); + + JsonNode lb = lbPolicy(report); + assertThat(lb.path("type").asText()).isEqualTo("custom"); + assertThat(lb.path("name").asText()).isEqualTo("LatencyAwarePolicy(RoundRobinPolicy)"); + assertThat(lb.path("adaptive-ordering").path("signals").get(0).asText()).isEqualTo("latency"); + // No token-aware policy in the chain, so neither key that describes one is claimed. + assertThat(lb.has("load-distribution")).isFalse(); + assertThat(lb.has("fallback-to-non-preferred-nodes")).isFalse(); + assertConformsToSchema(report); + } + + @Test(groups = "unit") + public void should_conform_to_schema_for_unwrapped_paging_optimizing_load_balancing_policy() + throws Exception { + assertConformsToSchema( + report( + Cluster.builder() + .withLoadBalancingPolicy( + new PagingOptimizingLoadBalancingPolicy( + new TokenAwarePolicy( + DCAwareRoundRobinPolicy.builder().withLocalDc("dc1").build()))))); + } + + @Test(groups = "unit") + public void should_conform_to_schema_for_tls_enabled() throws Exception { + assertConformsToSchema(report(Cluster.builder().withSSL())); + } + + @Test(groups = "unit") + public void should_conform_to_schema_for_socket_overrides() throws Exception { + SocketOptions socketOptions = + new SocketOptions() + .setKeepAlive(true) + .setReuseAddress(true) + .setSoLinger(15) + .setReceiveBufferSize(4096) + .setSendBufferSize(8192); + assertConformsToSchema(report(Cluster.builder().withSocketOptions(socketOptions))); + } + + @Test(groups = "unit") + 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=false. + ObjectNode report = (ObjectNode) report(Cluster.builder()); + report.put("bogus-unknown-key", "x"); + assertThat(violations(report)) + .as("unknown top-level key must be rejected") + .contains( + "$: property 'bogus-unknown-key' is not defined in the schema and the schema does not " + + "allow additional properties"); + } + + // ---- Helpers -------------------------------------------------------------------------------- + + /** + * Drives {@code policy} through the initialization {@code Cluster.Manager.init()} performs after + * the first control connection's handshake, with a single node in the given datacenter and rack, + * so that the policy infers what it would infer against a live cluster. Stubbing the getters + * would pin the reporter against a fiction; this exercises the policy's own inference. + */ + private static void initWithNode(LoadBalancingPolicy policy, String datacenter, String rack) { + Cluster cluster = mock(Cluster.class); + when(cluster.getConfiguration()).thenReturn(Cluster.builder().getConfiguration()); + Host host = mock(Host.class); + when(host.getDatacenter()).thenReturn(datacenter); + when(host.getRack()).thenReturn(rack); + policy.init(cluster, Collections.singletonList(host)); + } + + /** Builds the full report from a cluster builder and parses it. */ + private static JsonNode report(Cluster.Builder builder) throws IOException { + String json = new DefaultDriverConfigReporter(builder.getConfiguration()).buildJson(); + return MAPPER.readTree(json); + } + + // Accessors for the groups the schema nests, so that each test reads as what it asserts rather + // than as a path walk. The full paths are pinned once, explicitly, in + // should_report_default_configuration_shape. + + /** The {@code query.load-balancing} group. */ + private static JsonNode loadBalancing(JsonNode report) { + return report.path("query").path("load-balancing"); + } + + /** The reported load balancing policy. */ + private static JsonNode lbPolicy(JsonNode report) { + return loadBalancing(report).path("policy"); + } + + /** The reported node preference, which the schema nests next to the policy it derives from. */ + private static JsonNode nodePreference(JsonNode report) { + return loadBalancing(report).path("node-preference"); + } + + /** + * The schema's second node preference slot, under {@code connection}: the part of the same + * preference that decides which hosts are pooled, i.e. the datacenter alone. + */ + private static JsonNode connectionNodePreference(JsonNode report) { + return report.path("connection").path("node-preference"); + } + + /** The {@code query.defaults} group. */ + private static JsonNode queryDefaults(JsonNode report) { + return report.path("query").path("defaults"); + } + + /** The reported retry policy. */ + private static JsonNode retryPolicy(JsonNode report) { + return report.path("query").path("retry").path("policy"); + } + + /** The reported speculative execution policy. */ + private static JsonNode speculativeExecution(JsonNode report) { + return report.path("query").path("speculative-execution").path("policy"); + } + + /** A percentile speculative execution policy triggering at {@code percentile}. */ + private static PercentileSpeculativeExecutionPolicy percentile(double percentile) { + return new PercentileSpeculativeExecutionPolicy( + PerHostPercentileTracker.builder(1000).build(), percentile, 3); + } + + /** The discriminator of the retry policy {@code builder} is configured with. */ + private static String retryPolicyType(Cluster.Builder builder) throws IOException { + return retryPolicy(report(builder)).path("type").asText(); + } + + /** The {@code connection.socket} group. */ + private static JsonNode socket(JsonNode report) { + return report.path("connection").path("socket"); + } + + private static Set fieldNames(JsonNode node) { + Set names = new HashSet(); + Iterator iterator = node.fieldNames(); + while (iterator.hasNext()) { + names.add(iterator.next()); + } + return names; + } + + /** + * Asserts that {@code report} validates against the schema, save for any {@code extraGaps} the + * caller's configuration is expected to add. Passing none therefore also pins that no + * configuration silently grows a violation. + */ + private static void assertConformsToSchema(JsonNode report, String... extraGaps) { + Set expected = new HashSet(Arrays.asList(extraGaps)); + assertThat(violations(report)).as("schema violations in %s", report).isEqualTo(expected); + } + + /** + * The {@code load-distribution} reported for a token-aware policy ordering its replicas with + * {@code replicaOrdering}. + */ + private static String loadDistribution(TokenAwarePolicy.ReplicaOrdering replicaOrdering) + throws IOException { + JsonNode report = + report( + Cluster.builder() + .withLoadBalancingPolicy( + new TokenAwarePolicy( + DCAwareRoundRobinPolicy.builder().withLocalDc("dc1").build(), + replicaOrdering))); + assertConformsToSchema(report); + return lbPolicy(report).path("load-distribution").asText(); + } + + private static Set violations(JsonNode report) { + Set messages = new HashSet(); + for (ValidationMessage message : SCHEMA.validate(report)) { + messages.add(message.getMessage()); + } + return messages; + } + + /** A reporter that reports {@code json} verbatim, bypassing the configuration read. */ + private static DefaultDriverConfigReporter reporting(final String json) { + return new DefaultDriverConfigReporter(config()) { + @Override + protected String buildJson() { + return json; + } + }; + } + + /** + * 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; + } + + /** + * A user policy that matches none of the built-in types and is not chainable, so it is reported + * as {@code custom}. Only its class name is ever read by the reporter. + */ + private static class CustomLoadBalancingPolicy implements LoadBalancingPolicy { + @Override + public void init(Cluster cluster, Collection hosts) {} + + @Override + public HostDistance distance(Host host) { + return HostDistance.LOCAL; + } + + @Override + public Iterator newQueryPlan(String loggedKeyspace, Statement statement) { + return Collections.emptyList().iterator(); + } + + @Override + public void onAdd(Host host) {} + + @Override + public void onUp(Host host) {} + + @Override + public void onDown(Host host) {} + + @Override + public void onRemove(Host host) {} + + @Override + public void close() {} + } + + /** A speculative execution policy that is none of the built-ins, so reported by name only. */ + private static class CustomSpeculativeExecution implements SpeculativeExecutionPolicy { + @Override + public SpeculativeExecutionPlan newPlan(String loggedKeyspace, Statement statement) { + throw new UnsupportedOperationException("never planned in this test"); + } + + @Override + public void init(Cluster cluster) {} + + @Override + public void close() {} + } + + /** + * A user timestamp generator that is neither of the driver's own, so whether it assigns a + * timestamp client-side cannot be told from its class. + */ + private static class CustomTimestamps implements TimestampGenerator { + @Override + public long next() { + throw new UnsupportedOperationException("never called in this test"); + } + } + + /** + * A user reconnection policy that is neither of the built-ins, so it is reported as {@code + * custom}. Only its class name is ever read by the reporter. + */ + private static class CustomReconnectionPolicy implements ReconnectionPolicy { + @Override + public ReconnectionSchedule newSchedule() { + throw new UnsupportedOperationException("never scheduled in this test"); + } + + @Override + public void init(Cluster cluster) {} + + @Override + public void close() {} + } + + /** A chainable policy whose child is itself, i.e. a chain the reporter must not walk forever. */ + private static class CyclicLoadBalancingPolicy extends DelegatingLoadBalancingPolicy { + CyclicLoadBalancingPolicy() { + super(new RoundRobinPolicy()); + } + + @Override + public LoadBalancingPolicy getChildPolicy() { + return this; + } + } } diff --git a/driver-core/src/test/java/com/datastax/driver/core/DriverConfigReportingCcmTest.java b/driver-core/src/test/java/com/datastax/driver/core/DriverConfigReportingCcmTest.java index a51ed8568d3..a24a84c0ede 100644 --- a/driver-core/src/test/java/com/datastax/driver/core/DriverConfigReportingCcmTest.java +++ b/driver-core/src/test/java/com/datastax/driver/core/DriverConfigReportingCcmTest.java @@ -18,6 +18,9 @@ import static org.assertj.core.api.Assertions.assertThat; import com.datastax.driver.core.utils.ScyllaVersion; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; @@ -35,7 +38,10 @@ * shared value; *

  • {@code SESSION_ID} is shared across every {@code Session} obtained from the same {@code * Cluster} (it is Cluster-scoped, not Session-scoped — see {@link Connection.Factory}); - *
  • {@code DRIVER_CONFIG} is stored for exactly one connection (the control connection); + *
  • {@code DRIVER_CONFIG} is stored for exactly one connection (the control connection), as the + * full versioned JSON report; + *
  • a later control connection reports the datacenter the load balancing policy has inferred in + * the meantime, which the first one could not yet know; *
  • with reporting disabled, {@code SESSION_ID} is still stored but {@code DRIVER_CONFIG} is * not. * @@ -43,9 +49,8 @@ *

    The cluster under test uses the default configuration — no {@code withDriverConfigReporting} * call — so these also assert that reporting is enabled by default. * - *

    Stage 1 emits only the schema version, so {@code DRIVER_CONFIG} is asserted to be {@code - * {"version":1}}. ScyllaDB-only: {@code system.clients.client_options} is a Scylla feature (added - * in ScyllaDB 2026.1). + *

    ScyllaDB-only: {@code system.clients.client_options} is a Scylla feature (added in ScyllaDB + * 2026.1). */ @ScyllaVersion( minOSS = "2026.1", @@ -69,6 +74,90 @@ public void should_store_session_id_on_all_connections_and_driver_config_on_cont .as("connections carrying this cluster's SESSION_ID") .isGreaterThanOrEqualTo(2); + // DRIVER_CONFIG is stored for exactly one connection (the control connection, which + // soleDriverConfig asserts), and what arrived is the versioned report shape once parsed rather + // than merely a plausible string. The byte-for-byte round trip lives in the reconnect test + // below, where a rebuild is comparable with what was sent; there is nothing to compare this + // first report against, since the driver keeps no copy and rebuilding now would produce a + // different, later report. + // + // Asserted by JSON type and not by value alone: asInt() coerces a string "1", and has() is + // satisfied by an explicit null, so either would pass on a report of the wrong shape. + JsonNode report = parse(soleDriverConfig(rows)); + JsonNode version = report.path("version"); + assertThat(version.isIntegralNumber()).as("version is a JSON integer").isTrue(); + assertThat(version.intValue()).isEqualTo(1); + assertThat( + report.path("connection").path("pool").path("shard-aware").path("enabled").isBoolean()) + .as("connection.pool.shard-aware.enabled is a JSON boolean") + .isTrue(); + } + + @Test(groups = "short") + public void should_report_the_inferred_datacenter_after_a_control_connection_reconnect() { + // Its own Cluster rather than the class-level one, because this test is the only one here that + // mutates the cluster it observes: forcing a reconnect leaves the superseded control connection + // in system.clients until the server reaps its row, so two rows carry a DRIVER_CONFIG for a + // while. should_store_session_id_on_all_connections_and_driver_config_on_control asserts there + // is exactly one, and TestNG orders methods within a class alphabetically, which runs it after + // this one -- against the very cluster this would have perturbed. + try (Cluster cluster = register(createClusterBuilder().build())) { + try (Session ignored = cluster.connect()) { + String sessionId = sessionId(cluster); + List rows = awaitClusterConnections(sessionId, 2); + assertThat(rows).as("connections carrying this cluster's SESSION_ID").isNotEmpty(); + + // The default load balancing policy is TokenAwarePolicy(DCAwareRoundRobinPolicy) with no + // configured datacenter, so it infers one -- but only in Cluster.Manager.init(), which runs + // after the first control connection's STARTUP. That first report can therefore say no more + // than "a datacenter will be inferred". + JsonNode first = parse(soleDriverConfig(rows)); + JsonNode firstPreference = first.path("connection").path("node-preference"); + assertThat(firstPreference.path("type").asText()).isEqualTo("dc-auto"); + assertThat(firstPreference.has("local-dc")).isFalse(); + + // Force a new control connection. Every one of them builds its own report, so this is where + // the datacenter the policy has since inferred reaches the server. + Set before = connectionKeys(rows); + cluster.manager.controlConnection.triggerReconnect(); + + String reconnected = awaitNewDriverConfig(sessionId, before); + assertThat(reconnected) + .as("DRIVER_CONFIG on the reconnected control connection") + .isNotNull(); + + String localDc = cluster.manager.controlConnection.connectedHost().getDatacenter(); + assertThat(localDc).as("datacenter of the node the control connection is on").isNotEmpty(); + JsonNode second = parse(reconnected); + // dc-auto keeps an inferred datacenter under the plain local-dc key; only rack-auto + // prefixes. + assertThat(second.path("connection").path("node-preference").path("local-dc").asText()) + .isEqualTo(localDc); + assertThat( + second + .path("query") + .path("load-balancing") + .path("node-preference") + .path("local-dc") + .asText()) + .isEqualTo(localDc); + + // Byte-for-byte against a report built right now, rather than a spot-check of a few keys: + // an oversized STARTUP value is silently truncated by an unchecked 16-bit length prefix + // instead of being rejected (which is why MAX_DRIVER_CONFIG_LENGTH exists), and truncation, + // reordering or encoding damage is exactly what a key-by-key check would miss. A rebuild is + // a fair comparison only here: by now the protocol version is negotiated and PoolingOptions + // settled, and the policy has inferred everything it is going to. + assertThat(reconnected) + .isEqualTo(cluster.manager.connectionFactory.buildDriverConfigReport()); + } + } + } + + /** + * The single {@code DRIVER_CONFIG} among the given connections, which the control one carries. + */ + private String soleDriverConfig(List rows) { List driverConfigs = new ArrayList(); for (Row row : rows) { String driverConfig = clientOptions(row).get("DRIVER_CONFIG"); @@ -76,11 +165,46 @@ public void should_store_session_id_on_all_connections_and_driver_config_on_cont driverConfigs.add(driverConfig); } } - - // DRIVER_CONFIG is stored for exactly one connection (the control connection). Stage 1 reports - // only the schema version. assertThat(driverConfigs).hasSize(1); - assertThat(driverConfigs.get(0)).isEqualTo("{\"version\":1}"); + return driverConfigs.get(0); + } + + /** + * Polls {@code system.clients} until a connection of the given {@code SESSION_ID}'s cluster, and + * outside {@code excludeKeys}, carries a {@code DRIVER_CONFIG}; returns it, or {@code null} if + * none appears in time. Scoped to one cluster because every other driver connection to this CCM + * node is equally "new" to a key snapshot — including the class-level session's control + * connection, which carries a {@code DRIVER_CONFIG} of its own. + */ + private String awaitNewDriverConfig(String sessionId, Set excludeKeys) { + long deadline = System.currentTimeMillis() + 60_000L; + while (System.currentTimeMillis() < deadline) { + for (Row row : newDriverConnections(excludeKeys)) { + Map options = clientOptions(row); + if (!sessionId.equals(options.get("SESSION_ID"))) { + continue; + } + String driverConfig = options.get("DRIVER_CONFIG"); + if (driverConfig != null) { + return driverConfig; + } + } + try { + Thread.sleep(500L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + return null; + } + + private JsonNode parse(String json) { + try { + return new ObjectMapper().readTree(json); + } catch (IOException e) { + throw new AssertionError("DRIVER_CONFIG is not valid JSON: " + json, e); + } } @Test(groups = "short") diff --git a/driver-core/src/test/resources/config/driver-config-report-v1.schema.json b/driver-core/src/test/resources/config/driver-config-report-v1.schema.json new file mode 100644 index 00000000000..64f8ad6ce8a --- /dev/null +++ b/driver-core/src/test/resources/config/driver-config-report-v1.schema.json @@ -0,0 +1,1026 @@ +{ + "$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", + "control-plane", + "query" + ], + "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" + }, + "control-plane": { + "$ref": "#/$defs/control-plane" + }, + "query": { + "$ref": "#/$defs/query" + } + }, + "$defs": { + "positiveInteger": { + "type": "integer", + "minimum": 1 + }, + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "nonEmptyString": { + "type": "string", + "minLength": 1 + }, + "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. When max-ms is present, it MUST be greater than or equal to base-ms; this cross-property invariant must be checked by the producer or consumer because JSON Schema Draft 2020-12 cannot compare sibling numeric values.", + "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. MUST be greater than or equal to base-ms. Absent when no maximum delay is configured." + } + } + }, + { + "type": "object", + "description": "Constant backoff: wait a fixed, strictly positive delay 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. Must be greater than 0; omit backoff when no delay is configured." + } + } + } + ] + }, + "requests": { + "type": "object", + "description": "Per-connection CQL request and protocol stream capacity. `orphaned.max` is expected to be lower than `in-flight.max`.", + "additionalProperties": false, + "required": [ + "in-flight" + ], + "properties": { + "in-flight": { + "type": "object", + "description": "Requests currently awaiting a response on the connection.", + "additionalProperties": false, + "required": [ + "max" + ], + "properties": { + "max": { + "$ref": "#/$defs/positiveInteger", + "description": "Maximum number of concurrent in-flight requests allowed on one connection." + } + } + }, + "orphaned": { + "type": "object", + "description": "Requests that the client stopped waiting for but whose stream identifiers cannot yet be safely reused.", + "additionalProperties": false, + "required": [ + "max" + ], + "properties": { + "max": { + "$ref": "#/$defs/nonNegativeInteger", + "description": "Maximum number of orphaned requests allowed on one connection before the driver closes and replaces it. Absent only when this bound is unknown, for example when the client never replaces a connection over accumulated orphans and so has no limit to report." + } + } + } + } + }, + "connection-pool": { + "description": "Connection pooling configuration.", + "type": "object", + "required": [ + "shard-aware" + ], + "additionalProperties": false, + "properties": { + "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." + } + } + } + } + }, + "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", + "requests", + "pool", + "socket", + "reconnection" + ], + "additionalProperties": false, + "properties": { + "requests": { + "$ref": "#/$defs/requests" + }, + "node-preference": { + "$ref": "#/$defs/node-location-preference", + "description": "Defines part of the cluster driver holds connections to." + }, + "connect": { + "type": "object", + "description": "Settings for establishing a TCP/CQL connection to a node.", + "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": {} + }, + "pool": { + "$ref": "#/$defs/connection-pool", + "description": "A connection pooling configuration." + }, + "socket": { + "$ref": "#/$defs/socket" + }, + "reconnection": { + "description": "Connection reconnection configuration.", + "type": "object", + "required": [ + "policy" + ], + "additionalProperties": false, + "properties": { + "policy": { + "$ref": "#/$defs/reconnection-policy" + } + } + }, + "tls": { + "$ref": "#/$defs/tls" + } + } + }, + "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": [ + "queries", + "schema" + ], + "additionalProperties": false, + "properties": { + "queries": { + "type": "object", + "description": "Control-plane query settings.", + "additionalProperties": false, + "required": [ + "system" + ], + "properties": { + "system": { + "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": { + "type": "object", + "description": "Control-plane schema settings.", + "additionalProperties": false, + "required": [ + "agreement" + ], + "properties": { + "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. max-ms MUST be greater than or equal to base-ms; this cross-property invariant must be checked by the producer or consumer because JSON Schema Draft 2020-12 cannot compare sibling numeric values.", + "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. MUST be greater than or equal to base-ms. 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. A delay of 0 means reconnect immediately.", + "additionalProperties": false, + "required": [ + "type", + "delay-ms" + ], + "properties": { + "type": { + "const": "constant", + "description": "Reconnection policy type." + }, + "delay-ms": { + "$ref": "#/$defs/nonNegativeInteger", + "description": "Fixed delay between reconnection attempts in milliseconds; 0 means reconnect immediately. 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": { + "$ref": "#/$defs/nonEmptyString", + "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": "Standard error-aware retry policy.", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "const": "standard-error-aware", + "description": "Retry policy type." + }, + "max-retries": { + "$ref": "#/$defs/nonNegativeInteger", + "description": "Maximum number of retries before giving up; 0 means no retries. Absent when no explicit retry limit is configured." + } + } + }, + { + "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." + }, + "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": "Never-retry policy: does not retry read timeouts, write timeouts, or unavailable errors, but may try the next host for connection and server errors.", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "const": "never", + "description": "Retry policy type." + }, + "max-retries": { + "$ref": "#/$defs/nonNegativeInteger", + "description": "Maximum number of retries before giving up; 0 means no retries. Absent when no explicit retry limit is configured." + } + } + }, + { + "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." + }, + "max-retries": { + "$ref": "#/$defs/nonNegativeInteger", + "description": "Maximum number of retries before giving up; 0 means no retries. Absent when no explicit retry limit is configured." + } + } + }, + { + "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": { + "$ref": "#/$defs/nonEmptyString", + "description": "Name of the custom policy (e.g. the public type name of the user-provided policy)." + }, + "description": { + "$ref": "#/$defs/nonEmptyString", + "description": "Textual description of what this policy does." + }, + "max-retries": { + "$ref": "#/$defs/nonNegativeInteger", + "description": "Maximum number of retries before giving up; 0 means no retries. Absent when no explicit retry limit is configured." + } + } + } + ] + }, + "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. A delay of 0 means launch them immediately.", + "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/nonNegativeInteger", + "description": "Delay before launching each additional execution in milliseconds; 0 means launch immediately." + } + } + }, + { + "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": { + "$ref": "#/$defs/nonEmptyString", + "description": "Name of the custom policy (e.g. the public type name of the user-provided policy)." + }, + "description": { + "$ref": "#/$defs/nonEmptyString", + "description": "Textual description of what this policy does." + } + } + } + ] + }, + "adaptive-ordering": { + "type": "object", + "description": "Dynamic reordering of otherwise eligible candidate nodes using runtime responsiveness, load, or health observations. Absent when adaptive ordering is disabled. This capability does not imply a particular algorithm.", + "additionalProperties": false, + "required": [ + "signals" + ], + "properties": { + "signals": { + "type": "array", + "description": "Runtime observations used to influence ordering.", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "latency", + "response-rate", + "in-flight-requests", + "recovery-state" + ] + } + } + } + }, + "load-balancing-policy": { + "description": "Load balancing / host selection policy, discriminated by `type`. A built-in token-aware policy is reported with `type` set to `token-aware` and the normalized capability flags below. A user-supplied policy is reported with `type` set to `custom`, 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", + "load-distribution", + "fallback-to-non-preferred-nodes" + ], + "properties": { + "type": { + "const": "token-aware", + "description": "Load balancing policy type: the built-in token-aware policy." + }, + "load-distribution": { + "type": "string", + "enum": [ + "shuffle", + "round-robin", + "replica-set" + ], + "description": "Strategy used to distribute requests across otherwise equally preferred nodes. `shuffle` randomizes node selection across query plans; `round-robin` rotates the first selected node across successive query plans; `replica-set` preserves the replica set's existing order without reordering it." + }, + "fallback-to-non-preferred-nodes": { + "type": "boolean", + "description": "Whether requests may fail over to nodes outside of the preference configured by `query.load-balancing.node-preference`." + }, + "adaptive-ordering": { + "$ref": "#/$defs/adaptive-ordering" + } + } + }, + { + "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": { + "$ref": "#/$defs/nonEmptyString", + "description": "Name of the custom policy (e.g. the public type name of the user-provided policy)." + }, + "description": { + "$ref": "#/$defs/nonEmptyString", + "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": { + "$ref": "#/$defs/nonEmptyString", + "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": { + "$ref": "#/$defs/nonEmptyString", + "description": "Explicitly configured preferred datacenter." + }, + "local-rack": { + "$ref": "#/$defs/nonEmptyString", + "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": { + "$ref": "#/$defs/nonEmptyString", + "description": "Inferred preferred datacenter. Absent when not yet known at report time." + } + } + }, + { + "type": "object", + "description": "Datacenter and/or rack preference inferred from the connected node. Configured and inferred values are reported separately.", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "const": "rack-auto", + "description": "At least one part of the location preference is inferred." + }, + "local-dc": { + "$ref": "#/$defs/nonEmptyString", + "description": "Explicitly configured preferred datacenter." + }, + "local-rack": { + "$ref": "#/$defs/nonEmptyString", + "description": "Explicitly configured preferred rack." + }, + "inferred-local-dc": { + "$ref": "#/$defs/nonEmptyString", + "description": "Inferred preferred datacenter. Absent when not yet known." + }, + "inferred-local-rack": { + "$ref": "#/$defs/nonEmptyString", + "description": "Inferred preferred rack. Absent when not yet known." + } + }, + "allOf": [ + { + "not": { + "required": [ + "local-dc", + "inferred-local-dc" + ] + } + }, + { + "not": { + "required": [ + "local-rack", + "inferred-local-rack" + ] + } + }, + { + "not": { + "required": [ + "local-dc", + "local-rack" + ] + } + } + ] + } + ] + }, + "query": { + "description": "Query execution configuration.", + "type": "object", + "required": [ + "defaults", + "retry", + "load-balancing" + ], + "additionalProperties": false, + "properties": { + "defaults": { + "$ref": "#/$defs/query-defaults" + }, + "retry": { + "description": "Query retry configuration. Backoff is optional and is omitted when no retry delay is configured.", + "type": "object", + "required": [ + "policy" + ], + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "policy": { + "properties": { + "type": { + "const": "fallthrough" + } + }, + "required": [ + "type" + ] + } + }, + "required": [ + "policy" + ] + }, + "then": { + "not": { + "required": [ + "backoff" + ] + } + } + } + ], + "properties": { + "policy": { + "$ref": "#/$defs/retry-policy" + }, + "backoff": { + "$ref": "#/$defs/retryPolicyBackoff", + "description": "Delay inserted between retries. Omitted when no retry backoff is configured. Every configured delay must be greater than 0." + } + } + }, + "load-balancing": { + "description": "Load-balancing configuration applied to queries.", + "type": "object", + "required": [ + "policy" + ], + "additionalProperties": false, + "properties": { + "policy": { + "$ref": "#/$defs/load-balancing-policy" + }, + "node-preference": { + "$ref": "#/$defs/node-location-preference", + "description": "Defines part of the cluster queries will be scheduled on" + } + } + }, + "speculative-execution": { + "description": "Speculative-execution configuration applied to queries. Absent when speculative execution is disabled.", + "type": "object", + "required": [ + "policy" + ], + "additionalProperties": false, + "properties": { + "policy": { + "$ref": "#/$defs/speculative-execution-policy" + } + } + } + } + }, + "query-defaults": { + "description": "Default per-request settings applied to statements that do not override them.", + "type": "object", + "required": [ + "consistency", + "idempotence" + ], + "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", + "LOCAL_SERIAL" + ] + }, + "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. Absent only when this behavior is unknown, for example when a custom timestamp generator may or may not enforce a timestamp.", + "type": "boolean" + }, + "request": { + "type": "object", + "description": "Default request-level settings.", + "additionalProperties": false, + "properties": { + "timeout-ms": { + "$ref": "#/$defs/positiveInteger", + "description": "Client-side timeout for a single request/query in milliseconds. Absent when the timeout is disabled or unset." + } + } + } + } + }, + "tls": { + "description": "TLS/SSL transport settings. Absent when TLS is disabled. Reports only booleans; never credentials, keys, or host lists.", + "type": "object", + "additionalProperties": false, + "properties": { + "hostname-verification": { + "type": "boolean", + "description": "Whether the server hostname is verified against its certificate. Absent only when this behavior is unknown, for example when a custom certificate validator may or may not enforce hostname verification." + } + } + } + } +} diff --git a/pom.xml b/pom.xml index 88a6c1a6e9f..253727c9962 100644 --- a/pom.xml +++ b/pom.xml @@ -88,6 +88,10 @@ 1.2.13 3.0.8 0.27.2 + + 1.5.9 3.5.4 127.0.1. @@ -402,6 +406,12 @@ 4.3.0 + + com.networknt + json-schema-validator + ${json-schema-validator.version} + +