From d381653b839c6cb12a79f1c0c347b38708f4b785 Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Tue, 11 Aug 2026 14:59:30 +0200 Subject: [PATCH 1/6] test: ship the normative v1 driver-config schema and its validator Motivation: The DRIVER_CONFIG report is consumed as a cross-driver contract, so it has to be checked against the normative schema rather than against this driver's own idea of the shape. Nothing in 3.x could do that. Modifications: Vendors the schema block verbatim from the design doc, revision v5 -- whose report version field is still 1 -- as a test resource. This is the same resource the 4.x sibling PR #968 ships, so the two drivers are held to one document. Validation runs on com.networknt:json-schema-validator, pinned to the 1.5.x line because it is the last minor line still targeting Java 8. The conformance tests assert on this validator's exact ValidationMessage wording, so a bump may need those strings updated; that is noted on the version property in the parent pom. Result: The resource and the dependency are in place. Nothing consumes them yet -- the reporter and the conformance tests that validate against them follow in later commits. Co-Authored-By: Claude Sonnet 5 Co-Authored-By: Claude Opus 5 (1M context) --- driver-core/pom.xml | 5 + .../driver-config-report-v1.schema.json | 1026 +++++++++++++++++ pom.xml | 10 + 3 files changed, 1041 insertions(+) create mode 100644 driver-core/src/test/resources/config/driver-config-report-v1.schema.json 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/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} + + From a2d0a7cd193e2e33d5c4d3b5492d7cf2ef796f36 Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Tue, 11 Aug 2026 14:59:30 +0200 Subject: [PATCH 2/6] feat(policies): expose the state the config report reads Motivation: The DRIVER_CONFIG report has to describe the policies a session is actually running with, but the built-in policies keep their configuration in private fields with no accessors. A preferred datacenter, a replica ordering or a speculative-execution budget could not be observed from outside the policy at all. Modifications: Adds public getters for exactly what the schema has a slot for: - DCAwareRoundRobinPolicy: the local datacenter, whether it was set explicitly, and used-hosts-per-remote-DC. - RackAwareRoundRobinPolicy: the local datacenter and rack, and whether each was set explicitly. - TokenAwarePolicy: the replica ordering. - ConstantSpeculativeExecutionPolicy: max executions and the delay. - PercentileSpeculativeExecutionPolicy: max executions and the percentile. The two speculative-execution policies hold these in immutable fields whose values land in the schema's ranges exactly, so the report can name them by their built-in type rather than falling back to "custom". Also makes PagingOptimizingLoadBalancingPolicy implement ChainableLoadBalancingPolicy. Cluster.Manager wraps every session's policy in that internal class, so without a getChildPolicy() a chain walk stops at the wrapper and never reaches the policy the user configured. Result: Pure widening -- no behavior changes. Every accessor returns what the policy was constructed with. Co-Authored-By: Claude Sonnet 5 Co-Authored-By: Claude Opus 5 (1M context) --- .../ConstantSpeculativeExecutionPolicy.java | 20 ++++++++ .../policies/DCAwareRoundRobinPolicy.java | 35 ++++++++++++++ .../core/policies/HostFilterPolicy.java | 30 +++++++++++- .../PagingOptimizingLoadBalancingPolicy.java | 7 ++- .../PercentileSpeculativeExecutionPolicy.java | 20 ++++++++ .../policies/RackAwareRoundRobinPolicy.java | 48 +++++++++++++++++++ .../core/policies/TokenAwarePolicy.java | 10 ++++ 7 files changed, 168 insertions(+), 2 deletions(-) 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..d0fcb877c83 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 @@ -52,6 +52,26 @@ 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. + */ + 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. + */ + 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..5882bf587e5 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 @@ -77,6 +77,7 @@ public static Builder builder() { private final int usedHostsPerRemoteDc; private final boolean dontHopForLocalCL; + private final boolean localDcExplicit; private volatile Configuration configuration; @@ -90,6 +91,40 @@ 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}. + */ + 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. + */ + 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. + */ + 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..0ab0f33c3e3 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 @@ -26,6 +26,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 +40,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 +51,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 +68,23 @@ 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. + */ + public Set getWhiteListedDatacenters() { + return whiteListedDcs; + } + /** * {@inheritDoc} * @@ -137,7 +164,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..2096e3ad914 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 @@ -12,7 +12,7 @@ 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 +20,11 @@ public PagingOptimizingLoadBalancingPolicy(LoadBalancingPolicy loadBalancingPoli wrapped = loadBalancingPolicy; } + @Override + 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..d1f67d09977 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 @@ -58,6 +58,26 @@ 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. + */ + 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). + */ + 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..0d1a22c2728 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 @@ -90,6 +90,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 +111,52 @@ 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}. + */ + 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}. + */ + 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. + */ + 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. + */ + 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..b3350ccaab9 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 @@ -392,6 +392,16 @@ 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. + */ + public ReplicaOrdering getReplicaOrdering() { + return replicaOrdering; + } + @Override public void init(Cluster cluster, Collection hosts) { clusterMetadata = cluster.getMetadata(); From 9fd364bde9558ba4db230daa16d9267d7bfafbea Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Tue, 11 Aug 2026 14:59:30 +0200 Subject: [PATCH 3/6] fix: reject a null default consistency level in QueryOptions Motivation: QueryOptions.setConsistencyLevel accepted null, and a null there could never work: SessionManager falls back to the default consistency level for every request that does not set one of its own, and CBUtil.writeConsistencyLevel then dereferences it to write the frame. Any statement without an explicit level already failed against such a configuration. Modifications: The setter now rejects null, like the other QueryOptions setters that back a value every query needs. setSerialConsistencyLevel is deliberately left as it is. Serial consistency is genuinely optional, so a null there is a configuration that works, and it is reported as an omitted key rather than as a missing required one. Result: A behavior change to a public setter, and the only one in this series. It narrows the accepted input to what the driver could actually use; the rejected configuration had no working behavior to preserve. Co-Authored-By: Claude Sonnet 5 Co-Authored-By: Claude Opus 5 (1M context) --- .../main/java/com/datastax/driver/core/QueryOptions.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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; From 3f35e68341f75e43cfdcb9e4531ece9cdf7fc41d Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Tue, 11 Aug 2026 15:01:19 +0200 Subject: [PATCH 4/6] feat: report the full driver configuration to the cluster at connection time Motivation: Stage 1 wired up DRIVER_CONFIG and sent a {"version":1} placeholder. This fills in the report itself, in the normative cross-driver schema shape, so an operator can see from the server what a client is actually configured with. Modifications: The report is built once per Cluster as it initializes, from Configuration and Policies, and hangs off three groups. connection carries the connect/read timeouts, the per-connection request capacity, the pool, the socket options, the reconnection policy and -- only when TLS is on -- tls. control-plane carries the system-query and schema-agreement timeouts. query carries the per-request defaults plus the three policies acting on a query: retry, load-balancing (with the node preference beside it) and, when configured, speculative-execution. The schema reports the node preference in two places and 3.x fills both from the same policy chain: query.load-balancing.node-preference for what a query is routed by, connection.node-preference for the part of the cluster the driver holds connections to. One LoadBalancingPolicy decides both, since distance(Host) governs whether a host is pooled at all, so the connection key carries the datacenter half alone. A rack-aware policy's distance() returns REMOTE, never IGNORED, for a local-datacenter host in another rack, so the rack scopes no pooling at all; the datacenter does, a host outside the preferred one being IGNORED unless the policy is configured to use hosts there, and an ignored host gets no pool. token-aware is the only built-in load balancing shape the schema defines, so every other built-in policy -- a bare DCAwareRoundRobinPolicy, RoundRobinPolicy, WhiteListPolicy -- is reported as custom with its class name, which identifies it but carries none of the normalized flags. A token-aware chain reports load-distribution from its replica ordering (RANDOM, the 3.x default, is "shuffle"; TOPOLOGICAL is "replica-set"; NEUTRAL keeps the child's plan order, so "round-robin"). A custom policy is named after the policy the user configured, not after PagingOptimizingLoadBalancingPolicy, which Cluster.Manager wraps every session's policy in and which is the outermost element of the chain; an anonymous class falls back to its binary name, having no simple name where the schema requires a non-empty one. fallback-to-non-preferred-nodes is true whenever the policy can reach a node outside the preference reported beside it. For DCAwareRoundRobin that means used-hosts-per-remote-DC, since the preference is the datacenter. RackAwareRoundRobin reports a rack, and the other racks of its local datacenter are outside that yet are the second tier of every query plan, so it is always true there, remote datacenter hosts or not. in-flight.max needs a fallback because PoolingOptions is still UNSET when the report is built: the protocol version is only negotiated once the control connection is up. The default row is resolved with the same walk PoolingOptions.setProtocolVersion applies -- the highest DEFAULTS key not above the version -- driven by the version the user pinned with withProtocolVersion when they pinned one, and by v3 otherwise, that being the lowest version ScyllaDB negotiates. DEFAULTS holds only v1 and v3 rows, so a cluster pinned to v2 is sized from v1's 128 rather than v3's 1024, and pinning is the one part of negotiation knowable at report time. Three ways reporting could break a connection rather than merely fail to report are closed off: - The report is capped at 32KiB of UTF-8 (MAX_DRIVER_CONFIG_LENGTH), matching the 4.x sibling PR #968, gocql #964 and csharp-driver #262. Beyond cross-driver parity this is a correctness fix: CBUtil.writeString writes each STARTUP value with a 16-bit length prefix and no bounds check, so a value over 65535 bytes truncates the prefix modulo 65536 while still appending the whole body -- a corrupt frame and a failed handshake, and not something the fail-safe try/catch can contain since nothing throws. Parts of the report are user-supplied and unbounded (DC/rack names, consistency levels, custom policy class names). Over the limit means WARN and no DRIVER_CONFIG. - The fail-safe catch also covers InternalError, since customPolicy() calls getClass().getSimpleName() on arbitrary user policy objects (a documented JDK edge case for certain synthetic classes). Not a bare Error, so OutOfMemoryError and StackOverflowError still surface. - The load balancing chain walk is bounded at 16 policies and shared by both callers. It follows getChildPolicy() on arbitrary user policies, so a cyclic chain used to spin forever on the Cluster initialization path -- the one failure mode a try/catch cannot contain, because it hangs rather than throws. Result: Every group the reporter can emit is validated against the normative schema shipped earlier in this series, covering each discriminated-union branch and optional group, with a negative test proving additionalProperties=false is enforced. Where a configured value falls outside what the schema can express, an optional key or group is omitted rather than emitted as a value the schema rejects; where a required key has no accurate value to carry, it is left in the one state that is accurate and the limitation is documented on the class. The assertion is therefore that a report violates the schema in exactly the documented ways and no other. The PR description catalogues each omission and the three schema gaps recorded for the cross-driver document. Both node preference slots are an approximation once a wrapper sits above the policy they were read from. HostFilterPolicy.distance() -- and so WhiteListPolicy's, which extends it -- returns IGNORED for any host failing its predicate, including one inside the reported datacenter, and a custom chainable policy computes distance() itself and need honor nothing below it. The configured datacenter is reported anyway, on the grounds that hiding one the operator really did set is worse. The asymmetry is deliberate: nothing is inferred on a third party's behalf, but what was configured is passed through. The restriction has nowhere to go, the built-in shape having no room for a wrapper and fromDCWhiteList collapsing its datacenters into an opaque Predicate. Co-Authored-By: Claude Sonnet 5 Co-Authored-By: Claude Opus 5 (1M context) --- .../core/DefaultDriverConfigReporter.java | 925 +++++++++- .../driver/core/DriverConfigReporter.java | 12 +- .../core/DefaultDriverConfigReporterTest.java | 1644 ++++++++++++++++- 3 files changed, 2559 insertions(+), 22 deletions(-) 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..e166d135b31 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,38 @@ */ 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.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,10 +55,108 @@ * {@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.in-flight.max} must be positive, while {@link + * PoolingOptions#setMaxRequestsPerConnection(HostDistance, int)} also accepts 0. + *
  • {@code query.speculative-execution.policy.percentile} is bounded to 0..100 exclusive by the + * schema, while {@link PercentileSpeculativeExecutionPolicy} accepts a percentile of 0. + *
+ * + *

{@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}. */ public class DefaultDriverConfigReporter implements DriverConfigReporter { @@ -44,6 +171,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 +216,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 +245,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 +260,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..cce8e02548c 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 @@ -39,7 +39,17 @@ public interface DriverConfigReporter { * *

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/test/java/com/datastax/driver/core/DefaultDriverConfigReporterTest.java b/driver-core/src/test/java/com/datastax/driver/core/DefaultDriverConfigReporterTest.java index cabc6a591a5..9b766815998 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,1565 @@ 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_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; + } + } } From 7de81f9132a3002e27d3673dbfad784eac222da4 Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Tue, 11 Aug 2026 15:01:41 +0200 Subject: [PATCH 5/6] fix: build the config report per control connection, and only when it can be Motivation: Two problems with where the report was built, both in Connection.Factory. Stage 1 made jackson-core and jackson-databind required compile-scope dependencies of driver-core, and DefaultDriverConfigReporter holds an ObjectMapper in a static field. Exclude jackson-databind and merely initializing that class raises NoClassDefFoundError. That is an Error, raised while initializing the class rather than thrown from any method it declares, so neither the reporter's own fail-safe nor its caller could contain it -- and it happens on the Cluster initialization path. A classpath that merely lacks an optional serializer therefore went from "the report is skipped" to "no connection can be established at all", the inverse of the invariant this class is written around. Separately, the report was built once as the Cluster initialized and the resulting string reused for every control connection that factory ever opened. But Cluster.Manager.init() builds the factory, and the first control connection sends its STARTUP, before it calls LoadBalancingPolicy#init -- so a datacenter or rack the policy infers from the node it reaches could never be reported at all. The schema has keys for exactly that (dc-auto.local-dc, rack-auto.inferred-local-dc, rack-auto.inferred-local-rack) and nothing could ever fill them. Modifications: Connection.Factory decides once, as the Cluster initializes, whether a report can be built at all: canBuildDriverConfigReport catches LinkageError and answers false. LinkageError rather than NoClassDefFoundError alone, so a partially present or version-mismatched Jackson -- which surfaces as ExceptionInInitializerError out of the static initializer -- is covered too; probing for one class name would pass and then still fail here. Choosing whether to touch the class at all is the only place the check can live. The report it builds is discarded; running it there is what loads Jackson on the initialization thread rather than leaving a Netty event loop to be first. Logged at WARN, and unconditionally: reporting ships enabled, so nobody opted into it and nobody would think to look for a message saying it is off. The report itself is then built inside each control connection's STARTUP frame assembly, and never cached. Connection carries a reportConfig flag instead of a blob, and asks the factory for a report when it needs one. This is the same fallback SnappyCompressor already applies for its own optional library. It does not contradict buildReport() deliberately not catching bare Error: that is about report building never masking a real JVM-level failure, while this is a call site tolerating a missing optional dependency. Result: Excluding Jackson costs the report and nothing else, and a report describes the objects in force at the handshake that sends it rather than the configuration the Cluster was constructed from. The first control connection still reports {"type":"dc-auto"} with no datacenter -- the policy genuinely has not inferred one yet -- and every reconnect carries the one it has since inferred. A CCM test forces a control-connection reconnect against a live cluster and asserts both halves, byte-for-byte against a rebuilt report. Co-Authored-By: Claude Sonnet 5 Co-Authored-By: Claude Opus 5 (1M context) --- .../com/datastax/driver/core/Connection.java | 108 +++++++++++--- .../driver/core/DriverConfigReporter.java | 6 +- .../core/DefaultDriverConfigReporterTest.java | 13 ++ .../core/DriverConfigReportingCcmTest.java | 140 +++++++++++++++++- 4 files changed, 233 insertions(+), 34 deletions(-) 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/DriverConfigReporter.java b/driver-core/src/main/java/com/datastax/driver/core/DriverConfigReporter.java index cce8e02548c..260aafc2cfc 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 @@ -34,8 +34,10 @@ 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 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 9b766815998..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 @@ -149,6 +149,19 @@ protected String buildJson() { 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 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") From 960ab14f1ca46ebb9aacb14b84333cea942cc97e Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Thu, 13 Aug 2026 14:07:29 +0200 Subject: [PATCH 6/6] docs: mark the driver configuration reporting API as @Beta Motivation: Every public member the configuration-reporting feature adds is new and none of it has shipped: stage 1 merged as 8ee83490ad, after the 3.11.5.17 release, so the whole surface is still unreleased on 3.11.5.18-SNAPSHOT. The convention this repo already applies to recent additions -- TabletMap, Metadata#getReplicasList, Metadata#getTabletMap, BatchStatement#getRoutingStatement -- is Guava's @Beta, declaring the shape provisional until it has proven out across releases. This feature was the one recent addition not marked. Modifications: Marks the API in the two granularities the tree already uses: a whole new type at type level, as TabletMap is, and a new method on a pre-existing type at method level, as Metadata and BatchStatement are. Type level, both new types: - DriverConfigReporter, which covers buildReport(). - DefaultDriverConfigReporter, which covers DRIVER_CONFIG_KEY, the constructor, buildReport(), and the protected buildJson(), populateConfig(ObjectNode) and configuration extension points -- the class is public and non-final, so those are part of the surface a subclass compiles against. Method level, on types that predate the feature: - Cluster.Builder#withDriverConfigReporting and its Configuration counterparts, isDriverConfigReportingEnabled() and Configuration.Builder#withDriverConfig- Reporting. - The thirteen policy accessors this stage added to feed the report: ConstantSpeculativeExecutionPolicy and PercentileSpeculativeExecutionPolicy (max executions, delay, percentile), DCAwareRoundRobinPolicy and RackAwareRoundRobinPolicy (local datacenter and rack, whether each is explicit, used hosts per remote DC), HostFilterPolicy#getWhiteListedDatacenters, TokenAwarePolicy#getReplicaOrdering. PagingOptimizingLoadBalancingPolicy is marked on getChildPolicy() rather than on the class: the class is public API from 2018 and is not provisional, while its implementing ChainableLoadBalancingPolicy at all is what this work introduced. Result: Behaviour is unchanged -- @Beta is CLASS-retention, so nothing observes it at runtime, and @Documented, so it renders in the javadoc a caller reads. Callers of the reporting API now see that its shape may change before it settles, and clirr is unaffected because no signature moved. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/main/java/com/datastax/driver/core/Cluster.java | 2 ++ .../main/java/com/datastax/driver/core/Configuration.java | 3 +++ .../datastax/driver/core/DefaultDriverConfigReporter.java | 2 ++ .../java/com/datastax/driver/core/DriverConfigReporter.java | 3 +++ .../core/policies/ConstantSpeculativeExecutionPolicy.java | 3 +++ .../driver/core/policies/DCAwareRoundRobinPolicy.java | 4 ++++ .../com/datastax/driver/core/policies/HostFilterPolicy.java | 2 ++ .../core/policies/PagingOptimizingLoadBalancingPolicy.java | 2 ++ .../core/policies/PercentileSpeculativeExecutionPolicy.java | 3 +++ .../driver/core/policies/RackAwareRoundRobinPolicy.java | 5 +++++ .../com/datastax/driver/core/policies/TokenAwarePolicy.java | 2 ++ 11 files changed, 31 insertions(+) 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/DefaultDriverConfigReporter.java b/driver-core/src/main/java/com/datastax/driver/core/DefaultDriverConfigReporter.java index e166d135b31..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 @@ -42,6 +42,7 @@ 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; @@ -158,6 +159,7 @@ * {@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); 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 260aafc2cfc..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,6 +30,7 @@ * 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 { /** 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 d0fcb877c83..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; @@ -58,6 +59,7 @@ public ConstantSpeculativeExecutionPolicy( * * @return the number of speculative executions, always strictly positive. */ + @Beta public int getMaxSpeculativeExecutions() { return maxSpeculativeExecutions; } @@ -68,6 +70,7 @@ public int getMaxSpeculativeExecutions() { * * @return the delay in milliseconds, never negative. */ + @Beta public long getConstantDelayMillis() { return constantDelayMillis; } 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 5882bf587e5..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; @@ -102,6 +103,7 @@ private DCAwareRoundRobinPolicy( * * @return the local datacenter name, or {@code null}. */ + @Beta public String getLocalDc() { String dc = localDc; return Strings.isNullOrEmpty(dc) ? null : dc; @@ -113,6 +115,7 @@ public String getLocalDc() { * * @return {@code true} if the local datacenter was set explicitly. */ + @Beta public boolean isLocalDcExplicit() { return localDcExplicit; } @@ -123,6 +126,7 @@ public boolean isLocalDcExplicit() { * * @return the number of used hosts per remote datacenter. */ + @Beta public int getUsedHostsPerRemoteDc() { return usedHostsPerRemoteDc; } 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 0ab0f33c3e3..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; @@ -81,6 +82,7 @@ public LoadBalancingPolicy getChildPolicy() { * * @return an immutable set of allowed datacenter names, empty when unknown. */ + @Beta public Set getWhiteListedDatacenters() { return 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 2096e3ad914..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,6 +8,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 java.util.Collection; import java.util.Iterator; import java.util.concurrent.CopyOnWriteArrayList; @@ -21,6 +22,7 @@ public PagingOptimizingLoadBalancingPolicy(LoadBalancingPolicy loadBalancingPoli } @Override + @Beta public LoadBalancingPolicy getChildPolicy() { return wrapped; } 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 d1f67d09977..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; /** @@ -64,6 +65,7 @@ public PercentileSpeculativeExecutionPolicy( * * @return the maximum number of speculative executions, always strictly positive. */ + @Beta public int getMaxSpeculativeExecutions() { return maxSpeculativeExecutions; } @@ -74,6 +76,7 @@ public int getMaxSpeculativeExecutions() { * * @return the percentile, in the range 0 (inclusive) to 100 (exclusive). */ + @Beta public double getPercentile() { return percentile; } 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 0d1a22c2728..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; @@ -123,6 +124,7 @@ public RackAwareRoundRobinPolicy( * * @return the local datacenter name, or {@code null}. */ + @Beta public String getLocalDc() { String dc = localDc; return Strings.isNullOrEmpty(dc) ? null : dc; @@ -134,6 +136,7 @@ public String getLocalDc() { * * @return the local rack name, or {@code null}. */ + @Beta public String getLocalRack() { String rack = localRack; return Strings.isNullOrEmpty(rack) ? null : rack; @@ -145,6 +148,7 @@ public String getLocalRack() { * * @return {@code true} if the local datacenter was set explicitly. */ + @Beta public boolean isLocalDcExplicit() { return localDcExplicit; } @@ -155,6 +159,7 @@ public boolean isLocalDcExplicit() { * * @return {@code true} if the local rack was set explicitly. */ + @Beta public boolean isLocalRackExplicit() { return localRackExplicit; } 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 b3350ccaab9..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; @@ -398,6 +399,7 @@ public LoadBalancingPolicy getChildPolicy() { * * @return the replica ordering strategy. */ + @Beta public ReplicaOrdering getReplicaOrdering() { return replicaOrdering; }