From 9de9af152bc038af1ab012c378ccb274967db81a Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Tue, 11 Aug 2026 15:15:43 +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. 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 3.x sibling PR #974 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) --- core/pom.xml | 5 + .../driver-config-report-v1.schema.json | 1026 +++++++++++++++++ pom.xml | 6 + 3 files changed, 1037 insertions(+) create mode 100644 core/src/test/resources/config/driver-config-report-v1.schema.json diff --git a/core/pom.xml b/core/pom.xml index 8342b8b6df5..13d93bf56e1 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -160,6 +160,11 @@ mockito-core test + + com.networknt + json-schema-validator + test + io.reactivex.rxjava2 rxjava diff --git a/core/src/test/resources/config/driver-config-report-v1.schema.json b/core/src/test/resources/config/driver-config-report-v1.schema.json new file mode 100644 index 00000000000..64f8ad6ce8a --- /dev/null +++ b/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 089be546943..f98de978331 100644 --- a/pom.xml +++ b/pom.xml @@ -92,6 +92,7 @@ 1.1.4 2.2.21 4.3.0 + 1.5.9 2.0.0-M19 3.5.5 22.0.0.2 @@ -320,6 +321,11 @@ mockito-core 5.23.0 + + com.networknt + json-schema-validator + ${json-schema-validator.version} + io.reactivex.rxjava2 rxjava From 74725d39b7fe84f92d89a9499b205637cf50cff7 Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Tue, 11 Aug 2026 15:15:43 +0200 Subject: [PATCH 2/6] refactor: send SESSION_ID from StartupOptionsBuilder on every connection Motivation: Stage 1 had the config reporter emit both startup options, so SESSION_ID was governed by advanced.driver-config-reporting.enabled and disappeared when reporting was turned off. That conflates two different things: SESSION_ID is what lets the server group a session's connections, and it is useful whether or not the driver also describes its configuration. The cross-driver review settled this the same way for gocql and 3.x -- SESSION_ID is reported no matter what. Modifications: SESSION_ID moves to StartupOptionsBuilder, beside the other innate startup options, and is sent on every connection unconditionally. It is generated lazily rather than in a field initializer, mirroring clientId; DefaultDriverContext builds the startup options exactly once per session (LazyReference), which is what makes the value stable across all of the session's connections, including reconnects. It is deliberately not derived from the user-settable, Insights-oriented CLIENT_ID, so that it is guaranteed unique per session as a grouping key requires. DriverConfigReporter accordingly narrows from populateStartupOptions(options, reportDriverConfig) to populateControlConnectionOptions(options): the reporter now has one job, and the control-connection-only decision moves to the caller in ProtocolInitHandler. Result: Turning driver config reporting off no longer leaves the wire unchanged -- SESSION_ID is still sent. That is documented on the option itself. Co-Authored-By: Claude Sonnet 5 Co-Authored-By: Claude Opus 5 (1M context) --- .../core/channel/ProtocolInitHandler.java | 12 ++-- .../context/DefaultDriverConfigReporter.java | 29 ++------ .../core/context/DefaultDriverContext.java | 4 ++ .../core/context/DriverConfigReporter.java | 30 ++++---- .../core/context/StartupOptionsBuilder.java | 31 ++++++-- .../context/DseStartupOptionsBuilderTest.java | 2 + .../core/channel/ChannelFactoryTestBase.java | 4 +- .../core/channel/ProtocolInitHandlerTest.java | 56 +++++++++++---- .../DefaultDriverConfigReporterTest.java | 71 +++++-------------- .../context/StartupOptionsBuilderTest.java | 34 +++++++++ .../config/DriverConfigReportingCcmIT.java | 33 +++++++-- .../DriverConfigReportingSimulacronIT.java | 26 +++---- 12 files changed, 193 insertions(+), 139 deletions(-) diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/channel/ProtocolInitHandler.java b/core/src/main/java/com/datastax/oss/driver/internal/core/channel/ProtocolInitHandler.java index cf782096f18..1ca45855fed 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/channel/ProtocolInitHandler.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/channel/ProtocolInitHandler.java @@ -194,11 +194,13 @@ Message getRequest() { if (featureStore != null) { featureStore.populateStartupOptions(startupOptions); } - // Adds SESSION_ID on every connection and DRIVER_CONFIG on the control connection - // (options.reportConfig); no-op when driver config reporting is disabled. - context - .getDriverConfigReporter() - .populateStartupOptions(startupOptions, options.reportConfig); + // The DRIVER_CONFIG blob describes the whole session, so only the control connection + // carries it (options.reportConfig); the other connections are correlated to it by the + // SESSION_ID that every connection already carries from context.getStartupOptions(). + // No-op when driver config reporting is disabled. + if (options.reportConfig) { + context.getDriverConfigReporter().populateControlConnectionOptions(startupOptions); + } return request = new Startup(startupOptions); case GET_CLUSTER_NAME: return request = CLUSTER_NAME_QUERY; diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/context/DefaultDriverConfigReporter.java b/core/src/main/java/com/datastax/oss/driver/internal/core/context/DefaultDriverConfigReporter.java index 2cd1b5a8560..6ea0664b072 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/context/DefaultDriverConfigReporter.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/context/DefaultDriverConfigReporter.java @@ -19,12 +19,10 @@ import com.datastax.oss.driver.api.core.config.DefaultDriverOption; import com.datastax.oss.driver.api.core.config.DriverExecutionProfile; -import com.datastax.oss.driver.api.core.uuid.Uuids; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import java.util.Map; -import java.util.UUID; import net.jcip.annotations.ThreadSafe; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -44,9 +42,6 @@ public class DefaultDriverConfigReporter implements DriverConfigReporter { /** STARTUP option key under which the config JSON is sent. */ public static final String DRIVER_CONFIG_KEY = "DRIVER_CONFIG"; - /** STARTUP option key under which the per-session identifier is sent. */ - public static final String SESSION_ID_KEY = "SESSION_ID"; - /** * Major schema version. Adding keys is backward-compatible and does not bump this; only * changing/removing the meaning of an existing key does. @@ -57,19 +52,12 @@ public class DefaultDriverConfigReporter implements DriverConfigReporter { protected final InternalDriverContext context; - // Dedicated, driver-generated identifier for this session. Not derived from the (user-settable, - // Insights-oriented) CLIENT_ID, so that it is guaranteed unique per session as the grouping key - // requires. The reporter is a per-session singleton (built once via LazyReference), so this value - // is stable and shared across all of the session's connections. - private final UUID sessionId = Uuids.random(); - public DefaultDriverConfigReporter(InternalDriverContext context) { this.context = context; } @Override - public void populateStartupOptions( - Map startupOptions, boolean reportDriverConfig) { + public void populateControlConnectionOptions(Map startupOptions) { // Configuration reporting is a best-effort diagnostic aid: it runs on the connection // initialization path, so any failure here (a bad config read, a misbehaving policy while // introspecting, a serialization error) must be swallowed rather than allowed to break the @@ -78,19 +66,12 @@ public void populateStartupOptions( if (!isEnabled()) { return; } - // SESSION_ID on every connection so the server can group a session's connections. - startupOptions.put(SESSION_ID_KEY, sessionId.toString()); - // DRIVER_CONFIG blob only on the control connection. - if (reportDriverConfig) { - String json = buildJson(); - if (json != null) { - startupOptions.put(DRIVER_CONFIG_KEY, json); - } + String json = buildJson(); + if (json != null) { + startupOptions.put(DRIVER_CONFIG_KEY, json); } } catch (RuntimeException e) { - LOG.warn( - "Error while building the driver configuration report; skipping driver config reporting", - e); + LOG.warn("Error while building the driver configuration report; skipping DRIVER_CONFIG", e); } } diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/context/DefaultDriverContext.java b/core/src/main/java/com/datastax/oss/driver/internal/core/context/DefaultDriverContext.java index 3d1d5b82b87..2983ef0787f 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/context/DefaultDriverContext.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/context/DefaultDriverContext.java @@ -361,6 +361,10 @@ public DefaultDriverContext( /** * Returns the options to send in a Startup message. * + *

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

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

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

    - *
  • {@code SESSION_ID} — a unique-per-session identifier, added on every connection so - * the server can group all of a session's connections; - *
  • {@code DRIVER_CONFIG} — the full configuration JSON blob, added only on the control - * connection (pooled connections are correlated back to it via {@code SESSION_ID}). - *
+ *

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

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

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

Implementations must not throw: this runs on the connection initialization path, so a * failure to build the report must be swallowed (and logged) rather than propagated, otherwise it * would prevent the session from establishing or reconnecting. - * - * @param reportDriverConfig whether this connection should also carry the full {@code - * DRIVER_CONFIG} blob; true only for the control connection. */ - void populateStartupOptions(Map startupOptions, boolean reportDriverConfig); + void populateControlConnectionOptions(Map startupOptions); } diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/context/StartupOptionsBuilder.java b/core/src/main/java/com/datastax/oss/driver/internal/core/context/StartupOptionsBuilder.java index 684d6b01b9c..dd3a0307a79 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/context/StartupOptionsBuilder.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/context/StartupOptionsBuilder.java @@ -37,8 +37,20 @@ public class StartupOptionsBuilder { public static final String APPLICATION_VERSION_KEY = "APPLICATION_VERSION"; public static final String CLIENT_ID_KEY = "CLIENT_ID"; + /** + * STARTUP option key under which the session's identifier is sent, so that the server can group + * all of a session's connections (and correlate them with the configuration that the control + * connection reports under {@code DRIVER_CONFIG}). + * + *

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

The default set of options are built here and include {@link * com.datastax.oss.protocol.internal.request.Startup#COMPRESSION_KEY} (if the context passed in - * has a compressor/algorithm set), and the driver's {@link #DRIVER_NAME_KEY} and {@link - * #DRIVER_VERSION_KEY}. The {@link com.datastax.oss.protocol.internal.request.Startup} - * constructor will add {@link + * has a compressor/algorithm set), the driver's {@link #DRIVER_NAME_KEY} and {@link + * #DRIVER_VERSION_KEY}, and the {@link #SESSION_ID_KEY}. The {@link + * com.datastax.oss.protocol.internal.request.Startup} constructor will add {@link * com.datastax.oss.protocol.internal.request.Startup#CQL_VERSION_KEY}. * * @return Map of Startup Options. @@ -94,7 +106,7 @@ public StartupOptionsBuilder withApplicationVersion(@Nullable String application public Map build() { DriverExecutionProfile config = context.getConfig().getDefaultProfile(); - NullAllowingImmutableMap.Builder builder = NullAllowingImmutableMap.builder(3); + NullAllowingImmutableMap.Builder builder = NullAllowingImmutableMap.builder(4); // add compression (if configured) and driver name and version String compressionAlgorithm = context.getCompressor().algorithm(); if (compressionAlgorithm != null && !compressionAlgorithm.trim().isEmpty()) { @@ -102,6 +114,17 @@ public Map build() { } builder.put(DRIVER_NAME_KEY, getDriverName()).put(DRIVER_VERSION_KEY, getDriverVersion()); + // Identifier of this session, sent on every connection so the server can group them. Not + // derived from the (user-settable, Insights-oriented) CLIENT_ID below, so that it is guaranteed + // unique per session as the grouping key requires. Generated lazily here rather than eagerly in + // a field initializer, mirroring clientId; DefaultDriverContext builds the startup options + // exactly once per session (LazyReference), which is what makes the value stable across all of + // the session's connections, including reconnects. + if (sessionId == null) { + sessionId = Uuids.random(); + } + builder.put(SESSION_ID_KEY, sessionId.toString()); + // Add Insights entries, falling back to generation / config if no programmatic values provided: if (clientId == null) { clientId = Uuids.random(); diff --git a/core/src/test/java/com/datastax/dse/driver/internal/core/context/DseStartupOptionsBuilderTest.java b/core/src/test/java/com/datastax/dse/driver/internal/core/context/DseStartupOptionsBuilderTest.java index 9e4556e528d..81df53af9a3 100644 --- a/core/src/test/java/com/datastax/dse/driver/internal/core/context/DseStartupOptionsBuilderTest.java +++ b/core/src/test/java/com/datastax/dse/driver/internal/core/context/DseStartupOptionsBuilderTest.java @@ -80,6 +80,8 @@ private void assertDefaultStartupOptions(Startup startup) { Version version = Version.parse(startup.options.get(StartupOptionsBuilder.DRIVER_VERSION_KEY)); assertThat(version).isEqualTo(Session.OSS_DRIVER_COORDINATES.getVersion()); assertThat(startup.options).containsKey(StartupOptionsBuilder.CLIENT_ID_KEY); + // SESSION_ID is innate and must survive on the DSE path too. + assertThat(startup.options).containsKey(StartupOptionsBuilder.SESSION_ID_KEY); } @Test diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryTestBase.java b/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryTestBase.java index 2780d5bdec9..ed6668a6c83 100644 --- a/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryTestBase.java +++ b/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ChannelFactoryTestBase.java @@ -141,8 +141,8 @@ public void setup() throws InterruptedException { when(context.getEventBus()).thenReturn(eventBus); when(context.getWriteCoalescer()).thenReturn(new PassThroughWriteCoalescer(null)); when(context.getCompressor()).thenReturn(compressor); - // The init handler consults the config reporter for every connection; default to a no-op. - when(context.getDriverConfigReporter()).thenReturn((startupOptions, reportDriverConfig) -> {}); + // The init handler consults the config reporter for the control connection; default to a no-op. + when(context.getDriverConfigReporter()).thenReturn(startupOptions -> {}); // Start local server ServerBootstrap serverBootstrap = diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ProtocolInitHandlerTest.java b/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ProtocolInitHandlerTest.java index a7051ac466d..682caac198d 100644 --- a/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ProtocolInitHandlerTest.java +++ b/core/src/test/java/com/datastax/oss/driver/internal/core/channel/ProtocolInitHandlerTest.java @@ -42,9 +42,12 @@ import com.datastax.oss.driver.internal.core.ProtocolVersionRegistry; import com.datastax.oss.driver.internal.core.TestResponses; import com.datastax.oss.driver.internal.core.context.DefaultDriverConfigReporter; +import com.datastax.oss.driver.internal.core.context.DriverConfigReporter; import com.datastax.oss.driver.internal.core.context.InternalDriverContext; +import com.datastax.oss.driver.internal.core.context.StartupOptionsBuilder; import com.datastax.oss.driver.internal.core.metadata.TestNodeFactory; import com.datastax.oss.driver.shaded.guava.common.collect.ImmutableList; +import com.datastax.oss.driver.shaded.guava.common.collect.ImmutableMap; import com.datastax.oss.protocol.internal.Frame; import com.datastax.oss.protocol.internal.ProtocolConstants; import com.datastax.oss.protocol.internal.ProtocolConstants.ErrorCode; @@ -103,9 +106,8 @@ public void setup() { when(defaultProfile.getDuration(DefaultDriverOption.HEARTBEAT_INTERVAL)) .thenReturn(Duration.ofSeconds(30)); when(internalDriverContext.getProtocolVersionRegistry()).thenReturn(protocolVersionRegistry); - // The init handler consults the config reporter for every connection; default to a no-op. - when(internalDriverContext.getDriverConfigReporter()) - .thenReturn((startupOptions, reportDriverConfig) -> {}); + // The init handler consults the config reporter for the control connection; default to a no-op. + when(internalDriverContext.getDriverConfigReporter()).thenReturn(startupOptions -> {}); channel .pipeline() @@ -157,21 +159,17 @@ public void should_initialize() { assertThat(connectFuture).isSuccess(); } - // Mirrors the real reporter: SESSION_ID on every connection, DRIVER_CONFIG only when asked. + // Mirrors the real reporter, which only ever sees the control connection. private void stubConfigReporter() { when(internalDriverContext.getDriverConfigReporter()) .thenReturn( - (startupOptions, reportDriverConfig) -> { - startupOptions.put(DefaultDriverConfigReporter.SESSION_ID_KEY, "test-session-id"); - if (reportDriverConfig) { + startupOptions -> startupOptions.put( - DefaultDriverConfigReporter.DRIVER_CONFIG_KEY, "{\"version\":1}"); - } - }); + DefaultDriverConfigReporter.DRIVER_CONFIG_KEY, "{\"version\":1}")); } @Test - public void should_report_session_id_and_driver_config_on_control_connection() { + public void should_report_driver_config_on_control_connection() { stubConfigReporter(); channel .pipeline() @@ -191,13 +189,13 @@ public void should_report_session_id_and_driver_config_on_control_connection() { Frame requestFrame = readOutboundFrame(); assertThat(requestFrame.message).isInstanceOf(Startup.class); Startup startup = (Startup) requestFrame.message; - assertThat(startup.options).containsKey(DefaultDriverConfigReporter.SESSION_ID_KEY); assertThat(startup.options).containsKey(DefaultDriverConfigReporter.DRIVER_CONFIG_KEY); } @Test - public void should_report_session_id_but_not_driver_config_on_pool_connection() { - stubConfigReporter(); + public void should_not_consult_the_config_reporter_on_pool_connection() { + DriverConfigReporter reporter = mock(DriverConfigReporter.class); + when(internalDriverContext.getDriverConfigReporter()).thenReturn(reporter); channel .pipeline() .addLast( @@ -217,8 +215,36 @@ public void should_report_session_id_but_not_driver_config_on_pool_connection() Frame requestFrame = readOutboundFrame(); assertThat(requestFrame.message).isInstanceOf(Startup.class); Startup startup = (Startup) requestFrame.message; - assertThat(startup.options).containsKey(DefaultDriverConfigReporter.SESSION_ID_KEY); assertThat(startup.options).doesNotContainKey(DefaultDriverConfigReporter.DRIVER_CONFIG_KEY); + verify(reporter, never()).populateControlConnectionOptions(any()); + } + + @Test + public void should_pass_session_id_from_the_session_startup_options_to_every_connection() { + // SESSION_ID is not the reporter's business: it comes from the session-wide startup options, so + // it reaches pool connections (reportConfig = false) as well. + when(internalDriverContext.getStartupOptions()) + .thenReturn(ImmutableMap.of(StartupOptionsBuilder.SESSION_ID_KEY, "test-session-id")); + channel + .pipeline() + .addLast( + ChannelFactory.INIT_HANDLER_NAME, + new ProtocolInitHandler( + internalDriverContext, + DefaultProtocolVersion.V4, + null, + END_POINT, + DriverChannelOptions.DEFAULT, + heartbeatHandler, + false)); + + channel.connect(new InetSocketAddress("localhost", 9042)); + + Frame requestFrame = readOutboundFrame(); + assertThat(requestFrame.message).isInstanceOf(Startup.class); + Startup startup = (Startup) requestFrame.message; + assertThat(startup.options) + .containsEntry(StartupOptionsBuilder.SESSION_ID_KEY, "test-session-id"); } @Test diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/context/DefaultDriverConfigReporterTest.java b/core/src/test/java/com/datastax/oss/driver/internal/core/context/DefaultDriverConfigReporterTest.java index b303e6db5a5..66da31a1c5f 100644 --- a/core/src/test/java/com/datastax/oss/driver/internal/core/context/DefaultDriverConfigReporterTest.java +++ b/core/src/test/java/com/datastax/oss/driver/internal/core/context/DefaultDriverConfigReporterTest.java @@ -27,10 +27,12 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import java.util.HashMap; import java.util.Map; -import java.util.UUID; import org.junit.Before; import org.junit.Test; +// SESSION_ID is not this class's concern: it is an innate startup option built by +// StartupOptionsBuilder and sent on every connection regardless of these settings, so it is covered +// by StartupOptionsBuilderTest instead. public class DefaultDriverConfigReporterTest { private InternalDriverContext context; @@ -56,55 +58,21 @@ private void enableReporting(boolean enabled) { public void should_add_nothing_when_disabled() { enableReporting(false); Map options = new HashMap<>(); - reporter.populateStartupOptions(options, /* reportDriverConfig= */ true); - assertThat(options).doesNotContainKey(DefaultDriverConfigReporter.SESSION_ID_KEY); - assertThat(options).doesNotContainKey(DefaultDriverConfigReporter.DRIVER_CONFIG_KEY); + reporter.populateControlConnectionOptions(options); + assertThat(options).isEmpty(); } @Test - public void should_add_session_id_and_driver_config_on_control_connection() { + public void should_add_driver_config_when_enabled() { enableReporting(true); Map options = new HashMap<>(); - reporter.populateStartupOptions(options, /* reportDriverConfig= */ true); - // SESSION_ID is a valid, driver-generated UUID. - String sessionId = options.get(DefaultDriverConfigReporter.SESSION_ID_KEY); - assertThat(sessionId).isNotNull(); - assertThat(UUID.fromString(sessionId)).isNotNull(); // does not throw => valid UUID + reporter.populateControlConnectionOptions(options); // Stage 1 emits only the schema version; the value must be valid compact JSON. - assertThat(options.get(DefaultDriverConfigReporter.DRIVER_CONFIG_KEY)) - .isEqualTo("{\"version\":" + DefaultDriverConfigReporter.SCHEMA_VERSION + "}"); - } - - @Test - public void should_add_session_id_only_on_pool_connection() { - enableReporting(true); - Map options = new HashMap<>(); - reporter.populateStartupOptions(options, /* reportDriverConfig= */ false); - assertThat(options).containsKey(DefaultDriverConfigReporter.SESSION_ID_KEY); - assertThat(options).doesNotContainKey(DefaultDriverConfigReporter.DRIVER_CONFIG_KEY); - } - - @Test - public void should_use_a_stable_session_id_across_connections() { - enableReporting(true); - Map control = new HashMap<>(); - Map pool = new HashMap<>(); - reporter.populateStartupOptions(control, true); - reporter.populateStartupOptions(pool, false); - assertThat(pool.get(DefaultDriverConfigReporter.SESSION_ID_KEY)) - .isEqualTo(control.get(DefaultDriverConfigReporter.SESSION_ID_KEY)); - } - - @Test - public void should_use_a_distinct_session_id_per_reporter() { - enableReporting(true); - Map first = new HashMap<>(); - reporter.populateStartupOptions(first, false); - // A second session (new reporter instance) must get a different SESSION_ID. - Map second = new HashMap<>(); - new DefaultDriverConfigReporter(context).populateStartupOptions(second, false); - assertThat(second.get(DefaultDriverConfigReporter.SESSION_ID_KEY)) - .isNotEqualTo(first.get(DefaultDriverConfigReporter.SESSION_ID_KEY)); + assertThat(options) + .hasSize(1) + .containsEntry( + DefaultDriverConfigReporter.DRIVER_CONFIG_KEY, + "{\"version\":" + DefaultDriverConfigReporter.SCHEMA_VERSION + "}"); } /** Reporting must never break the connection: a failed config read is swallowed entirely. */ @@ -113,18 +81,16 @@ public void should_not_throw_when_reading_the_flag_fails() { when(profile.getBoolean(DefaultDriverOption.DRIVER_CONFIG_REPORTING_ENABLED, false)) .thenThrow(new IllegalStateException("config blew up")); Map options = new HashMap<>(); - reporter.populateStartupOptions(options, true); // must not throw - assertThat(options).doesNotContainKey(DefaultDriverConfigReporter.SESSION_ID_KEY); - assertThat(options).doesNotContainKey(DefaultDriverConfigReporter.DRIVER_CONFIG_KEY); + reporter.populateControlConnectionOptions(options); // must not throw + assertThat(options).isEmpty(); } /** * Reporting must never break the connection: a failure while building the config groups (as a - * Stage 2 policy introspection might) is swallowed. SESSION_ID is still emitted (it is added - * before, and independently of, the DRIVER_CONFIG blob); only DRIVER_CONFIG is omitted. + * Stage 2 policy introspection might) is swallowed, and DRIVER_CONFIG is simply omitted. */ @Test - public void should_keep_session_id_but_skip_driver_config_when_building_config_groups_fails() { + public void should_skip_driver_config_when_building_config_groups_fails() { enableReporting(true); DefaultDriverConfigReporter throwingReporter = new DefaultDriverConfigReporter(context) { @@ -134,8 +100,7 @@ protected void populateConfig(ObjectNode root, DriverExecutionProfile config) { } }; Map options = new HashMap<>(); - throwingReporter.populateStartupOptions(options, true); // must not throw - assertThat(options).containsKey(DefaultDriverConfigReporter.SESSION_ID_KEY); - assertThat(options).doesNotContainKey(DefaultDriverConfigReporter.DRIVER_CONFIG_KEY); + throwingReporter.populateControlConnectionOptions(options); // must not throw + assertThat(options).isEmpty(); } } diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/context/StartupOptionsBuilderTest.java b/core/src/test/java/com/datastax/oss/driver/internal/core/context/StartupOptionsBuilderTest.java index 2f8f4174093..963e9954592 100644 --- a/core/src/test/java/com/datastax/oss/driver/internal/core/context/StartupOptionsBuilderTest.java +++ b/core/src/test/java/com/datastax/oss/driver/internal/core/context/StartupOptionsBuilderTest.java @@ -30,6 +30,7 @@ import com.tngtech.java.junit.dataprovider.DataProvider; import com.tngtech.java.junit.dataprovider.DataProviderRunner; import java.util.Optional; +import java.util.UUID; import org.junit.Test; import org.junit.runner.RunWith; @@ -54,6 +55,10 @@ private void assertDefaultStartupOptions(Startup startup) { assertThat(startup.options).containsKey(StartupOptionsBuilder.DRIVER_VERSION_KEY); Version version = Version.parse(startup.options.get(StartupOptionsBuilder.DRIVER_VERSION_KEY)); assertThat(version).isEqualByComparingTo(Session.OSS_DRIVER_COORDINATES.getVersion()); + // SESSION_ID is innate: sent on every connection, whatever the configuration says. + assertThat(startup.options).containsKey(StartupOptionsBuilder.SESSION_ID_KEY); + assertThat(UUID.fromString(startup.options.get(StartupOptionsBuilder.SESSION_ID_KEY))) + .isNotNull(); } @Test @@ -85,6 +90,35 @@ public void should_build_startup_options(String compression) { assertDefaultStartupOptions(startup); } + @Test + public void should_use_a_stable_session_id_for_the_whole_session() { + + // The startup options are built once per session and copied into every connection's STARTUP, so + // all of a session's connections report the same SESSION_ID. + DefaultDriverContext ctx = MockedDriverContextFactory.defaultDriverContext(); + assertThat(ctx.getStartupOptions().get(StartupOptionsBuilder.SESSION_ID_KEY)) + .isEqualTo(ctx.getStartupOptions().get(StartupOptionsBuilder.SESSION_ID_KEY)); + } + + @Test + public void should_use_a_distinct_session_id_per_session() { + + DefaultDriverContext ctx1 = MockedDriverContextFactory.defaultDriverContext(); + DefaultDriverContext ctx2 = MockedDriverContextFactory.defaultDriverContext(); + assertThat(ctx1.getStartupOptions().get(StartupOptionsBuilder.SESSION_ID_KEY)) + .isNotEqualTo(ctx2.getStartupOptions().get(StartupOptionsBuilder.SESSION_ID_KEY)); + } + + @Test + public void should_not_derive_session_id_from_client_id() { + + // SESSION_ID must be driver-generated, not the (user-settable) CLIENT_ID, so that it is + // guaranteed unique per session as the grouping key requires. + DefaultDriverContext ctx = MockedDriverContextFactory.defaultDriverContext(); + assertThat(ctx.getStartupOptions().get(StartupOptionsBuilder.SESSION_ID_KEY)) + .isNotEqualTo(ctx.getStartupOptions().get(StartupOptionsBuilder.CLIENT_ID_KEY)); + } + @Test public void should_fail_to_build_startup_options_with_invalid_compression() { diff --git a/integration-tests/src/test/java/com/datastax/oss/driver/core/config/DriverConfigReportingCcmIT.java b/integration-tests/src/test/java/com/datastax/oss/driver/core/config/DriverConfigReportingCcmIT.java index 4710f9d4efe..f3e453d289f 100644 --- a/integration-tests/src/test/java/com/datastax/oss/driver/core/config/DriverConfigReportingCcmIT.java +++ b/integration-tests/src/test/java/com/datastax/oss/driver/core/config/DriverConfigReportingCcmIT.java @@ -30,6 +30,8 @@ import com.datastax.oss.driver.api.testinfra.session.SessionRule; import com.datastax.oss.driver.api.testinfra.session.SessionUtils; import com.datastax.oss.driver.categories.ParallelizableTests; +import com.datastax.oss.driver.internal.core.context.InternalDriverContext; +import com.datastax.oss.driver.internal.core.context.StartupOptionsBuilder; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -125,10 +127,12 @@ public void should_store_session_id_on_all_connections_and_driver_config_on_cont row.getMap("client_options", String.class, String.class)); } - // (a) Every connection carries SESSION_ID, and all of them share a single value (one session). + // (a) Every row carries this session's SESSION_ID (that is what they were selected on), and + // there is more than one of them — otherwise (b) below would be vacuous. + assertThat(rows).hasSizeGreaterThanOrEqualTo(2); Set sessionIds = rows.stream().map(row -> clientOptions(row).get("SESSION_ID")).collect(Collectors.toSet()); - assertThat(sessionIds).doesNotContainNull().hasSize(1); + assertThat(sessionIds).containsExactly(sessionId(session)); // (b) DRIVER_CONFIG is stored for exactly one connection (the control connection), and its // value round-trips through the server intact as the stage-1 payload: valid JSON carrying @@ -160,13 +164,30 @@ private static void assertStageOnePayload(String driverConfig) { assertThat(root.path("version").intValue()).isEqualTo(1); } + /** + * The {@code SESSION_ID} this session reports, read from the session-wide startup options — the + * same map the driver copies into every connection's {@code STARTUP}. + */ + private String sessionId(CqlSession session) { + String sessionId = + ((InternalDriverContext) session.getContext()) + .getStartupOptions() + .get(StartupOptionsBuilder.SESSION_ID_KEY); + assertThat(sessionId).isNotNull(); + return sessionId; + } + /** * The rows in the clients table that belong to this driver session's connections: this driver, in - * a {@code READY} state, and carrying the reporting {@code SESSION_ID}. Transient - * protocol-version negotiation attempts (no driver identity, closed immediately) are excluded, - * and their absence here is itself the confirmation that they leave no lingering session rows. + * a {@code READY} state, and carrying this session's {@code SESSION_ID}. + * + *

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

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

    - *
  • true — {@code SESSION_ID} is present (and identical) on every session - * connection, while {@code DRIVER_CONFIG} is present only on the control connection; - *
  • false — neither option is present on any session connection. + *
  • {@code SESSION_ID} is present (and identical) on every session connection, + * whatever {@code advanced.driver-config-reporting.enabled} is set to — it is an + * innate startup option, not part of configuration reporting; + *
  • {@code DRIVER_CONFIG} is present only on the control connection, and only when {@code + * advanced.driver-config-reporting.enabled} is true. *
* *

The control connection is identified independently of the reported options: it is the only @@ -140,7 +142,7 @@ private static void assertStageOnePayload(String driverConfig) { } @Test - public void should_report_nothing_when_disabled() { + public void should_still_report_session_id_when_driver_config_reporting_is_disabled() { DriverConfigLoader loader = SessionUtils.configLoaderBuilder() .withBoolean(DefaultDriverOption.DRIVER_CONFIG_REPORTING_ENABLED, false) @@ -151,13 +153,13 @@ public void should_report_nothing_when_disabled() { List startups = sessionStartups(); assertThat(distinctConnections(startups)).isGreaterThanOrEqualTo(2); - // Neither option is sent on any session connection: zero change on the wire when disabled. + // SESSION_ID does not depend on the option: it is still sent, with a single shared value... + assertThat(startups).allSatisfy(log -> assertThat(options(log)).containsKey(SESSION_ID_KEY)); + assertThat(startups.stream().map(log -> options(log).get(SESSION_ID_KEY)).distinct()) + .hasSize(1); + // ... while the configuration itself is reported nowhere. assertThat(startups) - .allSatisfy( - log -> - assertThat(options(log)) - .doesNotContainKey(SESSION_ID_KEY) - .doesNotContainKey(DRIVER_CONFIG_KEY)); + .allSatisfy(log -> assertThat(options(log)).doesNotContainKey(DRIVER_CONFIG_KEY)); } } From ce1de8a0050dac9846772f7149f9a8325d0f9ae8 Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Tue, 11 Aug 2026 15:15:54 +0200 Subject: [PATCH 3/6] refactor: hoist the orphan-request correction and the sharding-info unwrap Motivation: Two values the configuration report needs were computed inline where only one caller could reach them. The effective max-orphan-requests is not always the configured advanced.connection.max-orphan-requests: that option has to stay below advanced.connection.max-requests-per-connection, and a value that does not is silently corrected to a quarter of it. The correction lived inside ChannelFactory's channel-initialization block, so a second caller could only duplicate it -- and a report that duplicated it slightly differently would claim a limit the connection was not built with. Separately, ShardingInfo.ConnectionShardingInfo carries a shardId specific to one connection, which node-level and session-level callers have to unwrap past every time. Modifications: - ChannelFactory.effectiveMaxOrphanRequests(maxRequestsPerConnection, maxOrphanRequests) is now a static method, and the initialization block calls it. The warning still logs the configured value and the corrected one rather than recomputing either. - ProtocolFeatureStore.getNodeShardingInfo() does the unwrap once, returning null when the server advertised no sharding information -- which is the driver's own proxy check for "this is not ScyllaDB". DriverChannel delegates to it, and CassandraSchemaQueries, the other caller of that proxy check, gets a punctuation fix on the comment describing it. Result: Pure extraction, no behavior change. One implementation of each, so the report cannot drift from what the connection was actually built with. Co-Authored-By: Claude Sonnet 5 Co-Authored-By: Claude Opus 5 (1M context) --- .../internal/core/channel/ChannelFactory.java | 31 ++++++++++++++++--- .../internal/core/channel/DriverChannel.java | 3 +- .../queries/CassandraSchemaQueries.java | 2 +- .../core/protocol/ProtocolFeatureStore.java | 14 +++++++++ 4 files changed, 42 insertions(+), 8 deletions(-) diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/channel/ChannelFactory.java b/core/src/main/java/com/datastax/oss/driver/internal/core/channel/ChannelFactory.java index 6bfc355f910..35190afa3f4 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/channel/ChannelFactory.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/channel/ChannelFactory.java @@ -104,6 +104,26 @@ public class ChannelFactory { public static final String INFLIGHT_HANDLER_NAME = "inflight"; public static final String INIT_HANDLER_NAME = "init"; + /** + * The number of orphaned requests a connection is actually built with, which is not always the + * configured {@code advanced.connection.max-orphan-requests}: that option has to stay below + * {@code advanced.connection.max-requests-per-connection}, and a value that does not is silently + * corrected to a quarter of it (the caller logs a warning when that happens). + * + *

Shared with {@code DefaultDriverConfigReporter}, which reports this number as {@code + * connection.requests.orphaned.max}: one implementation means the report cannot claim a limit the + * connection was not built with. + * + * @param maxRequestsPerConnection the configured {@code max-requests-per-connection}. + * @param maxOrphanRequests the configured {@code max-orphan-requests}. + */ + public static int effectiveMaxOrphanRequests( + int maxRequestsPerConnection, int maxOrphanRequests) { + return (maxOrphanRequests >= maxRequestsPerConnection) + ? maxRequestsPerConnection / 4 + : maxOrphanRequests; + } + private final String logPrefix; protected final InternalDriverContext context; @@ -377,20 +397,21 @@ protected void initChannel(Channel channel) { (int) defaultConfig.getBytes(DefaultDriverOption.PROTOCOL_MAX_FRAME_LENGTH); int maxRequestsPerConnection = defaultConfig.getInt(DefaultDriverOption.CONNECTION_MAX_REQUESTS); - int maxOrphanRequests = + int configuredMaxOrphanRequests = defaultConfig.getInt(DefaultDriverOption.CONNECTION_MAX_ORPHAN_REQUESTS); - if (maxOrphanRequests >= maxRequestsPerConnection) { + int maxOrphanRequests = + effectiveMaxOrphanRequests(maxRequestsPerConnection, configuredMaxOrphanRequests); + if (configuredMaxOrphanRequests >= maxRequestsPerConnection) { if (LOGGED_ORPHAN_WARNING.compareAndSet(false, true)) { LOG.warn( "[{}] Invalid value for {}: {}. It must be lower than {}. " + "Defaulting to {} (1/4 of max-requests) instead.", logPrefix, DefaultDriverOption.CONNECTION_MAX_ORPHAN_REQUESTS.getPath(), - maxOrphanRequests, + configuredMaxOrphanRequests, DefaultDriverOption.CONNECTION_MAX_REQUESTS.getPath(), - maxRequestsPerConnection / 4); + maxOrphanRequests); } - maxOrphanRequests = maxRequestsPerConnection / 4; } InFlightHandler inFlightHandler = diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/channel/DriverChannel.java b/core/src/main/java/com/datastax/oss/driver/internal/core/channel/DriverChannel.java index d4d1bb600c7..5e6fac7f9e7 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/channel/DriverChannel.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/channel/DriverChannel.java @@ -170,8 +170,7 @@ public int getShardId() { } public ShardingInfo getShardingInfo() { - ConnectionShardingInfo info = getSupportedFeatures().getShardingInfo(); - return info != null ? info.shardingInfo : null; + return getSupportedFeatures().getNodeShardingInfo(); } public LwtInfo getLwtInfo() { diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/schema/queries/CassandraSchemaQueries.java b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/schema/queries/CassandraSchemaQueries.java index f909a0cb387..4c7e51318ed 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/schema/queries/CassandraSchemaQueries.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/metadata/schema/queries/CassandraSchemaQueries.java @@ -171,7 +171,7 @@ private void executeOnAdminExecutor() { } protected boolean shouldApplyUsingTimeout() { - // We use non-null sharding info as a proxy check for cluster being a ScyllaDB cluster + // We use non-null sharding info as a proxy check for cluster being a ScyllaDB cluster. return (channel.getShardingInfo() != null); } diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/protocol/ProtocolFeatureStore.java b/core/src/main/java/com/datastax/oss/driver/internal/core/protocol/ProtocolFeatureStore.java index 87134d32030..9d7fdbf1c8b 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/protocol/ProtocolFeatureStore.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/protocol/ProtocolFeatureStore.java @@ -2,6 +2,7 @@ import com.datastax.oss.protocol.internal.ProtocolFeatures; import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; import io.netty.channel.Channel; import io.netty.util.AttributeKey; import java.util.List; @@ -39,6 +40,19 @@ public ShardingInfo.ConnectionShardingInfo getShardingInfo() { return shardingInfo; } + /** + * The node-level sharding information the server advertised on this connection, unwrapped from + * the per-connection {@link ShardingInfo.ConnectionShardingInfo} (whose {@code shardId} is + * specific to this one connection and so of no interest to node-level or session-level callers). + * + * @return {@code null} if the server advertised no sharding information, which is the driver's + * own proxy check for "this is not ScyllaDB". + */ + @Nullable + public ShardingInfo getNodeShardingInfo() { + return shardingInfo == null ? null : shardingInfo.shardingInfo; + } + public TabletInfo getTabletFeatureInfo() { return tabletInfo; } From 12cc3cb7e493b9162bd13832caa8da20d0573284 Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Tue, 11 Aug 2026 15:15:54 +0200 Subject: [PATCH 4/6] feat: expose the state the config report reads Motivation: The report has to describe what a session is actually running with, and for several components that is not what the configuration currently says. The built-in policies read their options once, at construction, and keep them in private fields; after a configuration reload the profile holds new values while the policy still runs on the old ones. Reading the profile would describe a policy that is not in effect. The SSL engine factories hold the same kind of state -- whether they configure the engine to validate host names -- and the built-in one latches it from the configuration the same way. Modifications: Adds accessors for that state, so the report can read the instance rather than the config: - BasicLoadBalancingPolicy and DefaultLoadBalancingPolicy: the datacenter and rack the policy actually resolved, and whether adaptive ordering is on -- which DefaultLoadBalancingPolicy also latches at construction. - ConstantReconnectionPolicy and ConstantSpeculativeExecutionPolicy: the parameters they were built with. - JdkSslHandlerFactory: the SslEngineFactory it wraps, so host name validation is read from the factory actually in use rather than from a possibly unused one configured alongside it. A context that overrides buildSslHandlerFactory() may wrap a factory of its own choosing, in which case the configured one is never consulted on the connection path. - DefaultSslEngineFactory and ProgrammaticSslEngineFactory: whether they require host name validation. Deliberately on the classes rather than on the SslEngineFactory interface. An accessor there would need a default, and there is no honest default: an arbitrary factory can neither be assumed to validate host names nor be assumed not to, so the default answer would misdescribe a security control for every implementation that never considered the question. The report names the factories it recognizes and says nothing about the rest. Two of the load-balancing accessors widen from package-private to public; that is noted on them. Result: Pure widening -- no behavior changes. Every accessor returns state the component had already computed. Co-Authored-By: Claude Sonnet 5 Co-Authored-By: Claude Opus 5 (1M context) --- .../ssl/ProgrammaticSslEngineFactory.java | 14 ++++++++++++ .../ConstantReconnectionPolicy.java | 15 ++++++++++++- .../BasicLoadBalancingPolicy.java | 20 +++++++++++++++-- .../DefaultLoadBalancingPolicy.java | 12 ++++++++++ .../ConstantSpeculativeExecutionPolicy.java | 22 +++++++++++++++++++ .../core/ssl/DefaultSslEngineFactory.java | 14 ++++++++++++ .../core/ssl/JdkSslHandlerFactory.java | 12 ++++++++++ ...onstantSpeculativeExecutionPolicyTest.java | 12 ++++++++++ 8 files changed, 118 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/com/datastax/oss/driver/api/core/ssl/ProgrammaticSslEngineFactory.java b/core/src/main/java/com/datastax/oss/driver/api/core/ssl/ProgrammaticSslEngineFactory.java index d65eaa864aa..d50ad0f2397 100644 --- a/core/src/main/java/com/datastax/oss/driver/api/core/ssl/ProgrammaticSslEngineFactory.java +++ b/core/src/main/java/com/datastax/oss/driver/api/core/ssl/ProgrammaticSslEngineFactory.java @@ -133,6 +133,20 @@ public SSLEngine newSslEngine(@NonNull EndPoint remoteEndpoint) { return engine; } + /** + * Whether {@link #newSslEngine} configures the engine to validate the server certificate against + * the node's host name, as passed to the constructor. + * + *

A diagnostic accessor, read by the driver-configuration report sent to the server at + * connection time. Deliberately not on {@link SslEngineFactory}: an arbitrary factory can neither + * be assumed to validate host names nor be assumed not to, and a default answer on the interface + * would misdescribe a security control for every implementation that never considered the + * question. The report names the factories it recognizes and says nothing about the rest. + */ + public boolean isHostnameValidationRequired() { + return requireHostnameValidation; + } + @Override public void close() { // nothing to do diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/connection/ConstantReconnectionPolicy.java b/core/src/main/java/com/datastax/oss/driver/internal/core/connection/ConstantReconnectionPolicy.java index 03edb38f8d4..a3f272938e3 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/connection/ConstantReconnectionPolicy.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/connection/ConstantReconnectionPolicy.java @@ -49,6 +49,7 @@ public class ConstantReconnectionPolicy implements ReconnectionPolicy { private static final Logger LOG = LoggerFactory.getLogger(ConstantReconnectionPolicy.class); private final String logPrefix; + private final Duration delay; private final ReconnectionSchedule schedule; /** Builds a new instance. */ @@ -61,12 +62,24 @@ public ConstantReconnectionPolicy(DriverContext context) { String.format( "Invalid negative delay for " + DefaultDriverOption.RECONNECTION_BASE_DELAY.getPath() - + " (got %d)", + + " (got %s)", delay)); } + this.delay = delay; this.schedule = () -> delay; } + /** + * The fixed delay between reconnection attempts that this instance was built with. + * + *

Read from the configuration once, at construction: a later configuration reload does not + * affect an already-running policy. Exposed so that diagnostics can describe the delay actually + * in force rather than whatever the profile currently says. + */ + public Duration getDelay() { + return delay; + } + @NonNull @Override public ReconnectionSchedule newNodeSchedule(@NonNull Node node) { diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/loadbalancing/BasicLoadBalancingPolicy.java b/core/src/main/java/com/datastax/oss/driver/internal/core/loadbalancing/BasicLoadBalancingPolicy.java index 71160cb5612..d73d7dd2bb5 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/loadbalancing/BasicLoadBalancingPolicy.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/loadbalancing/BasicLoadBalancingPolicy.java @@ -235,15 +235,31 @@ private Optional getRequestProfile(@NonNull Request requ * Before initialization, this method always returns null. */ @Nullable - protected String getLocalDatacenter() { + public String getLocalDatacenter() { return localDc; } @Nullable - protected String getLocalRack() { + public String getLocalRack() { return localRack; } + /** + * Returns the maximum number of nodes per remote datacenter this policy will append to a query + * plan when failing over, as it was configured when the policy was built. + * + *

Read from the profile in the constructor and never re-read, so a configuration reload does + * not reach the running policy. Exposed for {@code DRIVER_CONFIG} reporting, which must describe + * the failover behavior actually in force rather than what the profile currently says. + * + *

Note this is only the first of the three conditions {@link #maybeAddDcFailover} requires: it + * also needs a local datacenter, and a request that {@link #isDcFailoverAllowedForRequest} + * admits. + */ + public int getMaxNodesPerRemoteDc() { + return maxNodesPerRemoteDc; + } + /** @return The nodes currently considered as live. */ protected NodeSet getLiveNodes() { return liveNodes; diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/loadbalancing/DefaultLoadBalancingPolicy.java b/core/src/main/java/com/datastax/oss/driver/internal/core/loadbalancing/DefaultLoadBalancingPolicy.java index ee563dbc770..035e69b797d 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/loadbalancing/DefaultLoadBalancingPolicy.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/loadbalancing/DefaultLoadBalancingPolicy.java @@ -121,6 +121,18 @@ public Optional getRequestTracker() { } } + /** + * Whether this policy reorders replicas to avoid slow ones, as it was configured when the policy + * was built. + * + *

Read from the profile in the constructor and never re-read, so a configuration reload does + * not reach the running policy. Exposed for {@code DRIVER_CONFIG} reporting, which must describe + * the ordering actually in force rather than what the profile currently says. + */ + public boolean isAvoidingSlowReplicas() { + return avoidSlowReplicas; + } + @NonNull @Override protected Optional discoverLocalDc(@NonNull Map nodes) { diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/specex/ConstantSpeculativeExecutionPolicy.java b/core/src/main/java/com/datastax/oss/driver/internal/core/specex/ConstantSpeculativeExecutionPolicy.java index 5e84f6b1002..0282ecc293b 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/specex/ConstantSpeculativeExecutionPolicy.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/specex/ConstantSpeculativeExecutionPolicy.java @@ -66,6 +66,28 @@ public ConstantSpeculativeExecutionPolicy(DriverContext context, String profileN } } + /** + * The maximum number of executions this instance was built with, including the initial, + * non-speculative one. + * + *

Read from the configuration once, at construction: a later configuration reload does not + * affect an already-running policy. Exposed so that diagnostics can describe the limit actually + * in force rather than whatever the profile currently says. + */ + public int getMaxExecutions() { + return maxExecutions; + } + + /** + * The fixed delay between executions that this instance was built with, in milliseconds; see + * {@link #getMaxExecutions()} for why it is exposed. + * + *

Already truncated to whole milliseconds, which is the precision this policy schedules with. + */ + public long getConstantDelayMillis() { + return constantDelayMillis; + } + @Override public long nextExecution( @NonNull @SuppressWarnings("unused") Node node, diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/ssl/DefaultSslEngineFactory.java b/core/src/main/java/com/datastax/oss/driver/internal/core/ssl/DefaultSslEngineFactory.java index 343d3f9e4e7..3b7edfa7265 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/ssl/DefaultSslEngineFactory.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/ssl/DefaultSslEngineFactory.java @@ -137,6 +137,20 @@ public SSLEngine newSslEngine(@NonNull EndPoint remoteEndpoint) { return engine; } + /** + * Whether {@link #newSslEngine} configures the engine to validate the server certificate against + * the node's host name, from {@code advanced.ssl-engine-factory.hostname-validation}. + * + *

A diagnostic accessor, read by the driver-configuration report sent to the server at + * connection time. Deliberately not on {@link SslEngineFactory}: an arbitrary factory can neither + * be assumed to validate host names nor be assumed not to, and a default answer on the interface + * would misdescribe a security control for every implementation that never considered the + * question. The report names the factories it recognizes and says nothing about the rest. + */ + public boolean isHostnameValidationRequired() { + return requireHostnameValidation; + } + protected SSLContext buildContext(DriverExecutionProfile config) throws Exception { if (config.isDefined(DefaultDriverOption.SSL_KEYSTORE_PATH) || config.isDefined(DefaultDriverOption.SSL_TRUSTSTORE_PATH)) { diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/ssl/JdkSslHandlerFactory.java b/core/src/main/java/com/datastax/oss/driver/internal/core/ssl/JdkSslHandlerFactory.java index 7661325005e..5dd625a70f0 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/ssl/JdkSslHandlerFactory.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/ssl/JdkSslHandlerFactory.java @@ -33,6 +33,18 @@ public JdkSslHandlerFactory(SslEngineFactory sslEngineFactory) { this.sslEngineFactory = sslEngineFactory; } + /** + * The engine factory this handler factory actually builds its engines from. + * + *

Not necessarily the one behind {@code DriverContext#getSslEngineFactory()}: a context that + * overrides {@code buildSslHandlerFactory()} may wrap an engine factory of its own choosing, in + * which case the configured one is never consulted on the connection path. Diagnostics that want + * to describe the engine in force must read it from here rather than from the context. + */ + public SslEngineFactory getSslEngineFactory() { + return sslEngineFactory; + } + @Override public SslHandler newSslHandler(Channel channel, EndPoint remoteEndpoint) { SSLEngine engine = sslEngineFactory.newSslEngine(remoteEndpoint); diff --git a/core/src/test/java/com/datastax/oss/driver/api/core/specex/ConstantSpeculativeExecutionPolicyTest.java b/core/src/test/java/com/datastax/oss/driver/api/core/specex/ConstantSpeculativeExecutionPolicyTest.java index efd804fa66e..0f02d327fe1 100644 --- a/core/src/test/java/com/datastax/oss/driver/api/core/specex/ConstantSpeculativeExecutionPolicyTest.java +++ b/core/src/test/java/com/datastax/oss/driver/api/core/specex/ConstantSpeculativeExecutionPolicyTest.java @@ -78,4 +78,16 @@ public void should_return_delay_until_max() { // Second speculative execution starts, we're at 3 => stop assertThat(policy.nextExecution(null, null, request, 3)).isNegative(); } + + @Test + public void should_expose_the_parameters_it_was_built_with() { + // Read by the configuration report, which describes the running policy rather than the profile: + // a later reload does not reach these fields, and this option is not modifiable at runtime. + mockOptions(3, 100); + ConstantSpeculativeExecutionPolicy policy = + new ConstantSpeculativeExecutionPolicy(context, DriverExecutionProfile.DEFAULT_NAME); + + assertThat(policy.getMaxExecutions()).isEqualTo(3); + assertThat(policy.getConstantDelayMillis()).isEqualTo(100); + } } From 1a572ce97bce44045adfae8b5b021a9d8d1d656e Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Tue, 11 Aug 2026 15:17:32 +0200 Subject: [PATCH 5/6] feat: report the full driver configuration, enabled by default 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 -- and turns reporting on by default, since a diagnostic nobody enables is a diagnostic nobody has during an incident. Modifications: The report is built once per session, from the default execution profile and the policies actually in force, 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. Values are read from the running components rather than from the profile wherever the two can disagree, using the accessors added earlier in this series: a policy latches its options at construction, so after a configuration reload the profile holds values the policy is not using. The same applies to host name validation, which is read from the SslEngineFactory the active SslHandlerFactory wraps rather than from the configured one, and to the effective orphaned-request limit, which is the corrected value the connection was actually built with. Where the driver cannot know an answer, the key is left out rather than guessed: an SslEngineFactory or TimestampGenerator that reports Optional.empty() produces no field at all, which is what the schema asks for. The default flips to true in reference.conf and OptionsMap, and the option's documentation is rewritten in the same edit -- it described a placeholder payload and a default of false, both of which this commit changes, so the prose and the value move together. Reporting stays best-effort and off the critical path: the report is capped at 32 KiB of UTF-8, and a report that cannot be built, or that exceeds the cap, is skipped with a warning rather than allowed to interfere with connecting. 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. The PR description catalogues each omission and the schema gaps recorded against the cross-driver document. The node preference is an approximation once a custom chainable policy sits above the one it was read from, since such a 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; that is documented on the field. Co-Authored-By: Claude Sonnet 5 Co-Authored-By: Claude Opus 5 (1M context) --- .../api/core/config/DefaultDriverOption.java | 30 +- .../driver/api/core/config/OptionsMap.java | 2 +- .../api/core/config/TypedDriverOption.java | 2 +- .../core/channel/ProtocolInitHandler.java | 4 +- .../context/DefaultDriverConfigReporter.java | 1138 ++++++- .../core/context/DriverConfigReporter.java | 5 +- .../internal/core/session/DefaultSession.java | 4 + core/src/main/resources/reference.conf | 29 +- .../DefaultDriverConfigReporterTest.java | 2670 ++++++++++++++++- .../DriverConfigReportingAssertions.java | 67 + .../config/DriverConfigReportingCcmIT.java | 76 +- .../DriverConfigReportingSimulacronIT.java | 50 +- upgrade_guide/README.md | 40 + 13 files changed, 3989 insertions(+), 128 deletions(-) create mode 100644 integration-tests/src/test/java/com/datastax/oss/driver/core/config/DriverConfigReportingAssertions.java diff --git a/core/src/main/java/com/datastax/oss/driver/api/core/config/DefaultDriverOption.java b/core/src/main/java/com/datastax/oss/driver/api/core/config/DefaultDriverOption.java index 3a6e4ed69bb..dd60a2487fb 100644 --- a/core/src/main/java/com/datastax/oss/driver/api/core/config/DefaultDriverOption.java +++ b/core/src/main/java/com/datastax/oss/driver/api/core/config/DefaultDriverOption.java @@ -1175,16 +1175,26 @@ public enum DefaultDriverOption implements DriverOption { */ ADDRESS_TRANSLATOR_RESOLVE_ADDRESSES("advanced.address-translator.resolve-addresses"), /** - * Whether the driver reports its effective configuration to ScyllaDB at connection time. - * - *

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

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

This option governs {@code DRIVER_CONFIG} only. The {@code SESSION_ID} startup option, which + * lets the server group all of a session's connections, is an innate driver behavior: it is sent + * on every connection unconditionally, whatever this is set to. So turning reporting off is not + * the same as leaving the wire unchanged. + * + *

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

Value type: boolean */ diff --git a/core/src/main/java/com/datastax/oss/driver/api/core/config/OptionsMap.java b/core/src/main/java/com/datastax/oss/driver/api/core/config/OptionsMap.java index 1d906caa985..c1a428b3524 100644 --- a/core/src/main/java/com/datastax/oss/driver/api/core/config/OptionsMap.java +++ b/core/src/main/java/com/datastax/oss/driver/api/core/config/OptionsMap.java @@ -400,7 +400,7 @@ protected static void fillWithDriverDefaults(OptionsMap map) { // values) with no sensible scalar default, analogous to how CONFIG_RELOAD_INTERVAL is omitted. map.put(TypedDriverOption.CLIENT_ROUTES_NATIVE_TRANSPORT_PORT, 9042); map.put(TypedDriverOption.CLIENT_ROUTES_SHARD_AWARENESS_ENABLED, false); - map.put(TypedDriverOption.DRIVER_CONFIG_REPORTING_ENABLED, false); + map.put(TypedDriverOption.DRIVER_CONFIG_REPORTING_ENABLED, true); } @Immutable diff --git a/core/src/main/java/com/datastax/oss/driver/api/core/config/TypedDriverOption.java b/core/src/main/java/com/datastax/oss/driver/api/core/config/TypedDriverOption.java index e412b99b404..af93e734ef1 100644 --- a/core/src/main/java/com/datastax/oss/driver/api/core/config/TypedDriverOption.java +++ b/core/src/main/java/com/datastax/oss/driver/api/core/config/TypedDriverOption.java @@ -976,7 +976,7 @@ public String toString() { new TypedDriverOption<>( DefaultDriverOption.CLIENT_ROUTES_SHARD_AWARENESS_ENABLED, GenericType.BOOLEAN); - /** Whether the driver reports its configuration to ScyllaDB at connection time. */ + /** Whether the driver reports its configuration to the cluster at connection time. */ public static final TypedDriverOption DRIVER_CONFIG_REPORTING_ENABLED = new TypedDriverOption<>( DefaultDriverOption.DRIVER_CONFIG_REPORTING_ENABLED, GenericType.BOOLEAN); diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/channel/ProtocolInitHandler.java b/core/src/main/java/com/datastax/oss/driver/internal/core/channel/ProtocolInitHandler.java index 1ca45855fed..dd7630a6530 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/channel/ProtocolInitHandler.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/channel/ProtocolInitHandler.java @@ -191,9 +191,7 @@ Message getRequest() { return request = Options.INSTANCE; case STARTUP: Map startupOptions = new HashMap<>(context.getStartupOptions()); - if (featureStore != null) { - featureStore.populateStartupOptions(startupOptions); - } + featureStore.populateStartupOptions(startupOptions); // The DRIVER_CONFIG blob describes the whole session, so only the control connection // carries it (options.reportConfig); the other connections are correlated to it by the // SESSION_ID that every connection already carries from context.getStartupOptions(). diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/context/DefaultDriverConfigReporter.java b/core/src/main/java/com/datastax/oss/driver/internal/core/context/DefaultDriverConfigReporter.java index 6ea0664b072..f48d686573c 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/context/DefaultDriverConfigReporter.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/context/DefaultDriverConfigReporter.java @@ -17,12 +17,44 @@ */ package com.datastax.oss.driver.internal.core.context; +import com.datastax.dse.driver.internal.core.loadbalancing.DseDcInferringLoadBalancingPolicy; +import com.datastax.dse.driver.internal.core.loadbalancing.DseLoadBalancingPolicy; import com.datastax.oss.driver.api.core.config.DefaultDriverOption; import com.datastax.oss.driver.api.core.config.DriverExecutionProfile; +import com.datastax.oss.driver.api.core.connection.ReconnectionPolicy; +import com.datastax.oss.driver.api.core.cql.Statement; +import com.datastax.oss.driver.api.core.loadbalancing.LoadBalancingPolicy; +import com.datastax.oss.driver.api.core.retry.RetryPolicy; +import com.datastax.oss.driver.api.core.specex.SpeculativeExecutionPolicy; +import com.datastax.oss.driver.api.core.ssl.ProgrammaticSslEngineFactory; +import com.datastax.oss.driver.api.core.ssl.SslEngineFactory; +import com.datastax.oss.driver.api.core.time.TimestampGenerator; +import com.datastax.oss.driver.internal.core.channel.ChannelFactory; +import com.datastax.oss.driver.internal.core.connection.ConstantReconnectionPolicy; +import com.datastax.oss.driver.internal.core.connection.ExponentialReconnectionPolicy; +import com.datastax.oss.driver.internal.core.loadbalancing.BasicLoadBalancingPolicy; +import com.datastax.oss.driver.internal.core.loadbalancing.DcInferringLoadBalancingPolicy; +import com.datastax.oss.driver.internal.core.loadbalancing.DefaultLoadBalancingPolicy; +import com.datastax.oss.driver.internal.core.retry.ConsistencyDowngradingRetryPolicy; +import com.datastax.oss.driver.internal.core.retry.DefaultRetryPolicy; +import com.datastax.oss.driver.internal.core.specex.ConstantSpeculativeExecutionPolicy; +import com.datastax.oss.driver.internal.core.specex.NoSpeculativeExecutionPolicy; +import com.datastax.oss.driver.internal.core.ssl.DefaultSslEngineFactory; +import com.datastax.oss.driver.internal.core.ssl.JdkSslHandlerFactory; +import com.datastax.oss.driver.internal.core.ssl.SniSslEngineFactory; +import com.datastax.oss.driver.internal.core.ssl.SslHandlerFactory; +import com.datastax.oss.driver.internal.core.time.AtomicTimestampGenerator; +import com.datastax.oss.driver.internal.core.time.ServerSideTimestampGenerator; +import com.datastax.oss.driver.internal.core.time.ThreadLocalTimestampGenerator; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import edu.umd.cs.findbugs.annotations.Nullable; +import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.Map; +import java.util.Optional; import net.jcip.annotations.ThreadSafe; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -31,8 +63,134 @@ * Default {@link DriverConfigReporter}: serializes the driver configuration to the cross-driver * {@code DRIVER_CONFIG} JSON shape and adds it to the control connection's {@code STARTUP} options. * - *

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

The rule this class is built on: report the object that is in force, not the configuration + * it was built from. The blob is (re)built on demand every time the control connection + * initializes, so a group whose option is re-read on every use reads the current (possibly + * reloaded) {@link DriverExecutionProfile} here too. But a policy or factory that captures an + * option once — into a {@code final} field in its constructor, or at {@code init()} — keeps using + * that value for the life of the session, because the context holds every one of them in a + * once-built {@code LazyReference}. For those the profile is the wrong source: after a reload it + * carries values no request executes with, and {@code advanced.speculative-execution-policy} is + * documented as not modifiable at runtime outright. So they are read off the running instance — + * {@code connection.reconnection.policy}, {@code query.speculative-execution.policy}, {@code + * query.load-balancing.policy.adaptive-ordering}, the first term of {@code + * fallback-to-non-preferred-nodes}, both {@code node-preference} groups, and {@code + * connection.tls.hostname-verification} off the active handler factory rather than the configured + * engine factory. + * + *

Anything added here owes the same question: find what consumes the option, and if it stores + * the value rather than re-reading it, expose an accessor and read that. Every field that got this + * wrong reported a plausible value that no traffic was subject to, which is the one failure mode a + * diagnostic cannot afford. + * + *

No profile read can cost more than the field it describes. {@code reference.conf} ships inside + * the driver jar and {@code OptionsMap.driverDefaults()} fills every option this class touches, so + * a missing one takes a config source that supplies neither — but the no-fallback getters throw + * when an option is absent, and one throw here used to drop the whole report behind a single + * warning. So every read is either guarded by {@link DriverExecutionProfile#isDefined} or passes an + * explicit fallback, chosen by what the schema permits: where the field or its group is + * optional, the fallback is the same "disabled" sentinel that already omits it (an + * undefined page size reads as unbounded, an undefined timeout as off), and where the field is + * required the fallback is the value {@code reference.conf} documents, since dropping it + * would make the whole document invalid. Two of the required ones cannot be reached in practice: + * {@code ChannelFactory} reads {@code max-requests-per-connection} while building the channel and + * the built-in load balancing policies resolve {@code basic.request.consistency} in their + * constructor, so a session missing either never gets far enough to report anything. + * + *

Follows the schema's omission principle throughout: a key the Java driver has no equivalent + * for is left out of the JSON entirely rather than reported as {@code null}. The same applies where + * a configured value falls outside what the schema can express but the key is optional: a + * disabled request timeout, a disabled {@code SO_LINGER}, an unbounded page size, a {@code + * basic.request.serial-consistency} outside the schema's two serial levels and the like are omitted + * rather than emitted as a value the schema rejects. Two optional booleans are omitted for a third + * reason — the answer is genuinely unknown, which is the only thing the schema lets their absence + * mean: {@code connection.tls.hostname-verification} when the SSL handler or engine factory in + * force is not one this class recognizes, and {@code query.defaults.client-timestamps} when the + * timestamp generator is not (see {@link #hostnameValidation} and {@link #clientTimestamps}). + * Guessing a boolean there would describe a security control, or a write-timestamp source, that may + * well be the opposite — which is also why neither is asked of the SPI itself: an accessor on + * {@link SslEngineFactory} or {@link TimestampGenerator} would have needed a default, and a default + * answer is exactly the guess being avoided. + * + *

A new field owes three checks, each of which this class has already got wrong once and + * each of which is cheap to run before review does it for you: + * + *

    + *
  1. Source. The rule above — is the value re-read on every use, or captured once? + *
  2. Range. Every value the option legally accepts must land inside the schema's + * constraint or take the omission route: a disabled or zero setting, a negative one, a + * sub-millisecond duration against a field counting whole milliseconds, and the option being + * undefined altogether. Note the driver's units and the schema's need not agree — {@code + * max-executions} counts the initial execution and the schema does not — so compare the two + * definitions rather than the two names, and pin each boundary with a test. + *
  3. Warrant. Do not assert a property the implementation does not guarantee. The line + * this class draws: nothing is inferred on a third party's behalf — no {@code + * dc-auto} for a policy the SPI never obliged to work one out, no guessed boolean for a + * component this class does not recognize — but what the operator explicitly + * configured is passed through even where the component in force may ignore it, + * because hiding a real setting is the worse failure. Where that line lands is a judgement + * call; say which side and why. + *
+ * + *

Known limitation: that omission is not always available. Two required fields + * are constrained slightly more tightly than the driver option behind them, so a configured value + * can in principle fall outside what the schema admits. Only the first is reachable through a live + * session: + * + *

    + *
  • {@code query.defaults.consistency} is a closed enum of the levels the schema knows, while + * {@code basic.request.consistency} is an unvalidated string. A name outside that enum fails + * the session as long as the load balancing policy is a built-in one, since those resolve it + * through the {@code ConsistencyLevelRegistry} in their constructor — so reaching this needs + * a custom registry that defines extra names, or a custom policy that never resolves the + * default (see {@link #queryDefaults}). Its optional sibling {@code + * query.defaults.serial-consistency} has the same mismatch and is not in this list, + * precisely because being optional lets it take the omission route instead. + *
  • {@code connection.requests.in-flight.max} must be strictly positive, and nothing validates + * {@code advanced.connection.max-requests-per-connection} against that: {@code + * ChannelFactory} hands the configured value straight to {@code StreamIdGenerator}, which + * does not range-check it (see {@link #requests}). No live session can report such a value + * all the same, because it is the connection that fails first: a negative setting makes + * {@code StreamIdGenerator}'s {@code BitSet} throw while the channel is still being built, + * and zero leaves no stream id for the control connection's own {@code OPTIONS} — {@code + * ChannelHandlerRequest} fails it on {@code preAcquireId} before this class is ever asked for + * a report. So the mismatch is unreachable by construction rather than a live exposure; the + * value is nonetheless reported as configured, and pinned by a test, so that the behavior is + * defined if the driver ever stops failing that early. The same setting would also drive + * {@code connection.requests.orphaned.max} negative (see {@code + * ChannelFactory#effectiveMaxOrphanRequests}), which is the second reason not to read this as + * one field's gap. + *
+ * + *

Both values are reported as-is. The reporter deliberately neither fabricates an + * admissible value — which would misreport a setting an operator may have chosen on purpose — nor + * drops the whole report over one field, so such a document is accurate but fails schema + * validation. For the consistency level, that is a cross-driver schema gap: the fix is to let the + * field express the value, the way {@code control-plane.schema.agreement.timeout-ms} already admits + * 0, the constant policy delays do, and {@code query.defaults.request} now does by being optional. + * + *

Where a duration is reported, any strictly positive value reports as at least + * 1 millisecond (see {@link #positiveMillis}), so a sub-millisecond timeout that is live at + * runtime is never reported as the 0 that means "disabled". Three fields are deliberately exempt, + * because 0 is what they really mean there: {@code connection.connect.timeout-ms} (Netty's {@code + * CONNECT_TIMEOUT_MILLIS} takes the truncated millisecond value, where 0 disables the timeout), + * {@code control-plane.queries.system.timeout.server-side-ms} (its value goes on the wire as the + * millisecond argument of a {@code USING TIMEOUT} clause, so a sub-millisecond setting really is + * {@code 0ms} server-side), and {@code query.speculative-execution.policy.delay-ms} (which is the + * policy's own already-truncated millisecond count, and whose {@code reference.conf} documents + * delays below 1 millisecond as equivalent to 0). + * + *

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

Thread safety: this class is safe to use as shipped, and holds no mutable state. Note + * that {@code buildJson()} runs on every control-connection (re)initialization, and may be called + * concurrently with a reconnect racing a fresh session start. */ @ThreadSafe public class DefaultDriverConfigReporter implements DriverConfigReporter { @@ -48,6 +206,22 @@ 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 PrimitiveCodec#writeString(String, + * Object)}, which writes a 16-bit length prefix with no bounds check (see {@code + * ByteBufPrimitiveCodec}): a value longer than 65535 bytes would silently truncate that prefix + * modulo 65536 while still appending the whole body, corrupting the frame and failing the + * handshake. Most of this report is fixed-shape, but some of it is user-supplied and unbounded — + * datacenter and rack names, consistency levels, and the class names of custom policy objects — + * so enforcing a limit here keeps "reporting must never prevent a connection from being + * established" a property of this class rather than of the user's configuration. 32KiB is + * generous for a configuration report, and is the same limit the other ScyllaDB drivers apply. + */ + static final int MAX_DRIVER_CONFIG_LENGTH = 32 * 1024; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); protected final InternalDriverContext context; @@ -62,33 +236,71 @@ public void populateControlConnectionOptions(Map startupOptions) // initialization path, so any failure here (a bad config read, a misbehaving policy while // introspecting, a serialization error) must be swallowed rather than allowed to break the // connection — which would prevent the session from establishing or reconnecting. + // + // RuntimeException only, deliberately. The one Error this class knows how to provoke is the + // InternalError that getClass().getSimpleName() throws for certain synthetic classes, and that + // is caught where it happens (see #policyName). Catching it here as well would also swallow an + // InternalError raised by anything else on this path — config access, a user policy, Jackson — + // and an InternalError is a VirtualMachineError: masking one hides a real JVM-level failure + // behind a diagnostic that was never meant to be load-bearing. try { if (!isEnabled()) { return; } String json = buildJson(); - if (json != null) { - startupOptions.put(DRIVER_CONFIG_KEY, json); + if (json == null) { + return; } + // Measured on the encoded bytes, since that is what the length prefix on the wire counts. + int length = json.getBytes(StandardCharsets.UTF_8).length; + if (length > MAX_DRIVER_CONFIG_LENGTH) { + LOG.warn( + "The driver configuration report is {} bytes long, which exceeds the {} byte limit; " + + "skipping DRIVER_CONFIG", + length, + MAX_DRIVER_CONFIG_LENGTH); + return; + } + startupOptions.put(DRIVER_CONFIG_KEY, json); } catch (RuntimeException e) { LOG.warn("Error while building the driver configuration report; skipping DRIVER_CONFIG", e); } } + // Read on every control-connection initialization rather than cached, so that a configuration + // reload takes effect on the next (re)connect. The fallback mirrors the reference.conf default, + // so that a configuration omitting the option behaves like the shipped one. private boolean isEnabled() { return context .getConfig() .getDefaultProfile() - .getBoolean(DefaultDriverOption.DRIVER_CONFIG_REPORTING_ENABLED, false); + .getBoolean(DefaultDriverOption.DRIVER_CONFIG_REPORTING_ENABLED, true); } /** * Builds the compact, single-line JSON configuration report. * - *

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

Relies on the policy/generator {@code LazyReference}s (reconnection, retry, speculative + * execution, load balancing, timestamp generator, SSL handler factory) already being resolved by + * the time this runs, which holds because {@code DefaultSession}'s init eagerly forces all of + * them — and this reporter itself — before any connection is opened, so no reference is first + * resolved from a Netty event-loop thread mid-{@code STARTUP} build. That ordering isn't this + * class's to enforce: a future change to session bootstrap that dropped one of those from the + * eager list would quietly reintroduce that. + * + *

The configured SSL engine factory is deliberately not among them: {@link #tls()} + * reads the engine factory held by the {@code JdkSslHandlerFactory} in force rather than the one + * behind {@code getSslEngineFactory()}. Those can differ — a context that overrides {@code + * buildSslHandlerFactory()} may wrap an engine factory of its own — and going through the context + * would both describe an engine nothing on the connection path uses and risk being the first + * caller to resolve it, which for the built-in factory means reading keystore/truststore files on + * a Netty event-loop thread (and failing the whole report if that throws). + * + * @return the report, or {@code null} if it could not be serialized — in which case {@code + * DRIVER_CONFIG} is skipped rather than the connection failed. */ - protected String buildJson() { + @Nullable + String buildJson() { ObjectNode root = OBJECT_MAPPER.createObjectNode(); root.put("version", SCHEMA_VERSION); populateConfig(root, context.getConfig().getDefaultProfile()); @@ -102,11 +314,911 @@ protected String buildJson() { } /** - * Populates the configuration groups onto the report root. Placeholder in Stage 1; Stage 2 fills - * in {@code connection}, {@code socket}, the policy groups, {@code query_defaults}, {@code tls}, - * etc. + * Populates the configuration groups onto the report root, from the default execution profile + * plus the context's policies. Each group follows the cross-driver schema; a key the Java driver + * has no equivalent for is omitted rather than reported as {@code null}. + */ + private void populateConfig(ObjectNode root, DriverExecutionProfile config) { + // Resolved once and shared: the load balancing policy decides both its own group and the + // node-location preferences reported under two different parents, and resolving it twice would + // mean a second SPI lookup on the Netty event-loop thread that is building STARTUP. + LoadBalancingPolicy loadBalancingPolicy = + context.getLoadBalancingPolicy(DriverExecutionProfile.DEFAULT_NAME); + NodeLocation nodeLocation = nodeLocation(config, loadBalancingPolicy); + root.set("connection", connection(config, nodeLocation)); + root.set("control-plane", controlPlane(config)); + root.set("query", query(config, loadBalancingPolicy, nodeLocation)); + } + + /** + * Everything the driver applies per connection: the socket beneath it, the CQL-level settings on + * top of it, how it is re-established, and which part of the cluster gets one at all. + */ + private ObjectNode connection( + DriverExecutionProfile config, @Nullable NodeLocation nodeLocation) { + ObjectNode n = connectionTimeouts(config); + n.set("socket", socket(config)); + ObjectNode reconnection = OBJECT_MAPPER.createObjectNode(); + reconnection.set("policy", reconnectionPolicy()); + n.set("reconnection", reconnection); + // Optional, and absent rather than false when off: presence of the group is what says TLS is + // enabled, since the schema dropped the boolean that used to carry it. + ObjectNode tls = tls(); + if (tls != null) { + n.set("tls", tls); + } + // The datacenter half only. Java's local rack never reaches computeNodeDistance() — it only + // reorders replicas at the head of a query plan (see DefaultLoadBalancingPolicy + // #shuffleLocalRackReplicasAndReplicas) — so connections are still held across the whole local + // DC, and reporting the rack here would claim a scoping the driver does not perform. The + // datacenter, on the other hand, genuinely does scope pooling: a node outside it is IGNORED, + // and an IGNORED node gets no pool. + if (nodeLocation != null) { + n.set("node-preference", nodeLocation.toDatacenterPreference()); + } + return n; + } + + private ObjectNode connectionTimeouts(DriverExecutionProfile config) { + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + ObjectNode connect = OBJECT_MAPPER.createObjectNode(); + // The enclosing "connect" object is required, its "timeout-ms" is not: DefaultNettyOptions + // hands the truncated millisecond value to Netty's CONNECT_TIMEOUT_MILLIS, where 0 means "no + // connect timeout", which the schema's positive-only field cannot express — so a disabled + // timeout is omitted rather than reported as a 0 the schema rejects. Deliberately measured on + // the emitted milliseconds, not the Duration, because Netty truncates the same way: a + // sub-millisecond connect timeout genuinely is disabled at runtime. + long connectTimeoutMs = + config + .getDuration(DefaultDriverOption.CONNECTION_CONNECT_TIMEOUT, Duration.ZERO) + .toMillis(); + if (connectTimeoutMs > 0) { + connect.put("timeout-ms", connectTimeoutMs); + } + n.set("connect", connect); + n.set("requests", requests(config)); + ObjectNode pool = OBJECT_MAPPER.createObjectNode(); + ObjectNode shardAware = OBJECT_MAPPER.createObjectNode(); + shardAware.put( + "enabled", + config.getBoolean(DefaultDriverOption.CONNECTION_ADVANCED_SHARD_AWARENESS_ENABLED, true)); + pool.set("shard-aware", shardAware); + n.set("pool", pool); + // The Java driver has no socket-level read/write timeouts, and connection.heartbeat is a + // reserved empty placeholder in this schema version (no slot for HEARTBEAT_INTERVAL/TIMEOUT + // yet) — all three are omitted entirely rather than reported as empty/null. + return n; + } + + /** Per-connection request capacity: the in-flight ceiling and the orphaned-request threshold. */ + private ObjectNode requests(DriverExecutionProfile config) { + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + int maxRequests = config.getInt(DefaultDriverOption.CONNECTION_MAX_REQUESTS, 1024); + ObjectNode inFlight = OBJECT_MAPPER.createObjectNode(); + // Reported as-is. reference.conf documents this option as "strictly positive, and less than + // 32768", but that is documentation only: ChannelFactory hands the configured value to + // StreamIdGenerator, which does not range-check it. The schema dropped the upper half of that + // bound and now only requires a positive integer, so the only value it would reject is a + // non-positive one — which no live session can reach, since the connection fails before any + // report is built (see the class javadoc). Reported rather than clamped to a limit that no + // connection was built with, so the behavior stays defined either way. + inFlight.put("max", maxRequests); + n.set("in-flight", inFlight); + ObjectNode orphaned = OBJECT_MAPPER.createObjectNode(); + // The effective threshold, not the configured one: ChannelFactory silently replaces a value + // that isn't below max-requests-per-connection with a quarter of it, so reporting the raw + // option would describe a limit no connection was built with. + orphaned.put( + "max", + ChannelFactory.effectiveMaxOrphanRequests( + maxRequests, config.getInt(DefaultDriverOption.CONNECTION_MAX_ORPHAN_REQUESTS, 256))); + n.set("orphaned", orphaned); + return n; + } + + private ObjectNode socket(DriverExecutionProfile config) { + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + n.put("tcp-no-delay", config.getBoolean(DefaultDriverOption.SOCKET_TCP_NODELAY, true)); + // keep-alive and reuse-address are unset by default; the driver leaves the socket option + // untouched, so the effective value is the JDK/OS default — false for both SO_KEEPALIVE and + // (client-socket) SO_REUSEADDR. The schema requires both keys, so they are always emitted. + n.put("keep-alive", config.getBoolean(DefaultDriverOption.SOCKET_KEEP_ALIVE, false)); + n.put("reuse-address", config.getBoolean(DefaultDriverOption.SOCKET_REUSE_ADDRESS, false)); + // A negative linger interval means SO_LINGER is disabled (reference.conf documents the + // sentinel), which the schema's non-negative interval-s cannot express — so the group is + // omitted in that case, the same way "page" is when paging is unbounded. Zero is a real + // value here (close immediately) and is reported. + if (config.isDefined(DefaultDriverOption.SOCKET_LINGER_INTERVAL)) { + int lingerInterval = config.getInt(DefaultDriverOption.SOCKET_LINGER_INTERVAL); + if (lingerInterval >= 0) { + ObjectNode linger = OBJECT_MAPPER.createObjectNode(); + linger.put("interval-s", lingerInterval); + n.set("linger", linger); + } + } + // Both buffer sizes are positive-only in the schema, and a non-positive one wouldn't survive + // Netty's own validation anyway; omit rather than emit a value the schema rejects. + if (config.isDefined(DefaultDriverOption.SOCKET_RECEIVE_BUFFER_SIZE)) { + int size = config.getInt(DefaultDriverOption.SOCKET_RECEIVE_BUFFER_SIZE); + if (size > 0) { + ObjectNode receiveBuffer = OBJECT_MAPPER.createObjectNode(); + receiveBuffer.put("size-bytes", size); + n.set("receive-buffer", receiveBuffer); + } + } + if (config.isDefined(DefaultDriverOption.SOCKET_SEND_BUFFER_SIZE)) { + int size = config.getInt(DefaultDriverOption.SOCKET_SEND_BUFFER_SIZE); + if (size > 0) { + ObjectNode sendBuffer = OBJECT_MAPPER.createObjectNode(); + sendBuffer.put("size-bytes", size); + n.set("send-buffer", sendBuffer); + } + } + return n; + } + + private ObjectNode controlPlane(DriverExecutionProfile config) { + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + + // These two are siblings under one "timeout" object but are NOT two views of the same timeout: + // client-side-ms is CONTROL_CONNECTION_TIMEOUT, which bounds topology and schema-agreement + // polling, while server-side-ms is METADATA_SCHEMA_REQUEST_TIMEOUT, which bounds schema queries + // (and is also their own client-side wait, see CassandraSchemaQueries). So no single query is + // subject to both numbers. That grouping is a characteristic of the cross-driver schema, not a + // choice made here — the mapping matches the schema's own per-driver table. + // + // Open item for the schema owner, with a concrete shape now that "system" is one key under + // "queries": a sibling would let each class of control query carry an honest pair, i.e. + // queries.system.timeout.client-side-ms <- CONTROL_CONNECTION_TIMEOUT (no server side) and + // queries.schema.timeout.{client-side-ms,server-side-ms} <- METADATA_SCHEMA_REQUEST_TIMEOUT. + // Not emitted here: "queries" is additionalProperties:false, so a "schema" sibling would fail + // validation until the spec adds it. + ObjectNode timeout = OBJECT_MAPPER.createObjectNode(); + // Both fields are optional and positive-only, and both options treat a non-positive value + // as "no timeout"; omit rather than report a 0 the schema rejects. AdminRequestHandler + // schedules this one in nanoseconds, so a sub-millisecond value is a live timeout and rounds + // up to 1 rather than being mistaken for a disabled one. + long clientSideMs = + positiveMillis( + config.getDuration(DefaultDriverOption.CONTROL_CONNECTION_TIMEOUT, Duration.ZERO)); + if (clientSideMs > 0) { + timeout.put("client-side-ms", clientSideMs); + } + // Reported as configuration intent, not as an observed effect, and therefore independent of the + // backend: CassandraSchemaQueries adds a "USING TIMEOUT ms" clause built from this value to + // every schema query, but only where sharding information says the peer is ScyllaDB — on + // genuine Cassandra the clause never goes on the wire and the option acts as a client-side wait + // alone. Gating the field on that check described the effect more precisely but made the report + // depend on peer detection for a value the driver knows before it connects; per the schema + // owner the field carries what is configured, the way pool.shard-aware.enabled already does. + // + // Deliberately not floored to 1 like the timeouts above: the value goes on the wire as a whole + // millisecond argument, so a sub-millisecond setting really is 0ms server-side. + long serverSideMs = + config + .getDuration(DefaultDriverOption.METADATA_SCHEMA_REQUEST_TIMEOUT, Duration.ZERO) + .toMillis(); + if (serverSideMs > 0) { + timeout.put("server-side-ms", serverSideMs); + } + ObjectNode system = OBJECT_MAPPER.createObjectNode(); + system.set("timeout", timeout); + ObjectNode queries = OBJECT_MAPPER.createObjectNode(); + queries.set("system", system); + n.set("queries", queries); + + ObjectNode schemaAgreement = OBJECT_MAPPER.createObjectNode(); + // Required and non-negative in the schema, where 0 specifically means "do not wait" — which + // matches SchemaAgreementChecker skipping the check entirely at 0. A negative value behaves + // identically (the first pass is already past the deadline), so normalizing it to 0 is exact + // rather than invented. The checker holds this timeout in nanoseconds, so a positive + // sub-millisecond value does wait, and must not collapse onto that "do not wait" 0. + schemaAgreement.put( + "timeout-ms", + positiveMillis( + config.getDuration( + DefaultDriverOption.CONTROL_CONNECTION_AGREEMENT_TIMEOUT, Duration.ofSeconds(10)))); + ObjectNode schema = OBJECT_MAPPER.createObjectNode(); + schema.set("agreement", schemaAgreement); + n.set("schema", schema); + + return n; + } + + /** + * Everything that governs how a statement is executed: the per-request defaults, and the three + * policies that decide where it goes, whether it is retried, and whether it is raced. + */ + private ObjectNode query( + DriverExecutionProfile config, + LoadBalancingPolicy policy, + @Nullable NodeLocation nodeLocation) { + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + n.set("defaults", queryDefaults(config)); + + ObjectNode retry = OBJECT_MAPPER.createObjectNode(); + retry.set("policy", retryPolicy()); + // "backoff" is a sibling of "policy" rather than a field on it, but no built-in Java retry + // policy inserts a delay between attempts and none takes one from configuration, so there is + // nothing to report and the optional key is omitted. + n.set("retry", retry); + + ObjectNode loadBalancing = OBJECT_MAPPER.createObjectNode(); + loadBalancing.set("policy", loadBalancingPolicy(policy, nodeLocation)); + // The full preference, rack included: unlike connection scoping, query routing is exactly what + // the rack affects. + if (nodeLocation != null) { + loadBalancing.set("node-preference", nodeLocation.toFullPreference()); + } + n.set("load-balancing", loadBalancing); + + // Optional group, and the policy inside it is required — so when there is no speculative + // execution to describe the whole group goes, rather than an empty object. + ObjectNode specExec = speculativeExecutionPolicy(); + if (specExec != null) { + ObjectNode speculativeExecution = OBJECT_MAPPER.createObjectNode(); + speculativeExecution.set("policy", specExec); + n.set("speculative-execution", speculativeExecution); + } + return n; + } + + /** + * The reconnection policy, read from the running instance rather than from the profile. + * + *

Both built-ins latch their delays into final fields at construction and never re-read them, + * so after a configuration reload the profile describes a policy that is not the one reconnecting + * — the instance is the only accurate source. It also makes the schema's {@code max-ms >= + * base-ms} invariant hold for free, since {@code ExponentialReconnectionPolicy} enforces it in + * its constructor whereas a reloaded profile could carry any pair. + */ + private ObjectNode reconnectionPolicy() { + ReconnectionPolicy policy = context.getReconnectionPolicy(); + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + // Exact-class checks, not instanceof: none of these built-ins are final, so a user subclass + // (e.g. to tweak one method) must fall through to the "custom" branch below rather than be + // misreported as the unmodified built-in. + if (policy.getClass() == ExponentialReconnectionPolicy.class) { + ExponentialReconnectionPolicy exponential = (ExponentialReconnectionPolicy) policy; + n.put("type", "exponential"); + n.put("base-ms", exponential.getBaseDelayMs()); + n.put("max-ms", exponential.getMaxDelayMs()); + // Java's built-in reconnection policies are unbounded: max-attempts is omitted. + } else if (policy.getClass() == ConstantReconnectionPolicy.class) { + n.put("type", "constant"); + // Non-negative in the schema, where 0 specifically means "reconnect immediately". + // ConstantReconnectionPolicy rejects only a negative delay, so a sub-millisecond one is legal + // — and live, since Reconnection schedules nextDelay() in nanoseconds — which is why it + // floors + // at 1 rather than truncating onto that "immediately" 0. + n.put("delay-ms", positiveMillis(((ConstantReconnectionPolicy) policy).getDelay())); + } else { + customPolicy(n, policy); + } + return n; + } + + /** + * The retry policy. Neither built-in fills the schema's optional {@code max-retries}: the key + * reports a configured retry limit, and Java has no such option. What both policies have + * instead are per-error-type rules hardcoded in Java — a single attempt for read timeouts, write + * timeouts and unavailable, but an unbounded walk down the query plan for aborted requests and + * error responses — which no single number describes. + * + *

Worth spelling out, because {@code 1} looks like the right answer and is not. {@code + * DefaultRetryPolicy} gates {@code onReadTimeout}, {@code onWriteTimeout} and {@code + * onUnavailable} on {@code retryCount == 0}, so those three really are bounded at one. The error + * paths are not: {@code CqlRequestHandler} consults {@code onErrorResponseVerdict} only when + * {@code Conversions.resolveIdempotence} says the statement is idempotent, and then never checks + * the count — so the very same policy bounds a non-idempotent request at 1 and an idempotent one + * at the length of the query plan. Idempotence is chosen per statement, which a session-level + * report cannot know, so there is no honest number to publish and the key is omitted. Settled the + * same way on the 3.x port (scylladb/java-driver#974); restoring the cap that + * scylladb/java-driver#992 tracks would make {@code 1} unconditional and this reportable. + */ + private ObjectNode retryPolicy() { + RetryPolicy policy = context.getRetryPolicy(DriverExecutionProfile.DEFAULT_NAME); + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + // Exact-class check, not instanceof: DefaultRetryPolicy/ConsistencyDowngradingRetryPolicy are + // not final, so a user subclass must fall through to "custom" rather than be misreported. + if (policy.getClass() == DefaultRetryPolicy.class) { + n.put("type", "standard-error-aware"); + // No configurable backoff and no configured retry limit: both omitted. + } else if (policy.getClass() == ConsistencyDowngradingRetryPolicy.class) { + n.put("type", "downgrading-consistency"); + // No configurable backoff and no configured retry limit: both omitted. + } else { + customPolicy(n, policy); + } + return n; + } + + /** + * Returns {@code null} when there is no speculative execution policy to report, in which case the + * whole group is omitted from the report (the schema has no null variant for it). + * + *

Both values are read off the policy instance rather than the profile, for the reason spelled + * out in {@link #reconnectionPolicy()}: the policy captured them at construction and {@code + * advanced.speculative-execution-policy} is documented as not modifiable at runtime, so a + * reloaded profile can carry numbers no request is actually executed with — and, unlike the + * policy's constructor, admits values this field's schema range rejects. + */ + private ObjectNode speculativeExecutionPolicy() { + SpeculativeExecutionPolicy policy = + context.getSpeculativeExecutionPolicy(DriverExecutionProfile.DEFAULT_NAME); + // Exact-class checks, not instanceof: neither built-in is final. + if (policy.getClass() == NoSpeculativeExecutionPolicy.class) { + return null; + } + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + if (policy.getClass() == ConstantSpeculativeExecutionPolicy.class) { + ConstantSpeculativeExecutionPolicy constant = (ConstantSpeculativeExecutionPolicy) policy; + // The policy counts the initial, non-speculative execution as one of the executions it caps + // (see SpeculativeExecutionPolicy#nextExecution's runningExecutions), while the schema field + // counts only the speculative ones — hence the -1. Its constructor rejects anything below 1, + // so the result cannot be negative; 1 means the policy never speculates, which is reported + // the way NoSpeculativeExecutionPolicy is — by omitting the group, since there is nothing to + // describe and the schema field is positive-only. + int speculativeExecutions = constant.getMaxExecutions() - 1; + if (speculativeExecutions < 1) { + return null; + } + n.put("type", "constant"); + n.put("max-executions", speculativeExecutions); + // No sub-millisecond value survives to floor here, unlike the timeouts: the policy already + // holds whole milliseconds, and reference.conf documents delays of less than 1 millisecond as + // equivalent to 0 for this option — which the schema accepts ("launch immediately"). + n.put("delay-ms", constant.getConstantDelayMillis()); + } else { + customPolicy(n, policy); + } + return n; + } + + private ObjectNode loadBalancingPolicy( + LoadBalancingPolicy policy, @Nullable NodeLocation nodeLocation) { + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + // The schema's built-in variant is a single type: every policy the driver ships routes queries + // to token replicas, so all four report "token-aware", and what distinguishes them is carried + // by the fields below (and, for the local DC, by node-location-preference). Exact-class checks + // (not instanceof) so an actual user subclass of any of them still falls through to "custom". + // + // DcInferringLoadBalancingPolicy extends DefaultLoadBalancingPolicy, overriding only how the + // local DC is discovered; DseLoadBalancingPolicy/DseDcInferringLoadBalancingPolicy are + // deprecated, behavior-identical aliases of the two ("equivalent to DefaultLoadBalancingPolicy, + // which should now be used instead" per their own javadoc). All of them extend + // BasicLoadBalancingPolicy, from which they inherit an unconditional shuffle of the replica + // head + // and the same DC-failover option; only slow-replica avoidance differs, so it is resolved per + // class below and the shared fields are written once. + Class policyClass = policy.getClass(); + boolean avoidsSlowReplicas; + if (policyClass == DefaultLoadBalancingPolicy.class + || policyClass == DcInferringLoadBalancingPolicy.class + || policyClass == DseLoadBalancingPolicy.class + || policyClass == DseDcInferringLoadBalancingPolicy.class) { + // Off the running instance, not the profile: DefaultLoadBalancingPolicy latches this flag in + // its constructor and never re-reads it, so after a configuration reload the profile can say + // one thing while the policy keeps reordering (or not) according to the other. Same reason + // the reconnection delays and the speculative-execution parameters are read off their + // policies. + avoidsSlowReplicas = ((DefaultLoadBalancingPolicy) policy).isAvoidingSlowReplicas(); + } else if (policyClass == BasicLoadBalancingPolicy.class) { + // Unlike DefaultLoadBalancingPolicy, BasicLoadBalancingPolicy has no slow-replica-avoidance + // mechanism at all. + avoidsSlowReplicas = false; + } else { + customPolicy(n, policy); + return n; + } + n.put("type", "token-aware"); + // BasicLoadBalancingPolicy#shuffleHead shuffles the replicas at the head of every query plan + // whenever there is more than one, and no built-in policy has an option to disable that — so + // "shuffle" rather than "round-robin" (which the driver applies only to the non-replica tail) + // or + // "replica-set" (which would mean leaving the replica order untouched). + n.put("load-distribution", "shuffle"); + // Both terms are necessary: BasicLoadBalancingPolicy#maybeAddDcFailover appends remote nodes to + // a query plan only when max-nodes-per-remote-dc is positive AND the policy has a local DC to + // treat as preferred. Reading the option alone reported failover as on for a config that + // changed + // nothing but that option, where no remote node is ever added — and the key's own definition is + // about leaving the preference, so with no preference there is nothing to leave. That is + // exactly + // what a null nodeLocation means here, so the predicate is reused rather than restated. + // + // Still an approximation in one respect, and deliberately: maybeAddDcFailover also consults + // isDcFailoverAllowedForRequest, which is false for a DC-local consistency while + // allow-for-local-consistency-levels is off. That is a per-request decision a statement can + // change, and re-deriving it here would duplicate policy logic in a diagnostic. + // + // The first term comes off the running instance for the same reason as adaptive ordering above: + // BasicLoadBalancingPolicy latches max-nodes-per-remote-dc in its constructor. Every class that + // reaches this point is one of the built-ins, so the cast holds. + boolean dcFailoverConfigured = ((BasicLoadBalancingPolicy) policy).getMaxNodesPerRemoteDc() > 0; + n.put("fallback-to-non-preferred-nodes", dcFailoverConfigured && nodeLocation != null); + // Optional, and its presence is what says adaptive ordering is on: the schema dropped the + // boolean that used to carry that and now requires a non-empty signal list, so "off" is the + // absent group rather than an enabled:false with nothing in it. + if (avoidsSlowReplicas) { + n.set("adaptive-ordering", adaptiveOrdering()); + } + return n; + } + + /** + * Which runtime observations reorder candidate nodes. Only called when such reordering is on — + * the caller omits the whole group otherwise. + * + *

The driver's only such mechanism is {@code DefaultLoadBalancingPolicy}'s slow-replica + * avoidance, so the signal list is fixed and describes what {@code avoidSlowReplicas} actually + * consults: a replica is demoted when it is both busy — {@code getInFlight} at or above the + * in-flight threshold — and answering too rarely, measured by {@code NodeResponseRateSample}; the + * first two replicas are then swapped on in-flight count alone; and a replica that came up within + * the last {@code NEWLY_UP_INTERVAL} is treated specially, so that a node still recovering is not + * immediately handed the front of the query plan. + * + *

{@code latency} is deliberately not among them: those samples record when responses + * arrived, not how long they took, so the driver has no latency-percentile host ordering to + * report. + */ + private ObjectNode adaptiveOrdering() { + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + ArrayNode signals = n.putArray("signals"); + signals.add("response-rate"); + signals.add("in-flight-requests"); + signals.add("recovery-state"); + return n; + } + + /** + * Resolves the session's datacenter/rack preference, or {@code null} when there is none to + * describe. + * + *

Java has no session-level locality API separate from the load balancing policy, so the + * values come from the policy itself wherever it has them: {@code getLocalDatacenter()} and + * {@code getLocalRack()} hold what it settled on, and that is what {@code computeNodeDistance} + * and the query plan go by. The policy is a once-built {@code LazyReference}, so a profile + * reloaded to a different datacenter never reaches it — reading the profile in preference would + * describe a locality no request is routed by. Before the policy is initialized, which is exactly + * the state the very first control connection sends {@code STARTUP} in, there is nothing resolved + * and the configured value stands alone: programmatically via {@link + * com.datastax.oss.driver.api.core.session.SessionBuilder#withLocalDatacenter} first, then + * config, mirroring {@code OptionalLocalDcHelper}'s own precedence; the local rack has no + * programmatic override and is config-only. + * + *

Whether anything was configured then decides only which of the schema's slots the + * reported value occupies, configured or inferred — a datacenter the policy took from an earlier + * generation of the configuration is still explicitly configured, merely stale, and reporting it + * as inferred would claim the driver worked it out unaided. + * + *

Returns {@code null} — omitting the group from both of its parents, where it is optional — + * when the session has no preference to describe: no datacenter configured, none resolved yet, + * and a policy that is not one of the built-ins {@linkplain #infersLocalDatacenter known to work + * one out}. {@code dc-auto} is a claim that a datacenter will be settled on, and neither + * {@code BasicLoadBalancingPolicy} (datacenter-agnostic for the life of the session) nor an + * arbitrary custom policy (the SPI requires no inference at all) makes that claim good. + * + *

An explicitly configured datacenter, on the other hand, is reported whatever the + * policy is — and that is an approximation for a custom one. Both parents describe an effect the + * built-ins produce: {@link #connection} claims the datacenter scopes which nodes hold a pool + * (true only because {@code BasicLoadBalancingPolicy#computeNodeDistance} makes an out-of-DC node + * {@code IGNORED}), and {@link #query} claims it scopes routing. A custom {@link + * LoadBalancingPolicy} computes distance itself and need not consult either source, so it may + * honor neither. Reported all the same, deliberately, on the grounds that hiding a setting the + * operator really did make is the worse failure mode — the same call as reporting a configured + * rack for a policy that ignores it. Note the asymmetry with the paragraph above: nothing is + * inferred on a custom policy's behalf, but what was configured is passed through. + * + *

A configured {@code basic.load-balancing-policy.evaluator.class} weakens the same claim one + * step further, even for a built-in policy: {@code BasicLoadBalancingPolicy#computeNodeDistance} + * consults the evaluator before the datacenter, so it can make an in-DC node {@code IGNORED} and + * leave it without a pool. Nothing is reported for it. The option names a user-supplied class — + * the driver ships no location-based evaluator of its own, only {@code + * NodeFilterToDistanceEvaluatorAdapter} around the deprecated {@code filter.class} — so unlike + * drivers with an introspectable built-in (gocql's {@code DataCenterHostFilter}), there is no + * datacenter to read out of it, and {@code node-location-preference} has no slot for a class + * name. + */ + @Nullable + private NodeLocation nodeLocation(DriverExecutionProfile config, LoadBalancingPolicy policy) { + String configuredDc = + blankToNull(context.getLocalDatacenter(DriverExecutionProfile.DEFAULT_NAME)); + if (configuredDc == null + && config.isDefined(DefaultDriverOption.LOAD_BALANCING_LOCAL_DATACENTER)) { + configuredDc = + blankToNull(config.getString(DefaultDriverOption.LOAD_BALANCING_LOCAL_DATACENTER)); + } + String configuredRack = + config.isDefined(DefaultDriverOption.LOAD_BALANCING_LOCAL_RACK) + ? blankToNull(config.getString(DefaultDriverOption.LOAD_BALANCING_LOCAL_RACK)) + : null; + + // instanceof here, deliberately unlike the exact-class checks used elsewhere: those decide + // which built-in we are looking at, and must not be fooled by a subclass. This one only reads a + // value the policy has already resolved, and a subclass's local DC is every bit as real as the + // base class's. Both are null until the policy is initialized — which it is not when the very + // first control connection sends STARTUP, but is on every later reconnect. + String resolvedDc = null; + String resolvedRack = null; + if (policy instanceof BasicLoadBalancingPolicy) { + BasicLoadBalancingPolicy basic = (BasicLoadBalancingPolicy) policy; + resolvedDc = blankToNull(basic.getLocalDatacenter()); + resolvedRack = blankToNull(basic.getLocalRack()); + } + + // Once the policy has resolved a value, that is the one reported: it is what + // computeNodeDistance + // and the query plan use, and the policy is a once-built LazyReference, so a profile reloaded + // to + // a different datacenter never reaches it. The configured value only decides *which slot* the + // reported one goes in — a datacenter the policy took from an earlier generation of the config + // is still explicitly configured, just stale, and not something it inferred. Before + // initialization there is nothing resolved, so the configured value stands on its own; that is + // the state the very first control connection reports from. + String localDc = resolvedDc != null ? resolvedDc : configuredDc; + String localRack = resolvedRack != null ? resolvedRack : configuredRack; + // Exactly one form of each field survives, which is what keeps the schema's "never a configured + // and an inferred form of the same field" constraint true by construction. + // + // inferredRack stays null for every built-in policy: BasicLoadBalancingPolicy#init discovers + // the rack through OptionalLocalRackHelper, which reads configuration and never infers one, and + // only once a datacenter is known — so a resolved rack always implies a configured one. It goes + // non-null only for a subclass overriding discoverLocalRack, which the instanceof check above + // deliberately does read. + String inferredDc = configuredDc == null ? localDc : null; + String inferredRack = configuredRack == null ? localRack : null; + configuredDc = configuredDc == null ? null : localDc; + configuredRack = configuredRack == null ? null : localRack; + + // No datacenter configured and none resolved: report a preference only for a policy that is + // going to arrive at one. A configured rack alone does not keep the group alive — no built-in + // looks for a rack before it knows a datacenter (BasicLoadBalancingPolicy#init), so a rack-only + // configuration is inoperative and describing it would claim a preference that never forms. + if (configuredDc == null + && inferredDc == null + && inferredRack == null + && !infersLocalDatacenter(policy)) { + return null; + } + return new NodeLocation(configuredDc, configuredRack, inferredDc, inferredRack); + } + + /** + * Whether this is one of the built-ins that works a local datacenter out for itself when none is + * configured — the only case where reporting {@code dc-auto} describes something that is actually + * going to happen. + * + *

Exact-class checks, like the policy branches elsewhere, and here for the converse reason: + * {@link LoadBalancingPolicy} nowhere requires an implementation to infer a datacenter, so + * neither a custom policy nor a subclass overriding {@code discoverLocalDc} can be assumed to. + * {@code BasicLoadBalancingPolicy} is deliberately absent — alone among the built-ins it uses + * {@code OptionalLocalDcHelper}, so it stays datacenter-agnostic for the life of the session. A + * policy outside this list that does infer one is still reported, once it has: the + * caller falls back to the datacenter it has already resolved. + */ + private static boolean infersLocalDatacenter(LoadBalancingPolicy policy) { + Class policyClass = policy.getClass(); + return policyClass == DefaultLoadBalancingPolicy.class + || policyClass == DcInferringLoadBalancingPolicy.class + || policyClass == DseLoadBalancingPolicy.class + || policyClass == DseDcInferringLoadBalancingPolicy.class; + } + + /** + * A resolved datacenter/rack preference, rendered into whichever of the schema's {@code + * node-location-preference} variants fits what is actually known. + * + *

The variants encode how the preference was arrived at, not just its value: {@code + * dc} and {@code rack} are wholly configured, {@code dc-auto} is a datacenter the policy worked + * out, and {@code rack-auto} covers every mixture ("at least one part is inferred") by carrying + * configured and inferred fields under distinct names. + * + *

Only one of those mixtures arises from a built-in policy — a configured rack alongside a + * datacenter the policy inferred — because none of them ever infers a rack (see {@link + * #nodeLocation}). The {@code inferred-local-rack} field and the branches that read it are there + * for a {@code BasicLoadBalancingPolicy} subclass that overrides {@code discoverLocalRack}, whose + * answer {@link #nodeLocation} does read. + */ + private static final class NodeLocation { + + @Nullable private final String configuredDc; + @Nullable private final String configuredRack; + @Nullable private final String inferredDc; + @Nullable private final String inferredRack; + + NodeLocation( + @Nullable String configuredDc, + @Nullable String configuredRack, + @Nullable String inferredDc, + @Nullable String inferredRack) { + this.configuredDc = configuredDc; + this.configuredRack = configuredRack; + this.inferredDc = inferredDc; + this.inferredRack = inferredRack; + } + + /** + * The datacenter half alone, for the preference that scopes which nodes are connected to at + * all. Never {@code rack}/{@code rack-auto}: the rack is not part of that scoping. + */ + ObjectNode toDatacenterPreference() { + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + if (configuredDc != null) { + n.put("type", "dc"); + n.put("local-dc", configuredDc); + } else { + // dc-auto carries the inferred datacenter in plain "local-dc" — unlike rack-auto, which + // gives it an "inferred-" prefix. That asymmetry is the schema's, not ours. + n.put("type", "dc-auto"); + if (inferredDc != null) { + n.put("local-dc", inferredDc); + } + } + return n; + } + + /** The whole preference, rack included, for the parent that governs query routing. */ + ObjectNode toFullPreference() { + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + if (configuredDc != null && configuredRack != null) { + n.put("type", "rack"); + n.put("local-dc", configuredDc); + n.put("local-rack", configuredRack); + } else if (configuredDc != null && inferredRack == null) { + n.put("type", "dc"); + n.put("local-dc", configuredDc); + } else if (configuredDc == null && configuredRack == null && inferredRack == null) { + n.put("type", "dc-auto"); + if (inferredDc != null) { + n.put("local-dc", inferredDc); + } + } else { + // Everything else is a mixture of configured and inferred parts. The schema forbids pairing + // a configured field with the inferred form of the same field, and forbids carrying an + // explicit DC together with an explicit rack (that is the "rack" variant above) — the + // resolver has already made both impossible. + n.put("type", "rack-auto"); + if (configuredDc != null) { + n.put("local-dc", configuredDc); + } + if (configuredRack != null) { + n.put("local-rack", configuredRack); + } + if (inferredDc != null) { + n.put("inferred-local-dc", inferredDc); + } + if (inferredRack != null) { + n.put("inferred-local-rack", inferredRack); + } + } + return n; + } + } + + private ObjectNode queryDefaults(DriverExecutionProfile config) { + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + int pageSize = config.getInt(DefaultDriverOption.REQUEST_PAGE_SIZE, 0); + if (pageSize > 0) { + ObjectNode page = OBJECT_MAPPER.createObjectNode(); + page.put("size", pageSize); + n.set("page", page); + } + // pageSize <= 0 means paging is unbounded: the "page" group is omitted entirely (the schema + // has no "unbounded" sentinel). + // Passed through verbatim: this field is required, so there is no omission route if the + // configured name is outside the schema's enum. The enum now covers the serial levels as well, + // and the built-in load balancing policies reject anything the ConsistencyLevelRegistry does + // not + // know, so what is left needs a custom registry — see the class javadoc. + n.put("consistency", config.getString(DefaultDriverOption.REQUEST_CONSISTENCY, "LOCAL_ONE")); + // Both consistency options are unvalidated strings, and both schema fields are closed enums — + // but this one is optional, so it takes the omission route the required "consistency" above + // cannot. Nothing checks basic.request.serial-consistency before the first conditional + // statement runs (Conversions#resolveSerialConsistency), so a session can be started with a + // value the schema has no member for; reporting it by omission keeps the rest of the document + // valid, the same way a disabled request timeout does below. + if (config.isDefined(DefaultDriverOption.REQUEST_SERIAL_CONSISTENCY)) { + String serialConsistency = config.getString(DefaultDriverOption.REQUEST_SERIAL_CONSISTENCY); + if ("SERIAL".equals(serialConsistency) || "LOCAL_SERIAL".equals(serialConsistency)) { + n.put("serial-consistency", serialConsistency); + } + } + n.put("idempotence", config.getBoolean(DefaultDriverOption.REQUEST_DEFAULT_IDEMPOTENCE, false)); + clientTimestamps(context.getTimestampGenerator()) + .ifPresent(clientSide -> n.put("client-timestamps", clientSide)); + // 0 legitimately disables the request timeout, and the schema's field is positive-only — but + // both the field and its enclosing object are optional now, so a disabled timeout is reported + // by omitting the whole "request" group rather than by emitting a 0 the schema rejects. A + // positive sub-millisecond timeout, on the other hand, is live (CqlRequestHandler schedules it + // in nanoseconds) and so must not collapse onto that same 0. + long requestTimeoutMs = + positiveMillis(config.getDuration(DefaultDriverOption.REQUEST_TIMEOUT, Duration.ZERO)); + if (requestTimeoutMs > 0) { + ObjectNode request = OBJECT_MAPPER.createObjectNode(); + request.put("timeout-ms", requestTimeoutMs); + n.set("request", request); + } + return n; + } + + /** + * Whether the timestamp generator in force assigns the write timestamp client-side, or {@link + * Optional#empty()} when it is not one this class recognizes. + * + *

Read by naming the driver's own generators rather than through an accessor on {@link + * TimestampGenerator}, deliberately: an implementation is free to return {@link + * Statement#NO_DEFAULT_TIMESTAMP} from {@code next()} and leave the timestamp to the coordinator, + * which nothing short of calling {@code next()} — and thereby consuming a timestamp — could + * detect. The interface therefore cannot answer for an arbitrary implementation, and a default + * answer on it would have named the wrong source for every write a session that never considered + * the question makes. Unknown is instead said by omission, which is what the schema's optional + * field is for. + * + *

The two client-side branches are the complete set: {@code MonotonicTimestampGenerator}, + * which both of them extend, is package-private, so nothing outside its own package can inherit + * its behavior without going through one of these two. + * + *

{@code instanceof}, not the exact-class checks the policy branches use, for the same reason + * as in {@link #hostnameValidation}: this reads a property the generator has rather than deciding + * which built-in is in force, and a subclass inherits the {@code next()} that supplies it. + */ + private static Optional clientTimestamps(TimestampGenerator generator) { + if (generator instanceof AtomicTimestampGenerator + || generator instanceof ThreadLocalTimestampGenerator) { + return Optional.of(true); + } else if (generator instanceof ServerSideTimestampGenerator) { + return Optional.of(false); + } + return Optional.empty(); + } + + /** + * TLS settings, or {@code null} when TLS is off — the schema dropped the {@code enabled} boolean, + * so presence of the group is what reports that it is on. + */ + @Nullable + private ObjectNode tls() { + // TLS is on exactly when the channel pipeline gets an SSL handler, which ChannelFactory decides + // from the low-level SslHandlerFactory. Deliberately not getSslEngineFactory(): that is only + // the public JDK-based path that DefaultDriverContext.buildSslHandlerFactory() wraps, and an + // override of that method (the documented expert extension point, e.g. Netty's native OpenSSL) + // supplies a handler factory with no engine factory at all — a session that is encrypted all + // the same. + Optional handlerFactory = context.getSslHandlerFactory(); + if (!handlerFactory.isPresent()) { + return null; + } + ObjectNode n = OBJECT_MAPPER.createObjectNode(); + // Host name validation, on the other hand, is a property of the JDK SSLEngine that the engine + // factory configures, so it can only be read on the JDK path — when the handler factory in + // force is the JdkSslHandlerFactory that buildSslHandlerFactory() wraps an engine factory in — + // and read off that handler rather than through the context (see #buildJson for why the two can + // disagree, and why resolving the context's is worse). Anything else (a native-OpenSSL handler, + // a bespoke one) leaves it unknown, and the schema's field is optional precisely so that + // unknown can be said by omission: reporting false would claim a session is not checking host + // names when it may well be. Exact-class check, like the policy branches above: + // JdkSslHandlerFactory is not final, and a subclass need not use the engine it was given. + // + // Note this is the factory's own state, not the SSL_HOSTNAME_VALIDATION config option: that + // option only governs the built-in DefaultSslEngineFactory. A factory supplied via + // SessionBuilder.withSslContext(...) (ProgrammaticSslEngineFactory) validates only if + // explicitly asked to (default off) regardless of that option, so reading the option here would + // falsely report validation as on when it isn't. + SslHandlerFactory factory = handlerFactory.get(); + if (factory.getClass() == JdkSslHandlerFactory.class) { + SslEngineFactory engineFactory = ((JdkSslHandlerFactory) factory).getSslEngineFactory(); + hostnameValidation(engineFactory).ifPresent(v -> n.put("hostname-verification", v)); + } + return n; + } + + /** + * Whether the engine factory in force validates host names, or {@link Optional#empty()} when it + * is not one this class recognizes. + * + *

Read by naming the driver's own factories rather than through an accessor on {@link + * SslEngineFactory}, deliberately: the interface obliges nobody to answer, so a default answer + * there would have described a security control on behalf of every implementation that never + * considered the question — including the ones that misdescribe it. Unknown is instead said by + * omission, which is what the schema's optional field is for. + * + *

{@code instanceof}, not the exact-class checks the policy branches use, and for the same + * reason as {@link #nodeLocation}: those decide which built-in is in force and must not + * be fooled by a subclass, whereas this one reads a value the factory already holds, and a + * subclass inherits it along with the {@code newSslEngine} that acts on it. A subclass that + * overrides {@code newSslEngine} to configure the engine differently — the only way to break that + * — reports its parent's answer; extending one of these factories is documented as a way to reuse + * it, not to invert it. + */ + private static Optional hostnameValidation(@Nullable SslEngineFactory engineFactory) { + if (engineFactory instanceof DefaultSslEngineFactory) { + return Optional.of(((DefaultSslEngineFactory) engineFactory).isHostnameValidationRequired()); + } else if (engineFactory instanceof ProgrammaticSslEngineFactory) { + return Optional.of( + ((ProgrammaticSslEngineFactory) engineFactory).isHostnameValidationRequired()); + } else if (engineFactory instanceof SniSslEngineFactory) { + // No accessor to read: SniSslEngineFactory sets the "HTTPS" endpoint identification algorithm + // on every engine it builds, unconditionally. + return Optional.of(true); + } + return Optional.empty(); + } + + /** + * A duration in milliseconds, floored at 1 for any strictly positive duration, and 0 for a zero + * or negative one. + * + *

The schema measures every duration in whole milliseconds, but the driver holds these options + * as {@link java.time.Duration} and schedules several of them in nanoseconds — so truncating + * would turn a live sub-millisecond timeout into the 0 that these fields define as "disabled" + * (or, for {@code schema-agreement.timeout-ms}, "do not wait"). Flooring at 1 keeps the reported + * value inside the schema's positive range and on the right side of that distinction; it costs at + * most one millisecond of precision on a setting the schema could not have expressed exactly + * anyway. + */ + private static long positiveMillis(Duration duration) { + return duration.isZero() || duration.isNegative() ? 0L : Math.max(1L, duration.toMillis()); + } + + /** + * The string as configured, or {@code null} if it is blank. + * + *

Deliberately not trimmed. {@code OptionalLocalDcHelper} and {@code OptionalLocalRackHelper} + * hand the configured string to the policy verbatim and match it against a node's datacenter with + * {@code Objects.equals}, so a padded {@code " dc1 "} is a datacenter that matches no node — and + * reporting it as {@code dc1} would hide exactly the typo an operator is reading this report to + * find. The schema's {@code nonEmptyString} only requires a length of at least 1, so the padded + * form is valid to emit as is. + * + *

Blank is the one case that cannot be passed through: {@code nonEmptyString} leaves no way to + * report {@code ""}, and a {@code type: "dc"} preference with the key omitted is invalid too. So + * a blank value is reported as no preference at all, which diverges from the helpers — they treat + * it as a set-but-unmatchable datacenter — but is the only schema-valid reading available. + */ + @Nullable + private static String blankToNull(@Nullable String s) { + return s == null || s.trim().isEmpty() ? null : s; + } + + private void customPolicy(ObjectNode node, Object policy) { + node.put("type", "custom"); + node.put("name", policyName(policy.getClass())); + } + + /** + * A name for a user-supplied policy class, never empty — the schema requires a non-empty string. + * + *

{@link #simpleName} is empty for an anonymous class (a common way to supply a one-off + * policy), and throws {@link InternalError} for certain synthetic class names. Both fall back to + * the full (binary) name, which is always available and still identifies the policy. + * + *

That {@code InternalError} is the one {@code Error} the reporter knows how to provoke, which + * is why the catch lives here rather than around the whole report build (see {@link + * #populateControlConnectionOptions}); it is deliberately {@code InternalError} rather than + * {@code Error}, so an {@code OutOfMemoryError} or {@code StackOverflowError} still propagates. + */ + private String policyName(Class policyClass) { + String name; + try { + name = simpleName(policyClass); + } catch (InternalError e) { + LOG.debug("Could not read a policy class's simple name; falling back to its binary name", e); + name = ""; + } + return name.isEmpty() ? policyClass.getName() : name; + } + + /** + * {@link Class#getSimpleName()}, which has a documented JDK edge case throwing {@link + * InternalError} for certain synthetic class names — reachable here because the class is + * arbitrary and user-supplied. + * + *

Package-private, and its own method, purely so that the test in this package can override it + * to raise that error: no class an ordinary test can declare provokes it. Same seam as {@link + * #buildJson}. */ - protected void populateConfig(ObjectNode root, DriverExecutionProfile config) { - // Stage 2: populate configuration groups from `config` and the context's policies. + String simpleName(Class policyClass) { + return policyClass.getSimpleName(); } } diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/context/DriverConfigReporter.java b/core/src/main/java/com/datastax/oss/driver/internal/core/context/DriverConfigReporter.java index b2793257204..bbabe2c8b3f 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/context/DriverConfigReporter.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/context/DriverConfigReporter.java @@ -29,7 +29,7 @@ * SESSION_ID} startup option, which the driver sends on every connection unconditionally and * independently of this reporter. * - *

Governed by {@code advanced.driver-config-reporting.enabled}. + *

Governed by {@code advanced.driver-config-reporting.enabled} (enabled by default). */ public interface DriverConfigReporter { @@ -42,6 +42,9 @@ public interface DriverConfigReporter { *

Implementations must not throw: this runs on the connection initialization path, so a * failure to build the report must be swallowed (and logged) rather than propagated, otherwise it * would prevent the session from establishing or reconnecting. + * + *

The report describes the driver's own configuration only, so nothing here depends on which + * backend answered: it can be built before the connection learns anything about its peer. */ void populateControlConnectionOptions(Map startupOptions); } diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/session/DefaultSession.java b/core/src/main/java/com/datastax/oss/driver/internal/core/session/DefaultSession.java index c9fee86f2c1..4e38007753a 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/session/DefaultSession.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/session/DefaultSession.java @@ -373,6 +373,10 @@ private void init(CqlIdentifier keyspace) { context.getAuthProvider(); context.getSslHandlerFactory(); context.getTimestampGenerator(); + // Also resolved here, and not only for the reason above: it is used from the connection + // initialization path, so leaving it lazy would make a Netty event loop the first thread to + // load its (and Jackson's) classes, mid-Startup. + context.getDriverConfigReporter(); } catch (Throwable error) { RunOrSchedule.on(adminExecutor, this::closePolicies); context diff --git a/core/src/main/resources/reference.conf b/core/src/main/resources/reference.conf index 8a3a444319e..590784b70c5 100644 --- a/core/src/main/resources/reference.conf +++ b/core/src/main/resources/reference.conf @@ -1206,21 +1206,30 @@ datastax-java-driver { advanced.driver-config-reporting { - # Whether the driver reports its effective configuration to ScyllaDB at connection time. + # Whether the driver reports its effective configuration to the cluster at connection time. # - # When true, the driver adds two entries to the CQL STARTUP options, which ScyllaDB stores in - # system.clients.client_options so operators can inspect driver settings while investigating - # incidents: a SESSION_ID on every connection (so the server can group a session's connections) - # and a compact JSON payload under the DRIVER_CONFIG key on the control connection only. At this - # stage the DRIVER_CONFIG payload carries only schema-version metadata ({"version":1}); reporting - # of the effective configuration fields is planned for a later stage. When false, neither entry - # is sent and there is no change on the wire. + # When true, the control connection adds a compact JSON payload under the DRIVER_CONFIG key to + # its CQL STARTUP options, which the server stores in its client-connection system table + # (system.clients on ScyllaDB, system_views.clients on Cassandra 4.1+) so operators can inspect + # driver settings while investigating incidents. It describes the effective configuration of the + # driver's default execution profile (connection/socket settings, timeouts, + # retry/reconnection/speculative-execution/load-balancing policies, connection pooling, query + # defaults, and TLS). Only the control connection sends it, since it describes the whole + # session. When false, DRIVER_CONFIG is not sent. + # + # This option governs DRIVER_CONFIG only. The SESSION_ID startup option, which lets the server + # group all of a session's connections, is an innate driver behavior: it is sent on every + # connection unconditionally, whatever this is set to. So turning reporting off is not the same + # as leaving the wire unchanged. + # + # Reporting is best-effort: if the report cannot be built, or would exceed 32 KiB, it is skipped + # (with a warning) rather than allowed to interfere with connecting. # # Required: no # Modifiable at runtime: yes, the new value will be used for connections initialized after the change. # Overridable in a profile: no - # Default: false - enabled = false + # Default: true + enabled = true } diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/context/DefaultDriverConfigReporterTest.java b/core/src/test/java/com/datastax/oss/driver/internal/core/context/DefaultDriverConfigReporterTest.java index 66da31a1c5f..91dc75d7487 100644 --- a/core/src/test/java/com/datastax/oss/driver/internal/core/context/DefaultDriverConfigReporterTest.java +++ b/core/src/test/java/com/datastax/oss/driver/internal/core/context/DefaultDriverConfigReporterTest.java @@ -21,39 +21,133 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import com.datastax.dse.driver.internal.core.loadbalancing.DseDcInferringLoadBalancingPolicy; +import com.datastax.dse.driver.internal.core.loadbalancing.DseLoadBalancingPolicy; +import com.datastax.oss.driver.api.core.CqlIdentifier; import com.datastax.oss.driver.api.core.config.DefaultDriverOption; import com.datastax.oss.driver.api.core.config.DriverConfig; +import com.datastax.oss.driver.api.core.config.DriverConfigLoader; import com.datastax.oss.driver.api.core.config.DriverExecutionProfile; +import com.datastax.oss.driver.api.core.config.OptionsMap; +import com.datastax.oss.driver.api.core.config.TypedDriverOption; +import com.datastax.oss.driver.api.core.connection.ReconnectionPolicy; +import com.datastax.oss.driver.api.core.context.DriverContext; +import com.datastax.oss.driver.api.core.loadbalancing.LoadBalancingPolicy; +import com.datastax.oss.driver.api.core.metadata.Node; +import com.datastax.oss.driver.api.core.retry.RetryPolicy; +import com.datastax.oss.driver.api.core.session.Request; +import com.datastax.oss.driver.api.core.specex.SpeculativeExecutionPolicy; +import com.datastax.oss.driver.api.core.ssl.ProgrammaticSslEngineFactory; +import com.datastax.oss.driver.api.core.ssl.SslEngineFactory; +import com.datastax.oss.driver.api.core.time.TimestampGenerator; +import com.datastax.oss.driver.internal.core.connection.ConstantReconnectionPolicy; +import com.datastax.oss.driver.internal.core.connection.ExponentialReconnectionPolicy; +import com.datastax.oss.driver.internal.core.loadbalancing.BasicLoadBalancingPolicy; +import com.datastax.oss.driver.internal.core.loadbalancing.DcInferringLoadBalancingPolicy; +import com.datastax.oss.driver.internal.core.loadbalancing.DefaultLoadBalancingPolicy; +import com.datastax.oss.driver.internal.core.retry.ConsistencyDowngradingRetryPolicy; +import com.datastax.oss.driver.internal.core.retry.DefaultRetryPolicy; +import com.datastax.oss.driver.internal.core.specex.ConstantSpeculativeExecutionPolicy; +import com.datastax.oss.driver.internal.core.specex.NoSpeculativeExecutionPolicy; +import com.datastax.oss.driver.internal.core.ssl.DefaultSslEngineFactory; +import com.datastax.oss.driver.internal.core.ssl.JdkSslHandlerFactory; +import com.datastax.oss.driver.internal.core.ssl.SniSslEngineFactory; +import com.datastax.oss.driver.internal.core.ssl.SslHandlerFactory; +import com.datastax.oss.driver.internal.core.time.AtomicTimestampGenerator; +import com.datastax.oss.driver.internal.core.time.ServerSideTimestampGenerator; +import com.datastax.oss.driver.internal.core.time.ThreadLocalTimestampGenerator; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion; +import com.networknt.schema.ValidationMessage; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.HashMap; import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Supplier; +import javax.net.ssl.SSLContext; import org.junit.Before; import org.junit.Test; -// SESSION_ID is not this class's concern: it is an innate startup option built by -// StartupOptionsBuilder and sent on every connection regardless of these settings, so it is covered -// by StartupOptionsBuilderTest instead. +// Many tests below use mock(SomeBuiltinPolicy.class) and assert the reporter recognizes it as that +// exact built-in (not "custom"). This relies on Mockito 5's default inline mock maker returning an +// object whose getClass() is the literal mocked class rather than a generated subclass (verified +// empirically for this project's Mockito version); a return to subclass-based mocking would make +// every exact-class branch under test here fall through to "custom" instead. public class DefaultDriverConfigReporterTest { - private InternalDriverContext context; - private DriverExecutionProfile profile; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + // The normative v1 JSON Schema from the design doc, shipped verbatim as a test resource. Loaded + // once and pinned to draft 2020-12 (its declared $schema); its internal "#/$defs/..." refs + // resolve locally, so validation needs no network access. + private static final JsonSchema SCHEMA = loadSchema(); + + private static JsonSchema loadSchema() { + try (InputStream in = + DefaultDriverConfigReporterTest.class.getResourceAsStream( + "/config/driver-config-report-v1.schema.json")) { + return JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012) + .getSchema(MAPPER.readTree(in)); + } catch (Exception e) { + throw new AssertionError("Cannot load the DRIVER_CONFIG v1 JSON Schema resource", e); + } + } + + // ---- Fixtures for the gating / fail-safe tests (bare mock profile) ---- + private InternalDriverContext mockContext; + private DriverExecutionProfile mockProfile; private DefaultDriverConfigReporter reporter; @Before public void setup() { - context = mock(InternalDriverContext.class); + mockContext = mock(InternalDriverContext.class); DriverConfig config = mock(DriverConfig.class); - profile = mock(DriverExecutionProfile.class); - when(context.getConfig()).thenReturn(config); - when(config.getDefaultProfile()).thenReturn(profile); - reporter = new DefaultDriverConfigReporter(context); + mockProfile = mock(DriverExecutionProfile.class); + when(mockContext.getConfig()).thenReturn(config); + when(config.getDefaultProfile()).thenReturn(mockProfile); + reporter = new DefaultDriverConfigReporter(mockContext); } private void enableReporting(boolean enabled) { - when(profile.getBoolean(DefaultDriverOption.DRIVER_CONFIG_REPORTING_ENABLED, false)) + when(mockProfile.getBoolean(DefaultDriverOption.DRIVER_CONFIG_REPORTING_ENABLED, true)) .thenReturn(enabled); } + /** A reporter over the bare mock context whose report is fixed (or fails) as given. */ + private DefaultDriverConfigReporter reporterReporting(Supplier json) { + return new DefaultDriverConfigReporter(mockContext) { + @Override + String buildJson() { + return json.get(); + } + }; + } + + // ==================== Gating ==================== + // + // Note that SESSION_ID is not this class's concern: it is an innate startup option built by + // StartupOptionsBuilder and sent on every connection regardless of these settings. + + @Test + public void should_add_driver_config_when_enabled() { + enableReporting(true); + Map options = new HashMap<>(); + reporterReporting(() -> "{\"version\":1}").populateControlConnectionOptions(options); + assertThat(options) + .hasSize(1) + .containsEntry(DefaultDriverConfigReporter.DRIVER_CONFIG_KEY, "{\"version\":1}"); + } + @Test public void should_add_nothing_when_disabled() { enableReporting(false); @@ -63,44 +157,2556 @@ public void should_add_nothing_when_disabled() { } @Test - public void should_add_driver_config_when_enabled() { - enableReporting(true); + public void should_add_driver_config_when_the_option_is_not_defined() { + // A configuration that omits the option altogether must behave like the shipped default, which + // is enabled. Uses a real (map-based) profile: a mock would return false for any unstubbed + // getBoolean(), ignoring the fallback that is under test here. Map options = new HashMap<>(); - reporter.populateControlConnectionOptions(options); - // Stage 1 emits only the schema version; the value must be valid compact JSON. - assertThat(options) - .hasSize(1) - .containsEntry( - DefaultDriverConfigReporter.DRIVER_CONFIG_KEY, - "{\"version\":" + DefaultDriverConfigReporter.SCHEMA_VERSION + "}"); + defaultsReporter(map -> map.remove(TypedDriverOption.DRIVER_CONFIG_REPORTING_ENABLED)) + .populateControlConnectionOptions(options); + assertThat(options).containsKey(DefaultDriverConfigReporter.DRIVER_CONFIG_KEY); } - /** Reporting must never break the connection: a failed config read is swallowed entirely. */ + // ==================== Fail-safe ==================== + @Test public void should_not_throw_when_reading_the_flag_fails() { - when(profile.getBoolean(DefaultDriverOption.DRIVER_CONFIG_REPORTING_ENABLED, false)) + when(mockProfile.getBoolean(DefaultDriverOption.DRIVER_CONFIG_REPORTING_ENABLED, true)) .thenThrow(new IllegalStateException("config blew up")); Map options = new HashMap<>(); reporter.populateControlConnectionOptions(options); // must not throw assertThat(options).isEmpty(); } - /** - * Reporting must never break the connection: a failure while building the config groups (as a - * Stage 2 policy introspection might) is swallowed, and DRIVER_CONFIG is simply omitted. - */ @Test - public void should_skip_driver_config_when_building_config_groups_fails() { + public void should_skip_driver_config_when_building_fails() { + enableReporting(true); + Map options = new HashMap<>(); + reporterReporting( + () -> { + throw new IllegalStateException("introspection blew up"); + }) + .populateControlConnectionOptions(options); // must not throw + assertThat(options).isEmpty(); + } + + @Test + public void should_skip_driver_config_when_serialization_fails() { + // buildJson() returns null when Jackson fails to serialize the node tree. enableReporting(true); - DefaultDriverConfigReporter throwingReporter = - new DefaultDriverConfigReporter(context) { + Map options = new HashMap<>(); + reporterReporting(() -> null).populateControlConnectionOptions(options); + assertThat(options).isEmpty(); + } + + @Test + public void should_fall_back_to_the_binary_name_when_getSimpleName_throws_an_error() + throws Exception { + // customPolicy() reads getSimpleName() off arbitrary user-supplied policy objects, which has a + // documented JDK edge case throwing InternalError for certain synthetic classes. That is caught + // where it happens rather than by the top-level catch, so the report is still produced -- with + // the binary name, which is always available. No class a test can declare provokes the error, + // so it is raised from the seam the production code guards. + LoadBalancingPolicy custom = mock(LoadBalancingPolicy.class); // not a built-in + DefaultDriverConfigReporter r = + new DefaultDriverConfigReporter( + contextWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + custom, + clientSideGenerator(), + Optional.empty(), + Optional.empty(), + null)) { @Override - protected void populateConfig(ObjectNode root, DriverExecutionProfile config) { - throw new IllegalStateException("policy introspection blew up"); + String simpleName(Class policyClass) { + throw new InternalError("simulated getSimpleName() JDK edge case"); } }; + JsonNode lb = report(r).get("query").get("load-balancing").get("policy"); + assertThat(lb.get("type").asText()).isEqualTo("custom"); + assertThat(lb.get("name").asText()).isEqualTo(custom.getClass().getName()); + } + + @Test + public void should_name_an_anonymous_policy_by_its_binary_name() throws Exception { + // getSimpleName() is empty for an anonymous class -- a common way to supply a one-off policy -- + // and the schema's name is a nonEmptyString, so the binary name is used instead. + SpeculativeExecutionPolicy anonymous = + new SpeculativeExecutionPolicy() { + @Override + public long nextExecution( + @NonNull Node node, + @Nullable CqlIdentifier keyspace, + @NonNull Request request, + int runningExecutions) { + return -1; + } + + @Override + public void close() {} + }; + assertThat(anonymous.getClass().getSimpleName()).isEmpty(); + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + anonymous, + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()); + JsonNode specExec = report(r).get("query").get("speculative-execution").get("policy"); + assertThat(specExec.get("type").asText()).isEqualTo("custom"); + assertThat(specExec.get("name").asText()).isEqualTo(anonymous.getClass().getName()); + } + + @Test + public void should_skip_driver_config_when_it_exceeds_the_size_limit() { + // STARTUP option values are written with an unchecked 16-bit length prefix, so an oversized + // report would corrupt the frame and fail the handshake rather than merely be useless. Parts of + // the report come from unbounded user-supplied values (DC/rack names, consistency levels, + // custom policy class names), so the limit has to be enforced here. + enableReporting(true); + Map options = new HashMap<>(); + reporterReporting(() -> oversizedReport()) + .populateControlConnectionOptions(options); // must not throw + assertThat(options).isEmpty(); + } + + @Test + public void should_add_driver_config_that_is_just_within_the_size_limit() { + enableReporting(true); Map options = new HashMap<>(); - throwingReporter.populateControlConnectionOptions(options); // must not throw + String atLimit = padTo(DefaultDriverConfigReporter.MAX_DRIVER_CONFIG_LENGTH); + reporterReporting(() -> atLimit).populateControlConnectionOptions(options); + assertThat(options).containsEntry(DefaultDriverConfigReporter.DRIVER_CONFIG_KEY, atLimit); + } + + @Test + public void should_skip_a_report_a_configuration_pushes_over_the_size_limit() throws Exception { + // The two tests above stub buildJson(), so neither shows that a report can reach the limit at + // all. This one drives the real serializer from a setting a user can make: the datacenter name + // is one of the unbounded, user-supplied values the limit exists for, and it is emitted under + // both node-preference parents, so half the limit in characters is enough to exceed it. + DefaultDriverConfigReporter reporter = + defaultsReporter( + map -> + map.put( + TypedDriverOption.LOAD_BALANCING_LOCAL_DATACENTER, + repeat('d', DefaultDriverConfigReporter.MAX_DRIVER_CONFIG_LENGTH / 2 + 1))); + + // Built, well-formed and over the limit: it is dropped for its size, not because building it + // failed. Reporting is left at the shipped default here, since defaultsReporter() reads a real + // profile rather than the bare mock the tests above use. + String json = reporter.buildJson(); + assertConformsToSchema(MAPPER.readTree(json)); + assertThat(json.getBytes(StandardCharsets.UTF_8).length) + .isGreaterThan(DefaultDriverConfigReporter.MAX_DRIVER_CONFIG_LENGTH); + + Map options = new HashMap<>(); + reporter.populateControlConnectionOptions(options); assertThat(options).isEmpty(); } + + /** {@code String.repeat}, which core cannot use: it compiles against Java 8. */ + private static String repeat(char c, int count) { + StringBuilder sb = new StringBuilder(count); + for (int i = 0; i < count; i++) { + sb.append(c); + } + return sb.toString(); + } + + /** + * A report that is within the limit by {@link String#length()} but over it once encoded, so that + * the check is pinned to UTF-8 bytes rather than characters. + */ + private static String oversizedReport() { + StringBuilder sb = new StringBuilder("{\"version\":1,\"pad\":\""); + // 3 bytes each in UTF-8, so two thirds of the limit in characters is over it in bytes. + for (int i = 0; i < (DefaultDriverConfigReporter.MAX_DRIVER_CONFIG_LENGTH * 2) / 3; i++) { + sb.append('€'); + } + String report = sb.append("\"}").toString(); + assertThat(report.length()).isLessThan(DefaultDriverConfigReporter.MAX_DRIVER_CONFIG_LENGTH); + assertThat(report.getBytes(StandardCharsets.UTF_8).length) + .isGreaterThan(DefaultDriverConfigReporter.MAX_DRIVER_CONFIG_LENGTH); + return report; + } + + /** A single-byte-per-character report of exactly {@code length} bytes. */ + private static String padTo(int length) { + String prefix = "{\"version\":1,\"pad\":\""; + String suffix = "\"}"; + StringBuilder sb = new StringBuilder(prefix); + for (int i = prefix.length() + suffix.length(); i < length; i++) { + sb.append('x'); + } + String report = sb.append(suffix).toString(); + assertThat(report.getBytes(StandardCharsets.UTF_8).length).isEqualTo(length); + return report; + } + + // ==================== Report content ==================== + + @Test + public void should_report_default_configuration() throws Exception { + JsonNode report = report(defaultsReporter(map -> {})); + + assertThat(report.get("version").asInt()).isEqualTo(DefaultDriverConfigReporter.SCHEMA_VERSION); + + // The whole document is three groups plus the version; everything else hangs off one of them. + for (String group : new String[] {"connection", "control-plane", "query"}) { + assertThat(report.has(group)).as("group %s present", group).isTrue(); + } + // Nothing survives at the top level from the flat envelope of the previous schema revision. + for (String group : + new String[] { + "socket", + "reconnection-policy", + "retry-policy", + "load-balancing-policy", + "speculative-execution-policy", + "node-location-preference", + "query-defaults", + "connection-pool", + "tls" + }) { + assertThat(report.has(group)).as("no top-level %s", group).isFalse(); + } + // No speculative execution policy configured by default: the group has no null variant in + // the schema, so it is omitted entirely rather than reported as null. + assertThat(report.get("query").has("speculative-execution")).isFalse(); + + JsonNode connection = report.get("connection"); + assertThat(connection.get("connect").get("timeout-ms").asLong()).isPositive(); + // No socket-level read/write timeout, and connection.heartbeat has no schema slot yet: all + // three are omitted rather than present-with-null/empty. + assertThat(connection.has("read")).isFalse(); + assertThat(connection.has("write")).isFalse(); + assertThat(connection.has("heartbeat")).isFalse(); + JsonNode requests = connection.get("requests"); + assertThat(requests.get("in-flight").get("max").asInt()).isEqualTo(1024); + assertThat(requests.get("orphaned").get("max").asInt()).isEqualTo(256); + assertThat(connection.get("pool").get("shard-aware").get("enabled").asBoolean()).isTrue(); + + JsonNode socket = report.get("connection").get("socket"); + assertThat(socket.get("tcp-no-delay").asBoolean()).isTrue(); + assertThat(socket.get("keep-alive").asBoolean()).isFalse(); + assertThat(socket.has("linger")).isFalse(); + assertThat(socket.has("receive-buffer")).isFalse(); + assertThat(socket.has("send-buffer")).isFalse(); + + JsonNode controlPlane = report.get("control-plane"); + assertThat( + controlPlane.get("queries").get("system").get("timeout").get("client-side-ms").asLong()) + .isPositive(); + assertThat( + controlPlane.get("queries").get("system").get("timeout").get("server-side-ms").asLong()) + .isPositive(); + assertThat(controlPlane.get("schema").get("agreement").get("timeout-ms").asLong()).isPositive(); + + JsonNode reconnection = report.get("connection").get("reconnection").get("policy"); + assertThat(reconnection.get("type").asText()).isEqualTo("exponential"); + assertThat(reconnection.get("base-ms").asLong()).isPositive(); + assertThat(reconnection.get("max-ms").asLong()).isPositive(); + // Java's built-in reconnection policies are unbounded: max-attempts is omitted. + assertThat(reconnection.has("max-attempts")).isFalse(); + + JsonNode retryGroup = report.get("query").get("retry"); + // "backoff" is a sibling of "policy", not a field on it: no built-in Java retry policy inserts + // a + // delay between attempts, so the optional key is omitted from the group. + assertThat(retryGroup.has("backoff")).isFalse(); + JsonNode retry = retryGroup.get("policy"); + assertThat(retry.get("type").asText()).isEqualTo("standard-error-aware"); + // The schema's max-retries reports a configured retry limit; Java's built-ins hardcode + // per-error-type rules instead of taking a count from configuration, so it is omitted. + assertThat(retry.has("max-retries")).isFalse(); + + JsonNode lb = report.get("query").get("load-balancing").get("policy"); + // Every built-in policy routes to token replicas, so they all share the one built-in type; what + // told them apart in the previous schema revision now lives in the fields below. + assertThat(lb.get("type").asText()).isEqualTo("token-aware"); + // Replicas at the head of the query plan are always shuffled, with no option to disable it. + assertThat(lb.get("load-distribution").asText()).isEqualTo("shuffle"); + assertThat(lb.get("fallback-to-non-preferred-nodes").asBoolean()).isFalse(); + // Slow-replica avoidance is on by default for DefaultLoadBalancingPolicy; "latency" is not + // among the signals because the driver samples when responses arrive, not how long they took. + JsonNode adaptiveOrdering = lb.get("adaptive-ordering"); + assertThat(adaptiveOrdering.get("signals")) + .extracting(JsonNode::asText) + .containsExactly("response-rate", "in-flight-requests", "recovery-state"); + // The schema dropped the boolean that used to carry this: presence is what says it is on. + assertThat(adaptiveOrdering.has("enabled")).isFalse(); + // local-dc/local-rack are no longer reported here; see the node preferences below. + assertThat(lb.has("local-dc")).isFalse(); + assertThat(lb.has("local-rack")).isFalse(); + + // local-datacenter not configured in the defaults => DC is inferred (dc-auto). The policy is a + // mock, so it has resolved nothing, which is also the real state when the very first control + // connection sends STARTUP. + JsonNode nodeLocation = report.get("query").get("load-balancing").get("node-preference"); + assertThat(nodeLocation.get("type").asText()).isEqualTo("dc-auto"); + assertThat(nodeLocation.has("local-dc")).isFalse(); + assertThat(nodeLocation.has("inferred-local-dc")).isFalse(); + // The same preference reaches the connection group, which is scoped by the DC alone. + JsonNode connectionNodeLocation = connection.get("node-preference"); + assertThat(connectionNodeLocation.get("type").asText()).isEqualTo("dc-auto"); + assertThat(connectionNodeLocation.has("local-dc")).isFalse(); + + JsonNode query = report.get("query").get("defaults"); + assertThat(query.get("consistency").asText()).isEqualTo("LOCAL_ONE"); + // Unlike the schema's "absent when unset" wording suggests, Java always has a serial level + // configured: basic.request.serial-consistency is a required option with a shipped default. + assertThat(query.get("serial-consistency").asText()).isEqualTo("SERIAL"); + assertThat(query.get("idempotence").asBoolean()).isFalse(); + assertThat(query.get("client-timestamps").asBoolean()).isTrue(); + assertThat(query.get("request").get("timeout-ms").asLong()).isPositive(); + assertThat(query.get("page").get("size").asInt()).isPositive(); + + // No SSL configured: the group is absent rather than an enabled:false, which the schema no + // longer has a field for. + assertThat(connection.has("tls")).isFalse(); + } + + @Test + public void should_report_server_side_timeout_whatever_the_backend() throws Exception { + // Configuration intent, not observed effect: metadata.schema.request-timeout goes on the wire + // as a "USING TIMEOUT" clause only where CassandraSchemaQueries finds sharding information, but + // the report describes what the driver is configured with, which it knows before it connects. + JsonNode report = report(defaultsReporter(map -> {})); + JsonNode timeout = report.get("control-plane").get("queries").get("system").get("timeout"); + assertThat(timeout.get("client-side-ms").asLong()).isPositive(); + assertThat(timeout.get("server-side-ms").asLong()).isPositive(); + } + + @Test + public void should_report_constant_reconnection_policy() throws Exception { + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + constantReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()); + JsonNode reconnection = report(r).get("connection").get("reconnection").get("policy"); + assertThat(reconnection.get("type").asText()).isEqualTo("constant"); + assertThat(reconnection.get("delay-ms").asLong()).isPositive(); + assertThat(reconnection.has("max-attempts")).isFalse(); + } + + @Test + public void should_report_custom_reconnection_policy() throws Exception { + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + mock(ReconnectionPolicy.class), // neither exponential nor constant + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()); + JsonNode reconnection = report(r).get("connection").get("reconnection").get("policy"); + assertThat(reconnection.get("type").asText()).isEqualTo("custom"); + assertThat(reconnection.get("name").asText()).isNotEmpty(); + } + + @Test + public void should_report_a_reconnection_policy_subclass_as_custom() throws Exception { + // A real (anonymous) subclass of a built-in, not a mock: proves the exact-class check doesn't + // misclassify user customizations of a built-in as the plain built-in. + // ConstantReconnectionPolicy is not final, and a real subclass of it already exists elsewhere + // in this repo's test code. + ReconnectionPolicy subclass = new ConstantReconnectionPolicy(policyConstructionContext()) {}; + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + subclass, + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()); + JsonNode reconnection = report(r).get("connection").get("reconnection").get("policy"); + assertThat(reconnection.get("type").asText()).isEqualTo("custom"); + // Also exercises the anonymous-class name fallback: getSimpleName() is empty for an anonymous + // class, so the reported name must fall back to the (non-empty) binary class name. + assertThat(reconnection.get("name").asText()).isNotEmpty(); + } + + @Test + public void should_report_downgrading_consistency_retry_policy() throws Exception { + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(ConsistencyDowngradingRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()); + JsonNode retry = report(r).get("query").get("retry").get("policy"); + assertThat(retry.get("type").asText()).isEqualTo("downgrading-consistency"); + // No retry limit taken from configuration, so the schema's optional max-retries is omitted. + assertThat(retry.has("max-retries")).isFalse(); + } + + @Test + public void should_report_a_retry_policy_subclass_as_custom() throws Exception { + // Real (anonymous) subclass, not a mock: DefaultRetryPolicy is not final, and a real subclass + // of it already exists elsewhere in this repo's test code (osgi-tests' CustomRetryPolicy). + RetryPolicy subclass = new DefaultRetryPolicy(policyConstructionContext(), "default") {}; + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + subclass, + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()); + JsonNode retry = report(r).get("query").get("retry").get("policy"); + assertThat(retry.get("type").asText()).isEqualTo("custom"); + assertThat(retry.get("name").asText()).isNotEmpty(); + // A custom policy cannot be introspected for a retry limit, so max-retries is omitted here too + // even though the schema allows it on the custom variant. + assertThat(retry.has("max-retries")).isFalse(); + } + + @Test + public void should_report_constant_speculative_execution_policy() throws Exception { + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + constantSpeculativeExecution(3, 100), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()); + JsonNode spec = report(r).get("query").get("speculative-execution").get("policy"); + assertThat(spec.get("type").asText()).isEqualTo("constant"); + // The policy counts the initial, non-speculative execution among the 3 it caps, while the + // schema field counts only the speculative ones — so 3 executions are 2 speculative. + assertThat(spec.get("max-executions").asInt()).isEqualTo(2); + assertThat(spec.get("delay-ms").asLong()).isEqualTo(100); + } + + @Test + public void should_report_speculative_executions_the_running_policy_was_built_with() + throws Exception { + // advanced.speculative-execution-policy is documented as not modifiable at runtime, and the + // context builds the policy once: after a reload, the profile can carry values no request runs + // with. The report describes the policy, so the reloaded 1 — which would omit the group as + // "never speculates" — does not reach it. + DefaultDriverConfigReporter r = + reporterWith( + defaults( + map -> { + map.put(TypedDriverOption.SPECULATIVE_EXECUTION_MAX, 1); + map.put(TypedDriverOption.SPECULATIVE_EXECUTION_DELAY, Duration.ofMillis(7)); + }), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + constantSpeculativeExecution(4, 100), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()); + JsonNode report = report(r); + JsonNode spec = report.get("query").get("speculative-execution").get("policy"); + assertThat(spec.get("max-executions").asInt()).isEqualTo(3); + assertThat(spec.get("delay-ms").asLong()).isEqualTo(100); + assertConformsToSchema(report); + } + + @Test + public void should_omit_speculative_execution_policy_when_it_never_speculates() throws Exception { + // max-executions = 1 is the smallest value ConstantSpeculativeExecutionPolicy accepts, and it + // permits only the initial execution — so there is no speculative execution to describe, and + // the + // group is omitted exactly as it is for NoSpeculativeExecutionPolicy. + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + constantSpeculativeExecution(1, 100), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()); + JsonNode report = report(r); + assertThat(report.get("query").has("speculative-execution")).isFalse(); + assertConformsToSchema(report); + } + + @Test + public void should_report_a_speculative_execution_policy_subclass_as_custom() throws Exception { + // Real (anonymous) subclass, not a mock: NoSpeculativeExecutionPolicy is not final. A subclass + // must be reported as "custom", not silently treated the same as "no policy" (which would drop + // the whole group). + SpeculativeExecutionPolicy subclass = + new NoSpeculativeExecutionPolicy(policyConstructionContext(), "default") {}; + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + subclass, + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()); + JsonNode spec = report(r).get("query").get("speculative-execution").get("policy"); + assertThat(spec.get("type").asText()).isEqualTo("custom"); + assertThat(spec.get("name").asText()).isNotEmpty(); + } + + // The schema's built-in load-balancing variant is a single type, so these tests no longer + // distinguish the built-ins by "type" — they pin that each one is still recognized as a built-in + // rather than falling through to "custom", and that adaptive-ordering tells them apart. + + @Test + public void should_report_dc_inferring_load_balancing_policy() throws Exception { + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing( + DcInferringLoadBalancingPolicy.class), // extends DefaultLoadBalancingPolicy + clientSideGenerator(), + Optional.empty()); + assertBuiltInLoadBalancingPolicy( + report(r).get("query").get("load-balancing").get("policy"), /* adaptiveOrdering= */ true); + } + + @Test + public void should_report_dse_load_balancing_policy_as_a_built_in() throws Exception { + // DseLoadBalancingPolicy is a deprecated, behavior-identical alias of + // DefaultLoadBalancingPolicy; must not fall through to "custom". Note: a real (non-mocked) + // instance of this class requires a resolvable local DC (it uses MandatoryLocalDcHelper) and + // would fail to construct with no DC configured, unlike DcInferringLoadBalancingPolicy above; + // the mock here bypasses that constructor validation, same as + // should_report_default_configuration. + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(DseLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()); + assertBuiltInLoadBalancingPolicy( + report(r).get("query").get("load-balancing").get("policy"), /* adaptiveOrdering= */ true); + } + + @Test + public void should_report_dse_dc_inferring_load_balancing_policy_as_a_built_in() + throws Exception { + // DseDcInferringLoadBalancingPolicy is a deprecated, behavior-identical alias of + // DcInferringLoadBalancingPolicy; must not fall through to "custom". + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(DseDcInferringLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()); + assertBuiltInLoadBalancingPolicy( + report(r).get("query").get("load-balancing").get("policy"), /* adaptiveOrdering= */ true); + } + + @Test + public void should_report_basic_load_balancing_policy_without_adaptive_ordering() + throws Exception { + // A real, distinct, documented third built-in (reference.conf lists exactly three); must not be + // misclassified as "custom". Unlike DefaultLoadBalancingPolicy it has no slow-replica-avoidance + // mechanism at all, so adaptive ordering is off regardless of the (default-policy-only) option + // — which is the only thing now distinguishing it in the report. + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> map.put(TypedDriverOption.LOAD_BALANCING_POLICY_SLOW_AVOIDANCE, true)), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(BasicLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()); + assertBuiltInLoadBalancingPolicy( + report(r).get("query").get("load-balancing").get("policy"), /* adaptiveOrdering= */ false); + } + + @Test + public void should_report_adaptive_ordering_disabled_when_slow_avoidance_is_off() + throws Exception { + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing( + DefaultLoadBalancingPolicy.class, + /* avoidSlowReplicas= */ false, + /* maxNodesPerRemoteDc= */ 0), + clientSideGenerator(), + Optional.empty()); + assertBuiltInLoadBalancingPolicy( + report(r).get("query").get("load-balancing").get("policy"), /* adaptiveOrdering= */ false); + } + + @Test + public void should_report_the_adaptive_ordering_the_running_policy_was_built_with() + throws Exception { + // DefaultLoadBalancingPolicy latches slow-replica avoidance in its constructor and never + // re-reads it, so after a reload the profile can say off while the policy keeps reordering. + // The report describes the policy, so the reloaded false does not reach it. + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> map.put(TypedDriverOption.LOAD_BALANCING_POLICY_SLOW_AVOIDANCE, false)), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing( + DefaultLoadBalancingPolicy.class, + /* avoidSlowReplicas= */ true, + /* maxNodesPerRemoteDc= */ 0), + clientSideGenerator(), + Optional.empty()); + JsonNode report = report(r); + assertBuiltInLoadBalancingPolicy( + report.get("query").get("load-balancing").get("policy"), /* adaptiveOrdering= */ true); + assertConformsToSchema(report); + } + + @Test + public void should_report_custom_load_balancing_policy() throws Exception { + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + mock(LoadBalancingPolicy.class), // not the default policy + clientSideGenerator(), + Optional.empty()); + JsonNode lb = report(r).get("query").get("load-balancing").get("policy"); + assertThat(lb.get("type").asText()).isEqualTo("custom"); + assertThat(lb.get("name").asText()).isNotEmpty(); + } + + @Test + public void should_report_explicit_local_dc() throws Exception { + DefaultDriverConfigReporter r = + defaultsReporter(map -> map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_DATACENTER, "dc1")); + JsonNode nodeLocation = report(r).get("query").get("load-balancing").get("node-preference"); + assertThat(nodeLocation.get("type").asText()).isEqualTo("dc"); + assertThat(nodeLocation.get("local-dc").asText()).isEqualTo("dc1"); + assertThat(nodeLocation.has("local-rack")).isFalse(); + } + + @Test + public void should_report_the_datacenter_half_of_the_preference_on_the_connection_group() + throws Exception { + // The two node-preference slots are not the same object. The datacenter scopes which nodes get + // a pool at all (a node outside it is IGNORED, and an IGNORED node gets no connection), so it + // belongs under "connection" — but the rack does not: it only reorders replicas at the head of + // a query plan, and connections are still held across the whole local DC. Reporting the rack + // there would claim a scoping the driver never performs. + DefaultDriverConfigReporter r = + defaultsReporter( + map -> { + map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_DATACENTER, "dc1"); + map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_RACK, "rack1"); + }); + JsonNode report = report(r); + + JsonNode connectionPreference = report.get("connection").get("node-preference"); + assertThat(connectionPreference.get("type").asText()).isEqualTo("dc"); + assertThat(connectionPreference.get("local-dc").asText()).isEqualTo("dc1"); + assertThat(connectionPreference.has("local-rack")).isFalse(); + + JsonNode queryPreference = report.get("query").get("load-balancing").get("node-preference"); + assertThat(queryPreference.get("type").asText()).isEqualTo("rack"); + assertThat(queryPreference.get("local-rack").asText()).isEqualTo("rack1"); + + assertConformsToSchema(report); + } + + @Test + public void should_report_dc_auto_on_the_connection_group_when_only_a_rack_is_configured() + throws Exception { + // Rack-only: the query side keeps the rack (rack-auto, since the DC that goes with it is not + // configured), while the connection side has no datacenter to name at all and degrades to a + // bare dc-auto rather than borrowing the rack. + DefaultDriverConfigReporter r = + defaultsReporter(map -> map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_RACK, "rack1")); + JsonNode report = report(r); + + JsonNode connectionPreference = report.get("connection").get("node-preference"); + assertThat(connectionPreference.get("type").asText()).isEqualTo("dc-auto"); + assertThat(connectionPreference.has("local-dc")).isFalse(); + assertThat(connectionPreference.has("local-rack")).isFalse(); + + JsonNode queryPreference = report.get("query").get("load-balancing").get("node-preference"); + assertThat(queryPreference.get("type").asText()).isEqualTo("rack-auto"); + assertThat(queryPreference.get("local-rack").asText()).isEqualTo("rack1"); + + assertConformsToSchema(report); + } + + @Test + public void should_report_the_datacenter_the_policy_has_already_inferred() throws Exception { + // On the very first control connection the policy has not been initialized and knows nothing, + // so the report says dc-auto with no value. On every later reconnect it has resolved a real + // local DC and uses it for routing — which is exactly what the schema's inferred fields are + // for, and what an operator reading system.clients mid-incident needs to see. + DefaultLoadBalancingPolicy policy = loadBalancing(DefaultLoadBalancingPolicy.class); + when(policy.getLocalDatacenter()).thenReturn("dc-inferred"); + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + policy, + clientSideGenerator(), + Optional.empty()); + JsonNode report = report(r); + + // dc-auto carries the inferred datacenter in plain "local-dc" — the schema reserves the + // "inferred-" prefix for rack-auto, where it has to be told apart from a configured one. + JsonNode queryPreference = report.get("query").get("load-balancing").get("node-preference"); + assertThat(queryPreference.get("type").asText()).isEqualTo("dc-auto"); + assertThat(queryPreference.get("local-dc").asText()).isEqualTo("dc-inferred"); + + JsonNode connectionPreference = report.get("connection").get("node-preference"); + assertThat(connectionPreference.get("type").asText()).isEqualTo("dc-auto"); + assertThat(connectionPreference.get("local-dc").asText()).isEqualTo("dc-inferred"); + + assertConformsToSchema(report); + } + + @Test + public void should_report_an_inferred_datacenter_alongside_a_configured_rack() throws Exception { + // A mixture: the rack was configured, the datacenter was worked out by the policy. rack-auto is + // the variant for "at least one part is inferred", and it keeps the two apart by name. + DefaultLoadBalancingPolicy policy = loadBalancing(DefaultLoadBalancingPolicy.class); + when(policy.getLocalDatacenter()).thenReturn("dc-inferred"); + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_RACK, "rack1")), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + policy, + clientSideGenerator(), + Optional.empty()); + JsonNode report = report(r); + + JsonNode queryPreference = report.get("query").get("load-balancing").get("node-preference"); + assertThat(queryPreference.get("type").asText()).isEqualTo("rack-auto"); + assertThat(queryPreference.get("local-rack").asText()).isEqualTo("rack1"); + assertThat(queryPreference.get("inferred-local-dc").asText()).isEqualTo("dc-inferred"); + // The schema forbids carrying a configured and an inferred form of the same field. + assertThat(queryPreference.has("local-dc")).isFalse(); + + assertConformsToSchema(report); + } + + @Test + public void should_prefer_a_configured_datacenter_over_the_one_the_policy_inferred() + throws Exception { + // When the user configured the DC, the policy resolved to that same value — so it is reported + // once, as configured. Emitting both forms would violate the schema and say nothing extra. + DefaultLoadBalancingPolicy policy = loadBalancing(DefaultLoadBalancingPolicy.class); + when(policy.getLocalDatacenter()).thenReturn("dc1"); + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_DATACENTER, "dc1")), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + policy, + clientSideGenerator(), + Optional.empty()); + JsonNode report = report(r); + + JsonNode queryPreference = report.get("query").get("load-balancing").get("node-preference"); + assertThat(queryPreference.get("type").asText()).isEqualTo("dc"); + assertThat(queryPreference.get("local-dc").asText()).isEqualTo("dc1"); + assertThat(queryPreference.has("inferred-local-dc")).isFalse(); + + assertConformsToSchema(report); + } + + @Test + public void should_report_the_datacenter_the_running_policy_resolved() throws Exception { + // The context builds the load balancing policy once, so a profile reloaded to another + // datacenter never reaches it: the policy keeps treating dc1 as local and an out-of-dc1 node + // stays IGNORED. The report follows the policy, so the reloaded dc2 does not appear -- but the + // value is still reported as configured, since that is where the policy took it from. + DefaultLoadBalancingPolicy policy = loadBalancing(DefaultLoadBalancingPolicy.class); + when(policy.getLocalDatacenter()).thenReturn("dc1"); + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_DATACENTER, "dc2")), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + policy, + clientSideGenerator(), + Optional.empty()); + JsonNode report = report(r); + + for (JsonNode preference : + new JsonNode[] { + report.get("query").get("load-balancing").get("node-preference"), + report.get("connection").get("node-preference") + }) { + assertThat(preference.get("type").asText()).isEqualTo("dc"); + assertThat(preference.get("local-dc").asText()).isEqualTo("dc1"); + assertThat(preference.has("inferred-local-dc")).isFalse(); + } + assertConformsToSchema(report); + } + + @Test + public void should_report_the_rack_the_running_policy_resolved() throws Exception { + // Same for the rack half, which only query routing carries. + DefaultLoadBalancingPolicy policy = loadBalancing(DefaultLoadBalancingPolicy.class); + when(policy.getLocalDatacenter()).thenReturn("dc1"); + when(policy.getLocalRack()).thenReturn("rack1"); + DefaultDriverConfigReporter r = + reporterWith( + defaults( + map -> { + map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_DATACENTER, "dc1"); + map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_RACK, "rack2"); + }), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + policy, + clientSideGenerator(), + Optional.empty()); + JsonNode report = report(r); + + JsonNode queryPreference = report.get("query").get("load-balancing").get("node-preference"); + assertThat(queryPreference.get("type").asText()).isEqualTo("rack"); + assertThat(queryPreference.get("local-dc").asText()).isEqualTo("dc1"); + assertThat(queryPreference.get("local-rack").asText()).isEqualTo("rack1"); + assertConformsToSchema(report); + } + + @Test + public void should_report_the_configured_datacenter_before_the_policy_is_initialized() + throws Exception { + // The very first control connection sends STARTUP before the policy is initialized, so it has + // resolved nothing yet and the configured value has to stand on its own -- the group must not + // silently disappear on the one connection that carries the report. + DefaultLoadBalancingPolicy policy = loadBalancing(DefaultLoadBalancingPolicy.class); + when(policy.getLocalDatacenter()).thenReturn(null); + when(policy.getLocalRack()).thenReturn(null); + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_DATACENTER, "dc1")), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + policy, + clientSideGenerator(), + Optional.empty()); + JsonNode report = report(r); + + JsonNode queryPreference = report.get("query").get("load-balancing").get("node-preference"); + assertThat(queryPreference.get("type").asText()).isEqualTo("dc"); + assertThat(queryPreference.get("local-dc").asText()).isEqualTo("dc1"); + assertConformsToSchema(report); + } + + @Test + public void should_report_explicit_local_dc_and_rack() throws Exception { + DefaultDriverConfigReporter r = + defaultsReporter( + map -> { + map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_DATACENTER, "dc1"); + map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_RACK, "rack1"); + }); + JsonNode nodeLocation = report(r).get("query").get("load-balancing").get("node-preference"); + assertThat(nodeLocation.get("type").asText()).isEqualTo("rack"); + assertThat(nodeLocation.get("local-dc").asText()).isEqualTo("dc1"); + assertThat(nodeLocation.get("local-rack").asText()).isEqualTo("rack1"); + } + + @Test + public void should_report_local_dc_set_via_session_builder() throws Exception { + // SessionBuilder.withLocalDatacenter(...), not the config option: surfaced through + // InternalDriverContext.getLocalDatacenter(), which the reporter must consult. + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty(), + /* programmaticLocalDc= */ "dc-programmatic"); + JsonNode nodeLocation = report(r).get("query").get("load-balancing").get("node-preference"); + assertThat(nodeLocation.get("type").asText()).isEqualTo("dc"); + assertThat(nodeLocation.get("local-dc").asText()).isEqualTo("dc-programmatic"); + } + + @Test + public void should_prefer_programmatic_local_dc_over_config_option() throws Exception { + DefaultDriverConfigReporter r = + reporterWith( + defaults( + map -> map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_DATACENTER, "dc-config")), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty(), + /* programmaticLocalDc= */ "dc-programmatic"); + JsonNode nodeLocation = report(r).get("query").get("load-balancing").get("node-preference"); + assertThat(nodeLocation.get("type").asText()).isEqualTo("dc"); + assertThat(nodeLocation.get("local-dc").asText()).isEqualTo("dc-programmatic"); + } + + @Test + public void should_report_rack_auto_when_only_rack_is_configured() throws Exception { + // Rack configured explicitly, but no DC (neither programmatically nor via config): the DC will + // be inferred, so this must not silently drop the explicitly-configured rack. local-rack is the + // configured-value key, and the schema forbids pairing it with a configured local-dc here — so + // the DC is absent under both its keys: there is no configured one, and the inferred one isn't + // known at report time. + DefaultDriverConfigReporter r = + defaultsReporter(map -> map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_RACK, "rack1")); + JsonNode nodeLocation = report(r).get("query").get("load-balancing").get("node-preference"); + assertThat(nodeLocation.get("type").asText()).isEqualTo("rack-auto"); + assertThat(nodeLocation.get("local-rack").asText()).isEqualTo("rack1"); + assertThat(nodeLocation.has("local-dc")).isFalse(); + assertThat(nodeLocation.has("inferred-local-dc")).isFalse(); + assertThat(nodeLocation.has("inferred-local-rack")).isFalse(); + } + + @Test + public void should_report_dc_failover_when_it_is_configured_and_a_datacenter_is_preferred() + throws Exception { + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing( + DefaultLoadBalancingPolicy.class, + /* avoidSlowReplicas= */ true, + /* maxNodesPerRemoteDc= */ 2), + clientSideGenerator(), + Optional.empty()); + JsonNode report = report(r); + assertThat( + report + .get("query") + .get("load-balancing") + .get("policy") + .get("fallback-to-non-preferred-nodes") + .asBoolean()) + .isTrue(); + assertConformsToSchema(report); + } + + @Test + public void should_report_the_dc_failover_the_running_policy_was_built_with() throws Exception { + // Same latching as adaptive ordering: BasicLoadBalancingPolicy reads max-nodes-per-remote-dc in + // its constructor, so a profile reloaded to 0 leaves a policy that still appends remote nodes. + DefaultDriverConfigReporter r = + reporterWith( + defaults( + map -> + map.put( + TypedDriverOption.LOAD_BALANCING_DC_FAILOVER_MAX_NODES_PER_REMOTE_DC, 0)), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing( + DefaultLoadBalancingPolicy.class, + /* avoidSlowReplicas= */ true, + /* maxNodesPerRemoteDc= */ 2), + clientSideGenerator(), + Optional.empty()); + JsonNode report = report(r); + assertThat( + report + .get("query") + .get("load-balancing") + .get("policy") + .get("fallback-to-non-preferred-nodes") + .asBoolean()) + .isTrue(); + assertConformsToSchema(report); + } + + @Test + public void should_report_dc_failover_as_off_without_a_datacenter_preference() throws Exception { + // max-nodes-per-remote-dc is necessary but not sufficient: maybeAddDcFailover also needs the + // policy to have a local DC, and BasicLoadBalancingPolicy with none configured never settles on + // one. Reporting true off the option alone claimed failover for a session where no remote node + // is ever appended to a query plan — and the key is about leaving the node preference, which + // this report does not even carry. + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing( + BasicLoadBalancingPolicy.class, + /* avoidSlowReplicas= */ false, + /* maxNodesPerRemoteDc= */ 2), + clientSideGenerator(), + Optional.empty()); + JsonNode report = report(r); + JsonNode loadBalancing = report.get("query").get("load-balancing"); + assertThat(loadBalancing.has("node-preference")).isFalse(); + assertThat(loadBalancing.get("policy").get("fallback-to-non-preferred-nodes").asBoolean()) + .isFalse(); + assertConformsToSchema(report); + } + + @Test + public void should_omit_node_location_preference_for_a_dc_agnostic_basic_policy() + throws Exception { + // BasicLoadBalancingPolicy is the one built-in that uses OptionalLocalDcHelper: with no DC + // configured it stays datacenter-agnostic for the life of the session instead of inferring one, + // so reporting dc-auto would describe a preference that never forms. The group is optional, so + // the honest answer is to leave it out. + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(BasicLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()); + assertThat(report(r).get("query").get("load-balancing").has("node-preference")).isFalse(); + } + + @Test + public void should_omit_node_location_preference_for_a_basic_policy_with_only_a_rack() + throws Exception { + // Same case, and the rack does not rescue it: BasicLoadBalancingPolicy only looks for a rack + // once it knows a DC, so a rack configured without one never takes effect either. + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_RACK, "rack1")), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(BasicLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()); + assertThat(report(r).get("query").get("load-balancing").has("node-preference")).isFalse(); + } + + @Test + public void should_report_a_configured_dc_for_a_basic_policy() throws Exception { + // With a DC configured, BasicLoadBalancingPolicy does honor it, so the group is reported. + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_DATACENTER, "dc1")), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(BasicLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()); + JsonNode nodeLocation = report(r).get("query").get("load-balancing").get("node-preference"); + assertThat(nodeLocation.get("type").asText()).isEqualTo("dc"); + assertThat(nodeLocation.get("local-dc").asText()).isEqualTo("dc1"); + } + + @Test + public void should_report_dc_auto_for_an_inferring_policy_without_a_local_dc() throws Exception { + // The counterpart to the basic-policy cases above: DcInferringLoadBalancingPolicy really does + // resolve a DC from the first contacted node, so dc-auto describes a preference it will form. + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(DcInferringLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()); + assertThat( + report(r) + .get("query") + .get("load-balancing") + .get("node-preference") + .get("type") + .asText()) + .isEqualTo("dc-auto"); + } + + @Test + public void should_omit_node_location_preference_for_a_dc_agnostic_custom_policy() + throws Exception { + // The LoadBalancingPolicy SPI nowhere requires an implementation to infer a datacenter, so a + // custom policy is in the same position as BasicLoadBalancingPolicy above, not the same as the + // built-ins that do infer: dc-auto would be a claim the driver cannot make on its behalf. + // Omitted from both parents, since the group is optional under each. + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + mock(LoadBalancingPolicy.class), // not a built-in + clientSideGenerator(), + Optional.empty()); + JsonNode report = report(r); + assertThat(report.get("query").get("load-balancing").has("node-preference")).isFalse(); + assertThat(report.get("connection").has("node-preference")).isFalse(); + assertConformsToSchema(report); + } + + @Test + public void should_report_a_datacenter_a_non_inferring_policy_has_nonetheless_resolved() + throws Exception { + // The flip side: a subclass falls through to "custom" for the policy type, but if it has in + // fact + // settled on a local datacenter then that is evidence, not guesswork, and outranks the + // exact-class rule that decided the case above. + BasicLoadBalancingPolicy policy = loadBalancing(BasicLoadBalancingPolicy.class); + when(policy.getLocalDatacenter()).thenReturn("dc-resolved"); + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + policy, + clientSideGenerator(), + Optional.empty()); + JsonNode report = report(r); + JsonNode queryPreference = report.get("query").get("load-balancing").get("node-preference"); + assertThat(queryPreference.get("type").asText()).isEqualTo("dc-auto"); + assertThat(queryPreference.get("local-dc").asText()).isEqualTo("dc-resolved"); + assertThat(report.get("connection").get("node-preference").get("local-dc").asText()) + .isEqualTo("dc-resolved"); + assertConformsToSchema(report); + } + + @Test + public void should_treat_a_blank_local_dc_and_rack_as_unset() throws Exception { + // The driver's own helpers accept an empty local-datacenter as "set" (it just matches no node), + // but the schema requires a non-empty string — and omitting the key while keeping type "dc" + // would be invalid too. Treating blank as unset is the only schema-valid reading. + DefaultDriverConfigReporter r = + defaultsReporter( + map -> { + map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_DATACENTER, " "); + map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_RACK, ""); + }); + JsonNode report = report(r); + JsonNode nodeLocation = report.get("query").get("load-balancing").get("node-preference"); + assertThat(nodeLocation.get("type").asText()).isEqualTo("dc-auto"); + assertThat(nodeLocation.has("local-dc")).isFalse(); + assertThat(nodeLocation.has("local-rack")).isFalse(); + assertConformsToSchema(report); + } + + @Test + public void should_report_a_padded_local_dc_verbatim() throws Exception { + // OptionalLocalDcHelper hands the configured string to the policy as is and matches it with + // Objects.equals, so " dc1 " is a datacenter that matches no node. nonEmptyString admits it, so + // it is reported as configured rather than normalized — trimming would hide the very typo an + // operator reads this report to find. + DefaultDriverConfigReporter r = + defaultsReporter( + map -> map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_DATACENTER, " dc1 ")); + JsonNode report = report(r); + assertThat( + report + .get("query") + .get("load-balancing") + .get("node-preference") + .get("local-dc") + .asText()) + .isEqualTo(" dc1 "); + assertConformsToSchema(report); + } + + @Test + public void should_report_server_side_timestamps_as_disabled_client_timestamps() + throws Exception { + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(DefaultLoadBalancingPolicy.class), + mock(ServerSideTimestampGenerator.class), + Optional.empty()); + assertThat(report(r).get("query").get("defaults").get("client-timestamps").asBoolean()) + .isFalse(); + } + + @Test + public void should_omit_client_timestamps_for_an_unrecognized_generator() throws Exception { + // A generator this class does not recognize may assign timestamps either way — the interface + // lets it return Statement.NO_DEFAULT_TIMESTAMP from next() and leave them to the coordinator — + // and naming the wrong source for every write the session makes is worse than saying nothing. + // The schema's field is optional precisely so this document stays valid. + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(DefaultLoadBalancingPolicy.class), + mock(TimestampGenerator.class), + Optional.empty()); + JsonNode report = report(r); + assertThat(report.get("query").get("defaults").has("client-timestamps")).isFalse(); + assertConformsToSchema(report); + } + + @Test + public void should_report_every_built_in_generator() throws Exception { + // None of the built-ins is ever reported as unknown: the two monotonic ones always assign the + // timestamp themselves, and the server-side one never does. Real instances rather than mocks, + // so that the branches are pinned to the classes the driver actually instantiates. + assertThat(clientTimestampsOf(new AtomicTimestampGenerator(policyConstructionContext()))) + .isTrue(); + assertThat(clientTimestampsOf(new ThreadLocalTimestampGenerator(policyConstructionContext()))) + .isTrue(); + assertThat(clientTimestampsOf(new ServerSideTimestampGenerator(policyConstructionContext()))) + .isFalse(); + } + + /** The {@code query.defaults.client-timestamps} a report built over this generator carries. */ + private Boolean clientTimestampsOf(TimestampGenerator generator) throws Exception { + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(DefaultLoadBalancingPolicy.class), + generator, + Optional.empty()); + JsonNode clientTimestamps = report(r).get("query").get("defaults").get("client-timestamps"); + assertThat(clientTimestamps).isNotNull(); + return clientTimestamps.asBoolean(); + } + + @Test + public void should_report_tls_enabled_with_hostname_verification() throws Exception { + // hostname-verification comes from the factory's own state, not the config option. + SslEngineFactory factory = + new ProgrammaticSslEngineFactory( + SSLContext.getDefault(), null, /* requireHostnameValidation= */ true); + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.of(factory)); + JsonNode connection = report(r).get("connection"); + // Presence of the group is what reports TLS as on: the schema dropped the "enabled" boolean. + assertThat(connection.has("tls")).isTrue(); + assertThat(connection.get("tls").get("hostname-verification").asBoolean()).isTrue(); + } + + @Test + public void should_report_hostname_verification_from_factory_not_config_option() + throws Exception { + // Regression for the false-report bug: a ProgrammaticSslEngineFactory (as built by + // SessionBuilder.withSslContext(...)) does NO hostname validation by default and ignores the + // SSL_HOSTNAME_VALIDATION config option. The report must reflect the factory's real state + // (false), not the config option (true here) — otherwise it falsely claims validation is on. + SslEngineFactory programmatic = new ProgrammaticSslEngineFactory(SSLContext.getDefault()); + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> map.put(TypedDriverOption.SSL_HOSTNAME_VALIDATION, true)), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.of(programmatic)); + JsonNode connection = report(r).get("connection"); + // Presence of the group is what reports TLS as on: the schema dropped the "enabled" boolean. + assertThat(connection.has("tls")).isTrue(); + assertThat(connection.get("tls").get("hostname-verification").asBoolean()).isFalse(); + } + + @Test + public void should_report_tls_enabled_for_a_custom_ssl_handler_factory() throws Exception { + // Overriding DefaultDriverContext.buildSslHandlerFactory() is the driver's documented low-level + // SSL extension point (e.g. Netty's native OpenSSL), and such an override supplies no + // SslEngineFactory at all. That session is still encrypted, so TLS must be read from the + // handler + // factory — the same reference ChannelFactory installs the SSL handler from — and not from + // getSslEngineFactory(). Host name validation is a property of the JDK SSLEngine and cannot be + // read on this path at all, so it is omitted rather than guessed in either direction. + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> map.put(TypedDriverOption.SSL_HOSTNAME_VALIDATION, true)), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + /* ssl= */ Optional.empty(), + Optional.of(mock(SslHandlerFactory.class)), + /* programmaticLocalDc= */ null); + JsonNode report = report(r); + JsonNode connection = report.get("connection"); + // Presence of the group is what reports TLS as on: the schema dropped the "enabled" boolean. + assertThat(connection.has("tls")).isTrue(); + assertThat(connection.get("tls").has("hostname-verification")).isFalse(); + // An empty tls group is a valid document: the schema made hostname-verification optional so + // that "unknown" has a representation. + assertConformsToSchema(report); + } + + @Test + public void should_omit_hostname_verification_for_an_unrecognized_engine_factory() + throws Exception { + // The JDK path, but with a custom engine factory that is none of the driver's own, so its host + // name handling is unknown. Guessing false here would report a session as not checking host + // names when its factory may well be doing exactly that. + SslEngineFactory unrecognized = mock(SslEngineFactory.class); + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.of(unrecognized)); + JsonNode report = report(r); + JsonNode tls = report.get("connection").get("tls"); + assertThat(tls).isNotNull(); + assertThat(tls.has("hostname-verification")).isFalse(); + assertConformsToSchema(report); + } + + @Test + public void should_report_every_built_in_engine_factory() throws Exception { + // None of the built-ins is ever reported as unknown. Real instances rather than mocks, so that + // the branches are pinned to the classes the driver actually instantiates — and, for the + // configured one, to the whole option-to-field-to-report chain. + assertThat(hostnameVerificationOf(new DefaultSslEngineFactory(policyConstructionContext()))) + .isTrue(); + assertThat(hostnameVerificationOf(new SniSslEngineFactory(SSLContext.getDefault()))).isTrue(); + assertThat(hostnameVerificationOf(new ProgrammaticSslEngineFactory(SSLContext.getDefault()))) + .isFalse(); + assertThat( + hostnameVerificationOf( + new ProgrammaticSslEngineFactory( + SSLContext.getDefault(), null, /* requireHostnameValidation= */ true))) + .isTrue(); + } + + /** The {@code connection.tls.hostname-verification} a report built over this factory carries. */ + private Boolean hostnameVerificationOf(SslEngineFactory factory) throws Exception { + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.of(factory)); + JsonNode verification = report(r).get("connection").get("tls").get("hostname-verification"); + assertThat(verification).isNotNull(); + return verification.asBoolean(); + } + + @Test + public void should_report_hostname_verification_from_the_engine_the_handler_actually_wraps() + throws Exception { + // The configured engine factory and the one the active handler wraps can be different objects: + // a context that overrides buildSslHandlerFactory() may pass an engine factory of its own while + // advanced.ssl-engine-factory.class still names another. The report has to describe the engine + // that actually builds the connection's SSLEngine, so the wrapped one wins. + SslEngineFactory wrapped = + new ProgrammaticSslEngineFactory( + SSLContext.getDefault(), null, /* requireHostnameValidation= */ true); + SslEngineFactory configuredButUnused = + new ProgrammaticSslEngineFactory(SSLContext.getDefault()); + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.of(configuredButUnused), + Optional.of(new JdkSslHandlerFactory(wrapped)), + /* programmaticLocalDc= */ null); + JsonNode connection = report(r).get("connection"); + assertThat(connection.get("tls").get("hostname-verification").asBoolean()).isTrue(); + } + + @Test + public void should_not_resolve_the_configured_engine_factory_at_all() throws Exception { + // Stronger than "reads the right one": the reporter must not touch getSslEngineFactory(). It is + // a LazyReference nothing on this path has necessarily forced yet, and resolving the built-in + // factory reads keystore/truststore files — on a Netty event-loop thread, mid-STARTUP, and + // throwing there would cost the whole report. + // Every mock is built before the stubbing below starts: the helpers stub their own returns, and + // Mockito cannot have a when(...) open while another begins. + ReconnectionPolicy reconnection = exponentialReconnection(); + TimestampGenerator timestamps = clientSideGenerator(); + SslEngineFactory wrapped = + new ProgrammaticSslEngineFactory( + SSLContext.getDefault(), null, /* requireHostnameValidation= */ true); + SslHandlerFactory handlerFactory = new JdkSslHandlerFactory(wrapped); + // Built before the stubbing chain below: the helper stubs the policy itself, and Mockito + // rejects a nested when() inside an unfinished one. + LoadBalancingPolicy policy = loadBalancing(DefaultLoadBalancingPolicy.class); + + InternalDriverContext ctx = mock(InternalDriverContext.class); + DriverConfig config = mock(DriverConfig.class); + when(ctx.getConfig()).thenReturn(config); + when(config.getDefaultProfile()).thenReturn(defaults(map -> {})); + when(ctx.getReconnectionPolicy()).thenReturn(reconnection); + when(ctx.getRetryPolicy(DriverExecutionProfile.DEFAULT_NAME)) + .thenReturn(mock(DefaultRetryPolicy.class)); + when(ctx.getSpeculativeExecutionPolicy(DriverExecutionProfile.DEFAULT_NAME)) + .thenReturn(mock(NoSpeculativeExecutionPolicy.class)); + when(ctx.getLoadBalancingPolicy(DriverExecutionProfile.DEFAULT_NAME)).thenReturn(policy); + when(ctx.getTimestampGenerator()).thenReturn(timestamps); + when(ctx.getSslHandlerFactory()).thenReturn(Optional.of(handlerFactory)); + when(ctx.getSslEngineFactory()) + .thenThrow(new AssertionError("the configured engine factory must not be resolved")); + + JsonNode report = MAPPER.readTree(new DefaultDriverConfigReporter(ctx).buildJson()); + // The group is built from the wrapped engine factory alone; getSslEngineFactory() throwing + // proves it was never consulted. + assertThat(report.get("connection").get("tls").get("hostname-verification").asBoolean()) + .isTrue(); + } + + @Test + public void should_not_report_hostname_verification_from_an_unused_engine_factory() + throws Exception { + // The handler factory and the engine factory are independent: a context can override + // buildSslHandlerFactory() (so the pipeline gets a handler the driver knows nothing about) and + // still have advanced.ssl-engine-factory.class configured, leaving a fully built engine factory + // that nothing on the connection path ever consults. Reading it would claim host name + // validation the custom handler does not perform, so hostname-verification is omitted unless + // the handler in force is the driver's own JdkSslHandlerFactory. + SslEngineFactory validating = + new ProgrammaticSslEngineFactory( + SSLContext.getDefault(), null, /* requireHostnameValidation= */ true); + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.of(validating), + Optional.of(mock(SslHandlerFactory.class)), + /* programmaticLocalDc= */ null); + JsonNode connection = report(r).get("connection"); + // Presence of the group is what reports TLS as on: the schema dropped the "enabled" boolean. + assertThat(connection.has("tls")).isTrue(); + assertThat(connection.get("tls").has("hostname-verification")).isFalse(); + } + + @Test + public void should_report_socket_overrides() throws Exception { + DefaultDriverConfigReporter r = + defaultsReporter( + map -> { + map.put(TypedDriverOption.SOCKET_KEEP_ALIVE, true); + map.put(TypedDriverOption.SOCKET_RECEIVE_BUFFER_SIZE, 65535); + map.put(TypedDriverOption.SOCKET_LINGER_INTERVAL, 5); + }); + JsonNode socket = report(r).get("connection").get("socket"); + assertThat(socket.get("keep-alive").asBoolean()).isTrue(); + assertThat(socket.get("receive-buffer").get("size-bytes").asInt()).isEqualTo(65535); + assertThat(socket.get("linger").get("interval-s").asInt()).isEqualTo(5); + } + + // ==================== Values the schema cannot express ==================== + // + // Options whose legal values can fall outside the schema's constraints — usually because + // "disabled" is spelled 0, and twice because the driver does not enforce what the schema does. + // Where the field is optional the group is omitted (as "page" already is when paging is + // unbounded); where it is required, the real value is reported even though that document fails + // validation — see the reporter's class javadoc. + + @Test + public void should_omit_linger_when_disabled() throws Exception { + // A negative interval means SO_LINGER is off, which the schema's non-negative interval-s + // cannot express. + DefaultDriverConfigReporter r = + defaultsReporter(map -> map.put(TypedDriverOption.SOCKET_LINGER_INTERVAL, -1)); + assertThat(report(r).get("connection").get("socket").has("linger")).isFalse(); + } + + @Test + public void should_report_zero_linger_interval() throws Exception { + // 0 is a real setting (close immediately), not a disabled sentinel, and the schema allows + // it: it must survive the guard above. + DefaultDriverConfigReporter r = + defaultsReporter(map -> map.put(TypedDriverOption.SOCKET_LINGER_INTERVAL, 0)); + assertThat(report(r).get("connection").get("socket").get("linger").get("interval-s").asInt()) + .isZero(); + } + + @Test + public void should_omit_socket_buffers_when_not_positive() throws Exception { + DefaultDriverConfigReporter r = + defaultsReporter( + map -> { + map.put(TypedDriverOption.SOCKET_RECEIVE_BUFFER_SIZE, 0); + map.put(TypedDriverOption.SOCKET_SEND_BUFFER_SIZE, 0); + }); + JsonNode socket = report(r).get("connection").get("socket"); + assertThat(socket.has("receive-buffer")).isFalse(); + assertThat(socket.has("send-buffer")).isFalse(); + } + + @Test + public void should_omit_client_side_timeout_when_control_connection_timeout_is_disabled() + throws Exception { + DefaultDriverConfigReporter r = + defaultsReporter( + map -> map.put(TypedDriverOption.CONTROL_CONNECTION_TIMEOUT, Duration.ZERO)); + JsonNode systemQueries = report(r).get("control-plane").get("queries").get("system"); + // The field is optional, but its enclosing "timeout" object is required, so it stays present. + assertThat(systemQueries.has("timeout")).isTrue(); + assertThat(systemQueries.get("timeout").has("client-side-ms")).isFalse(); + } + + @Test + public void should_report_one_millisecond_for_a_sub_millisecond_client_side_timeout() + throws Exception { + // AdminRequestHandler schedules this timeout in nanoseconds, so a sub-millisecond value is a + // live timeout. Truncating it to 0 would land it on the very value both this option and the + // schema read as "no timeout", so it floors at 1 instead. + DefaultDriverConfigReporter r = + defaultsReporter( + map -> + map.put(TypedDriverOption.CONTROL_CONNECTION_TIMEOUT, Duration.ofNanos(500_000))); + assertThat( + report(r) + .get("control-plane") + .get("queries") + .get("system") + .get("timeout") + .get("client-side-ms") + .asLong()) + .isEqualTo(1); + } + + @Test + public void should_report_one_millisecond_for_a_sub_millisecond_schema_agreement_timeout() + throws Exception { + // SchemaAgreementChecker holds this timeout in nanoseconds and only skips the check outright at + // 0, which is also what the schema documents 0 to mean ("do not wait") — so a positive + // sub-millisecond timeout, which does wait, must not collapse onto it. + DefaultDriverConfigReporter r = + defaultsReporter( + map -> + map.put( + TypedDriverOption.CONTROL_CONNECTION_AGREEMENT_TIMEOUT, + Duration.ofNanos(500_000))); + assertThat( + report(r) + .get("control-plane") + .get("schema") + .get("agreement") + .get("timeout-ms") + .asLong()) + .isEqualTo(1); + } + + @Test + public void should_report_one_millisecond_for_a_sub_millisecond_request_timeout() + throws Exception { + // Same reasoning: CqlRequestHandler schedules the request timeout in nanoseconds, and 0 is how + // this option spells "disabled". + DefaultDriverConfigReporter r = + defaultsReporter( + map -> map.put(TypedDriverOption.REQUEST_TIMEOUT, Duration.ofNanos(500_000))); + assertThat(report(r).get("query").get("defaults").get("request").get("timeout-ms").asLong()) + .isEqualTo(1); + } + + @Test + public void should_omit_a_sub_millisecond_connect_timeout() throws Exception { + // Deliberately not floored at 1, unlike the timeouts above: DefaultNettyOptions hands the + // truncated millisecond value to Netty's CONNECT_TIMEOUT_MILLIS, where 0 disables the timeout, + // so a sub-millisecond connect timeout genuinely is disabled at runtime. The enclosing + // "connect" object is required and stays present; only the key goes. + DefaultDriverConfigReporter r = + defaultsReporter( + map -> + map.put(TypedDriverOption.CONNECTION_CONNECT_TIMEOUT, Duration.ofNanos(500_000))); + JsonNode connection = report(r).get("connection"); + assertThat(connection.has("connect")).isTrue(); + assertThat(connection.get("connect").has("timeout-ms")).isFalse(); + } + + @Test + public void should_omit_a_disabled_connect_timeout() throws Exception { + DefaultDriverConfigReporter r = + defaultsReporter( + map -> map.put(TypedDriverOption.CONNECTION_CONNECT_TIMEOUT, Duration.ZERO)); + JsonNode report = report(r); + assertThat(report.get("connection").get("connect").has("timeout-ms")).isFalse(); + // Unlike the request timeout, this one has a schema-valid representation now that the key is + // optional, so the document stays valid. + assertConformsToSchema(report); + } + + @Test + public void should_omit_a_sub_millisecond_server_side_timeout() throws Exception { + // Also deliberately not floored: this value goes on the wire as the millisecond argument of a + // USING TIMEOUT clause, so sub-millisecond really is 0ms server-side. + DefaultDriverConfigReporter r = + defaultsReporter( + map -> + map.put( + TypedDriverOption.METADATA_SCHEMA_REQUEST_TIMEOUT, Duration.ofNanos(500_000))); + assertThat( + report(r) + .get("control-plane") + .get("queries") + .get("system") + .get("timeout") + .has("server-side-ms")) + .isFalse(); + } + + @Test + public void should_omit_server_side_timeout_when_schema_request_timeout_is_disabled() + throws Exception { + DefaultDriverConfigReporter r = + defaultsReporter( + map -> map.put(TypedDriverOption.METADATA_SCHEMA_REQUEST_TIMEOUT, Duration.ZERO)); + assertThat( + report(r) + .get("control-plane") + .get("queries") + .get("system") + .get("timeout") + .has("server-side-ms")) + .isFalse(); + } + + @Test + public void should_clamp_negative_schema_agreement_timeout_to_zero() throws Exception { + // Required and non-negative in the schema. A negative timeout behaves exactly like 0 (the first + // pass is already past the deadline), so normalizing is exact rather than invented. + DefaultDriverConfigReporter r = + defaultsReporter( + map -> + map.put( + TypedDriverOption.CONTROL_CONNECTION_AGREEMENT_TIMEOUT, + Duration.ofSeconds(-1))); + assertThat( + report(r) + .get("control-plane") + .get("schema") + .get("agreement") + .get("timeout-ms") + .asLong()) + .isZero(); + } + + @Test + public void should_report_configured_request_capacity() throws Exception { + DefaultDriverConfigReporter r = + defaultsReporter( + map -> { + map.put(TypedDriverOption.CONNECTION_MAX_REQUESTS, 2048); + map.put(TypedDriverOption.CONNECTION_MAX_ORPHAN_REQUESTS, 512); + }); + JsonNode requests = report(r).get("connection").get("requests"); + assertThat(requests.get("in-flight").get("max").asInt()).isEqualTo(2048); + assertThat(requests.get("orphaned").get("max").asInt()).isEqualTo(512); + } + + @Test + public void should_report_the_corrected_orphaned_request_threshold() throws Exception { + // ChannelFactory requires max-orphan-requests to stay below max-requests-per-connection and + // silently substitutes a quarter of the latter when it doesn't. Reporting the configured 1024 + // here would describe a threshold no connection was ever built with. + DefaultDriverConfigReporter r = + defaultsReporter( + map -> { + map.put(TypedDriverOption.CONNECTION_MAX_REQUESTS, 1024); + map.put(TypedDriverOption.CONNECTION_MAX_ORPHAN_REQUESTS, 1024); + }); + JsonNode report = report(r); + assertThat(report.get("connection").get("requests").get("orphaned").get("max").asInt()) + .isEqualTo(256); + assertConformsToSchema(report); + } + + @Test + public void should_report_the_maximum_supported_in_flight_request_count() throws Exception { + // The largest value reference.conf documents for this option ("less than 32768"). The schema no + // longer caps the field at all, so this and anything above it are valid documents; the only + // in-flight value it rejects is a non-positive one, pinned below. + DefaultDriverConfigReporter r = + defaultsReporter(map -> map.put(TypedDriverOption.CONNECTION_MAX_REQUESTS, 32767)); + JsonNode report = report(r); + assertThat(report.get("connection").get("requests").get("in-flight").get("max").asInt()) + .isEqualTo(32767); + assertConformsToSchema(report); + } + + @Test + public void should_report_an_in_flight_max_above_the_documented_bound() throws Exception { + // reference.conf documents "strictly positive, and less than 32768", but nothing enforces the + // upper half of that and the schema no longer encodes it either, so a value past it is simply + // reported — and now validates, where it used to be a knowingly invalid document. + DefaultDriverConfigReporter r = + defaultsReporter(map -> map.put(TypedDriverOption.CONNECTION_MAX_REQUESTS, 40000)); + JsonNode report = report(r); + assertThat(report.get("connection").get("requests").get("in-flight").get("max").asInt()) + .isEqualTo(40000); + assertConformsToSchema(report); + } + + @Test + public void should_omit_the_request_group_when_the_request_timeout_is_disabled() + throws Exception { + // basic.request.timeout = 0 legally disables the request timeout, and timeout-ms is + // positive-only — but both it and its enclosing "request" object are optional, so "disabled" is + // said by omission and the document stays valid. This used to be one of the schema gaps the + // reporter had to knowingly violate; making "request" optional closed it. + DefaultDriverConfigReporter r = + defaultsReporter(map -> map.put(TypedDriverOption.REQUEST_TIMEOUT, Duration.ZERO)); + JsonNode report = report(r); + assertThat(report.get("query").get("defaults").has("request")).isFalse(); + assertConformsToSchema(report); + } + + @Test + public void should_report_a_non_positive_in_flight_max_even_though_the_schema_forbids_it() + throws Exception { + // The schema requires a positive integer and nothing in the driver validates the option against + // that: ChannelFactory hands the configured value straight to StreamIdGenerator, which does not + // range-check it. Unreachable through a live session all the same — the connection fails first + // (a negative value throws out of StreamIdGenerator's BitSet while the channel is being built; + // zero leaves no stream id for the control connection's own OPTIONS) — so this pins the + // behavior at the seam rather than describing a live exposure: the reporter describes what is + // configured rather than substituting a limit no connection was built with. + DefaultDriverConfigReporter r = + defaultsReporter(map -> map.put(TypedDriverOption.CONNECTION_MAX_REQUESTS, 0)); + JsonNode report = report(r); + assertThat(report.get("connection").get("requests").get("in-flight").get("max").asInt()) + .isZero(); + assertThat(SCHEMA.validate(report)) + .as("the v1 schema requires a positive in-flight.max") + .isNotEmpty(); + } + + @Test + public void should_report_an_out_of_enum_consistency_even_though_the_schema_forbids_it() + throws Exception { + // The one knowingly invalid document a live session can actually produce, unlike its in-flight + // sibling above, and the same trade-off. basic.request.consistency is an + // unvalidated string while the schema's field is a closed enum, so a name outside it has no + // schema-valid form and the field is required, leaving no omission route. Reaching this takes a + // custom ConsistencyLevelRegistry that defines extra names (or a custom load balancing policy): + // the built-in policies resolve this option through the registry in their constructor, so an + // unknown name fails the session before any report is built. Reporting the effective + // configuration means passing it through rather than aliasing it to something the operator did + // not set, or dropping the whole report over one field. + DefaultDriverConfigReporter r = + defaultsReporter(map -> map.put(TypedDriverOption.REQUEST_CONSISTENCY, "SITE_QUORUM")); + JsonNode report = report(r); + assertThat(report.get("query").get("defaults").get("consistency").asText()) + .isEqualTo("SITE_QUORUM"); + assertThat(SCHEMA.validate(report)) + .as("the v1 schema's consistency enum has no member for a custom level") + .isNotEmpty(); + } + + @Test + public void should_report_the_serial_levels_as_a_default_consistency() throws Exception { + // basic.request.consistency accepts a serial level — a misconfiguration the server rejects for + // regular reads and writes, but one the driver itself never refuses — and the schema's enum now + // has members for both, so this is a valid document rather than the knowing violation it was. + for (String level : new String[] {"SERIAL", "LOCAL_SERIAL"}) { + DefaultDriverConfigReporter r = + defaultsReporter(map -> map.put(TypedDriverOption.REQUEST_CONSISTENCY, level)); + JsonNode report = report(r); + assertThat(report.get("query").get("defaults").get("consistency").asText()).isEqualTo(level); + assertConformsToSchema(report); + } + } + + @Test + public void should_omit_a_serial_consistency_the_schema_has_no_member_for() throws Exception { + // The mirror image of the case above, and the reason it is not a second known gap: + // basic.request.serial-consistency is just as unvalidated (nothing rejects a non-serial level + // before the first conditional statement runs), but the schema's field is *optional*, so the + // value can be said by omission instead of breaking the whole document. + DefaultDriverConfigReporter r = + defaultsReporter(map -> map.put(TypedDriverOption.REQUEST_SERIAL_CONSISTENCY, "QUORUM")); + JsonNode report = report(r); + assertThat(report.get("query").get("defaults").has("serial-consistency")).isFalse(); + assertConformsToSchema(report); + } + + // ==================== Options a config source may not define ==================== + // + // reference.conf and OptionsMap.driverDefaults() both cover every option this reporter reads, but + // a custom DriverConfigLoader need not, and the no-fallback getters throw on a missing option. No + // read may cost more than the field it describes: an optional field is omitted, a required one + // falls back to the value reference.conf documents. + + @Test + public void should_omit_optional_fields_whose_options_are_undefined() throws Exception { + // Each of these maps to a schema field or group that is optional, so an undefined option is + // reported the same way a disabled one is: by omission, leaving a valid document. + DefaultDriverConfigReporter r = + defaultsReporter( + map -> { + map.remove(TypedDriverOption.CONNECTION_CONNECT_TIMEOUT); + map.remove(TypedDriverOption.CONTROL_CONNECTION_TIMEOUT); + map.remove(TypedDriverOption.METADATA_SCHEMA_REQUEST_TIMEOUT); + map.remove(TypedDriverOption.REQUEST_PAGE_SIZE); + map.remove(TypedDriverOption.REQUEST_TIMEOUT); + }); + JsonNode report = report(r); + assertThat(report.get("connection").get("connect").has("timeout-ms")).isFalse(); + JsonNode systemTimeout = + report.get("control-plane").get("queries").get("system").get("timeout"); + assertThat(systemTimeout.has("client-side-ms")).isFalse(); + assertThat(systemTimeout.has("server-side-ms")).isFalse(); + JsonNode defaults = report.get("query").get("defaults"); + assertThat(defaults.has("page")).isFalse(); + assertThat(defaults.has("request")).isFalse(); + assertConformsToSchema(report); + } + + @Test + public void should_report_the_speculative_execution_group_when_its_options_are_undefined() + throws Exception { + // max-executions and delay-ms are both required within the constant variant, and neither is + // read from the profile: they come off the policy, which could not have been built at all had + // the options been undefined then. So an option missing now costs the group nothing. + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> map.remove(TypedDriverOption.SPECULATIVE_EXECUTION_MAX)), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + constantSpeculativeExecution(3, 100), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()); + JsonNode report = report(r); + JsonNode specExec = report.get("query").get("speculative-execution").get("policy"); + assertThat(specExec.get("max-executions").asInt()).isEqualTo(2); + assertThat(specExec.get("delay-ms").asLong()).isEqualTo(100); + assertConformsToSchema(report); + } + + @Test + public void should_still_report_when_a_required_fields_option_is_undefined() throws Exception { + // These five map to required schema fields, where omission would invalidate the whole document, + // so each falls back to the value reference.conf documents. Removing all five at once is the + // worst case: the report is still built, still complete, and still valid. + DefaultDriverConfigReporter r = + defaultsReporter( + map -> { + map.remove(TypedDriverOption.CONNECTION_MAX_REQUESTS); + map.remove(TypedDriverOption.CONNECTION_MAX_ORPHAN_REQUESTS); + map.remove(TypedDriverOption.CONTROL_CONNECTION_AGREEMENT_TIMEOUT); + map.remove(TypedDriverOption.REQUEST_CONSISTENCY); + map.remove(TypedDriverOption.REQUEST_DEFAULT_IDEMPOTENCE); + }); + JsonNode report = report(r); + JsonNode requests = report.get("connection").get("requests"); + assertThat(requests.get("in-flight").get("max").asInt()).isEqualTo(1024); + assertThat(requests.get("orphaned").get("max").asInt()).isEqualTo(256); + assertThat( + report.get("control-plane").get("schema").get("agreement").get("timeout-ms").asLong()) + .isEqualTo(10_000); + JsonNode defaults = report.get("query").get("defaults"); + assertThat(defaults.get("consistency").asText()).isEqualTo("LOCAL_ONE"); + assertThat(defaults.get("idempotence").asBoolean()).isFalse(); + assertConformsToSchema(report); + } + + @Test + public void should_report_both_serial_consistency_levels() throws Exception { + // Both members of the schema's enum survive the guard above; SERIAL is the shipped default and + // is asserted by the default-report test, so this pins the other one. + DefaultDriverConfigReporter r = + defaultsReporter( + map -> map.put(TypedDriverOption.REQUEST_SERIAL_CONSISTENCY, "LOCAL_SERIAL")); + JsonNode report = report(r); + assertThat(report.get("query").get("defaults").get("serial-consistency").asText()) + .isEqualTo("LOCAL_SERIAL"); + assertConformsToSchema(report); + } + + @Test + public void should_report_reconnection_delays_from_the_running_policy_not_the_profile() + throws Exception { + // Both built-in reconnection policies latch their delays into final fields at construction and + // never re-read them, so after a configuration reload the profile describes delays that nothing + // is reconnecting with. The instance is the only accurate source; the profile here deliberately + // carries different numbers, standing in for a reload the running policy has not seen. + ExponentialReconnectionPolicy policy = mock(ExponentialReconnectionPolicy.class); + when(policy.getBaseDelayMs()).thenReturn(2000L); + when(policy.getMaxDelayMs()).thenReturn(90000L); + DefaultDriverConfigReporter r = + reporterWith( + defaults( + map -> { + map.put(TypedDriverOption.RECONNECTION_BASE_DELAY, Duration.ofSeconds(7)); + map.put(TypedDriverOption.RECONNECTION_MAX_DELAY, Duration.ofSeconds(11)); + }), + policy, + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()); + JsonNode report = report(r); + JsonNode reconnection = report.get("connection").get("reconnection").get("policy"); + assertThat(reconnection.get("base-ms").asLong()).isEqualTo(2000L); + assertThat(reconnection.get("max-ms").asLong()).isEqualTo(90000L); + assertConformsToSchema(report); + } + + @Test + public void should_report_a_zero_constant_reconnection_delay() throws Exception { + // ConstantReconnectionPolicy rejects only a negative base delay, so 0 is a legal setting + // (reconnect immediately, no backoff) — unlike ExponentialReconnectionPolicy, which requires a + // strictly positive base. The schema admits it too ("0 means reconnect immediately"), so this + // is + // reported verbatim and the document stays valid. + // + // The delay is stubbed on the policy, not just set in the profile, because the reporter reads + // the running instance — see exponentialReconnection(). + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> map.put(TypedDriverOption.RECONNECTION_BASE_DELAY, Duration.ZERO)), + constantReconnection(Duration.ZERO), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()); + JsonNode report = report(r); + JsonNode reconnection = report.get("connection").get("reconnection").get("policy"); + assertThat(reconnection.get("type").asText()).isEqualTo("constant"); + assertThat(reconnection.get("delay-ms").asLong()).isZero(); + assertConformsToSchema(report); + } + + @Test + public void should_report_one_millisecond_for_a_sub_millisecond_constant_reconnection_delay() + throws Exception { + // The counterpart to the zero case above: ConstantReconnectionPolicy also accepts a positive + // sub-millisecond delay, and Reconnection schedules nextDelay() in nanoseconds, so that delay + // really does elapse between attempts. Truncating it would report the one value the schema + // defines as "reconnect immediately" for a policy that does back off. + DefaultDriverConfigReporter r = + reporterWith( + defaults( + map -> + map.put(TypedDriverOption.RECONNECTION_BASE_DELAY, Duration.ofNanos(500_000))), + constantReconnection(Duration.ofNanos(500_000)), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()); + JsonNode report = report(r); + JsonNode reconnection = report.get("connection").get("reconnection").get("policy"); + assertThat(reconnection.get("type").asText()).isEqualTo("constant"); + assertThat(reconnection.get("delay-ms").asLong()).isEqualTo(1L); + assertConformsToSchema(report); + } + + @Test + public void should_report_a_zero_speculative_execution_delay() throws Exception { + // ConstantSpeculativeExecutionPolicy explicitly allows a zero delay ("Delay must be positive or + // 0"), meaning every speculative execution fires at once, and the schema admits it ("0 means + // launch immediately"). reference.conf also documents sub-millisecond delays as equivalent to + // 0, + // which is why this field is not floored at 1 the way the timeouts are — the policy has already + // truncated them to the 0 it schedules with. + DefaultDriverConfigReporter r = + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + constantSpeculativeExecution(3, 0), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()); + JsonNode report = report(r); + JsonNode specExec = report.get("query").get("speculative-execution").get("policy"); + assertThat(specExec.get("type").asText()).isEqualTo("constant"); + assertThat(specExec.get("delay-ms").asLong()).isZero(); + assertConformsToSchema(report); + } + + @Test + public void should_omit_page_when_unbounded() throws Exception { + DefaultDriverConfigReporter r = + defaultsReporter(map -> map.put(TypedDriverOption.REQUEST_PAGE_SIZE, 0)); + // The schema has no "unbounded" sentinel: the whole page group is omitted instead. + assertThat(report(r).get("query").get("defaults").has("page")).isFalse(); + } + + @Test + public void should_report_bounded_page_size() throws Exception { + DefaultDriverConfigReporter r = + defaultsReporter(map -> map.put(TypedDriverOption.REQUEST_PAGE_SIZE, 5000)); + assertThat(report(r).get("query").get("defaults").get("page").get("size").asInt()) + .isEqualTo(5000); + } + + @Test + public void should_report_shard_awareness_under_connection_pool() throws Exception { + // The schema keeps no pool size or keying, so what is left of pooling is the shard-awareness + // intent — configuration, not an observed effect: the option describes how a connection reaches + // a chosen shard, not what the server turns out to be. + DefaultDriverConfigReporter r = + defaultsReporter( + map -> map.put(TypedDriverOption.CONNECTION_ADVANCED_SHARD_AWARENESS_ENABLED, false)); + JsonNode pool = report(r).get("connection").get("pool"); + assertThat(pool.get("shard-aware").get("enabled").asBoolean()).isFalse(); + } + + @Test + public void should_not_report_a_pool_size_the_schema_no_longer_carries() throws Exception { + // CONNECTION_POOL_LOCAL_SIZE has no schema slot any more, which is also what settles the + // local-versus-remote question: neither size is reported, so neither can misstate the other. + DefaultDriverConfigReporter r = + defaultsReporter( + map -> { + map.put(TypedDriverOption.CONNECTION_POOL_LOCAL_SIZE, 8); + map.put(TypedDriverOption.CONNECTION_POOL_REMOTE_SIZE, 3); + }); + JsonNode report = report(r); + assertThat(report.get("connection").get("pool").fieldNames()) + .toIterable() + .containsExactly("shard-aware"); + assertConformsToSchema(report); + } + + // ==================== Schema conformance ==================== + // + // These build a config, serialize it via the reporter, and validate the produced JSON against the + // normative v1 JSON Schema (the same document ScyllaDB uses to interpret DRIVER_CONFIG). They + // cover every discriminated-union branch and optional-group case the reporter can emit, so that + // schema conformance is enforced rather than assumed. + // + // Conformance is not unconditional: where a required schema field is positive-only and its driver + // option legitimately admits 0, the reporter emits the real value and the document does not + // validate. Those cases are deliberate and pinned in the "Values the schema cannot express" + // section above, each asserting the violation explicitly — see the reporter's class javadoc. + + @Test + public void should_conform_to_schema_for_default_report() throws Exception { + assertConformsToSchema(report(defaultsReporter(map -> {}))); + } + + @Test + public void should_conform_to_schema_for_constant_reconnection_policy() throws Exception { + assertConformsToSchema( + report( + reporterWith( + defaults(map -> {}), + constantReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()))); + } + + @Test + public void should_conform_to_schema_for_custom_reconnection_policy() throws Exception { + assertConformsToSchema( + report( + reporterWith( + defaults(map -> {}), + mock(ReconnectionPolicy.class), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()))); + } + + @Test + public void should_conform_to_schema_for_downgrading_consistency_retry_policy() throws Exception { + assertConformsToSchema( + report( + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(ConsistencyDowngradingRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()))); + } + + @Test + public void should_conform_to_schema_for_custom_retry_policy() throws Exception { + assertConformsToSchema( + report( + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(RetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()))); + } + + @Test + public void should_conform_to_schema_for_constant_speculative_execution_policy() + throws Exception { + assertConformsToSchema( + report( + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + constantSpeculativeExecution(3, 100), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()))); + } + + @Test + public void should_conform_to_schema_for_custom_speculative_execution_policy() throws Exception { + // The custom variant of the one discriminated union whose branches were otherwise only pinned + // for the built-in: a policy that is neither of the two built-ins keeps the enclosing group + // (unlike NoSpeculativeExecutionPolicy, which drops it) and carries just type + name. + assertConformsToSchema( + report( + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(SpeculativeExecutionPolicy.class), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()))); + } + + @Test + public void should_conform_to_schema_for_basic_load_balancing_policy() throws Exception { + assertConformsToSchema( + report( + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(BasicLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()))); + } + + @Test + public void should_conform_to_schema_for_custom_load_balancing_policy() throws Exception { + assertConformsToSchema( + report( + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + mock(LoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()))); + } + + @Test + public void should_conform_to_schema_for_explicit_dc_and_rack() throws Exception { + assertConformsToSchema( + report( + defaultsReporter( + map -> { + map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_DATACENTER, "dc1"); + map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_RACK, "rack1"); + }))); + } + + @Test + public void should_conform_to_schema_for_rack_auto_node_location() throws Exception { + assertConformsToSchema( + report( + defaultsReporter( + map -> map.put(TypedDriverOption.LOAD_BALANCING_LOCAL_RACK, "rack1")))); + } + + @Test + public void should_conform_to_schema_for_tls_enabled_with_hostname_verification() + throws Exception { + SslEngineFactory factory = + new ProgrammaticSslEngineFactory( + SSLContext.getDefault(), null, /* requireHostnameValidation= */ true); + assertConformsToSchema( + report( + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.of(factory)))); + } + + @Test + public void should_conform_to_schema_for_socket_overrides() throws Exception { + assertConformsToSchema( + report( + defaultsReporter( + map -> { + map.put(TypedDriverOption.SOCKET_KEEP_ALIVE, true); + map.put(TypedDriverOption.SOCKET_RECEIVE_BUFFER_SIZE, 65535); + map.put(TypedDriverOption.SOCKET_SEND_BUFFER_SIZE, 65535); + map.put(TypedDriverOption.SOCKET_LINGER_INTERVAL, 5); + }))); + } + + @Test + public void should_conform_to_schema_without_a_node_location_preference() throws Exception { + // The datacenter-agnostic basic policy omits the group entirely; it is optional, so the + // document + // stays valid without it. + JsonNode report = + report( + reporterWith( + defaults(map -> {}), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(BasicLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty())); + assertThat(report.get("query").get("load-balancing").has("node-preference")).isFalse(); + assertConformsToSchema(report); + } + + @Test + public void should_conform_to_schema_when_all_optional_timeouts_are_disabled() throws Exception { + // The case the omission guards exist for: every control-plane timeout that can be turned off + // is, leaving system-queries.timeout an empty object — which validates, since that object has + // no required keys. + assertConformsToSchema( + report( + defaultsReporter( + map -> { + map.put(TypedDriverOption.CONTROL_CONNECTION_TIMEOUT, Duration.ZERO); + map.put(TypedDriverOption.METADATA_SCHEMA_REQUEST_TIMEOUT, Duration.ZERO); + map.put(TypedDriverOption.CONTROL_CONNECTION_AGREEMENT_TIMEOUT, Duration.ZERO); + }))); + } + + @Test + public void should_conform_to_schema_when_optional_socket_and_page_values_are_disabled() + throws Exception { + assertConformsToSchema( + report( + defaultsReporter( + map -> { + map.put(TypedDriverOption.SOCKET_LINGER_INTERVAL, -1); + map.put(TypedDriverOption.SOCKET_RECEIVE_BUFFER_SIZE, 0); + map.put(TypedDriverOption.SOCKET_SEND_BUFFER_SIZE, 0); + map.put(TypedDriverOption.CONNECTION_CONNECT_TIMEOUT, Duration.ZERO); + map.put(TypedDriverOption.REQUEST_PAGE_SIZE, 0); + }))); + } + + @Test + public void should_reject_a_report_that_violates_the_schema() throws Exception { + // Sanity check that the validator actually enforces the schema (rather than accepting + // anything): an unknown top-level key must be rejected, since the schema sets + // additionalProperties to false. + ObjectNode report = (ObjectNode) report(defaultsReporter(map -> {})); + report.put("bogus-unknown-key", "x"); + assertThat(SCHEMA.validate(report)).as("unknown top-level key must be rejected").isNotEmpty(); + } + + // ==================== helpers ==================== + + private void assertConformsToSchema(JsonNode report) { + Set errors = SCHEMA.validate(report); + assertThat(errors).as("schema violations in %s", report).isEmpty(); + } + + /** + * Asserts the shape every built-in load balancing policy shares, given whether it reorders + * candidates from runtime observations. + */ + private void assertBuiltInLoadBalancingPolicy(JsonNode lb, boolean adaptiveOrdering) { + assertThat(lb.get("type").asText()).isEqualTo("token-aware"); + assertThat(lb.get("load-distribution").asText()).isEqualTo("shuffle"); + // The schema has no "enabled" flag and requires a non-empty signal list, so adaptive ordering + // is reported by the group's presence: absent when off, never present-but-empty. + if (adaptiveOrdering) { + assertThat(lb.get("adaptive-ordering").get("signals")) + .extracting(JsonNode::asText) + .containsExactly("response-rate", "in-flight-requests", "recovery-state"); + } else { + assertThat(lb.has("adaptive-ordering")).isFalse(); + } + } + + /** + * An exponential reconnection policy with its delays stubbed. + * + *

The reporter reads these off the instance rather than the profile, because both built-ins + * latch their delays at construction and a reload does not reach the running policy. A bare mock + * would answer 0 for both, which the schema rejects — and would not exercise the accessor path at + * all. + */ + private static ExponentialReconnectionPolicy exponentialReconnection() { + ExponentialReconnectionPolicy policy = mock(ExponentialReconnectionPolicy.class); + when(policy.getBaseDelayMs()).thenReturn(1000L); + when(policy.getMaxDelayMs()).thenReturn(60000L); + return policy; + } + + /** + * A constant reconnection policy with its delay stubbed; see {@link #exponentialReconnection}. + */ + private static ConstantReconnectionPolicy constantReconnection() { + return constantReconnection(Duration.ofSeconds(1)); + } + + private static ConstantReconnectionPolicy constantReconnection(Duration delay) { + ConstantReconnectionPolicy policy = mock(ConstantReconnectionPolicy.class); + when(policy.getDelay()).thenReturn(delay); + return policy; + } + + /** + * A constant speculative execution policy with its parameters stubbed; read off the instance for + * the same reason as the reconnection delays, see {@link #exponentialReconnection}. + * + * @param maxExecutions counted the way the policy counts it — including the initial, + * non-speculative execution, so the report shows one less. + */ + private static ConstantSpeculativeExecutionPolicy constantSpeculativeExecution( + int maxExecutions, long delayMillis) { + ConstantSpeculativeExecutionPolicy policy = mock(ConstantSpeculativeExecutionPolicy.class); + when(policy.getMaxExecutions()).thenReturn(maxExecutions); + when(policy.getConstantDelayMillis()).thenReturn(delayMillis); + return policy; + } + + /** + * A built-in load balancing policy carrying the state it would have latched from the shipped + * defaults; read off the instance for the same reason as the reconnection delays, see {@link + * #exponentialReconnection}. + */ + private static T loadBalancing(Class policyClass) { + return loadBalancing(policyClass, /* avoidSlowReplicas= */ true, /* maxNodesPerRemoteDc= */ 0); + } + + /** + * Same, with the two latched values set explicitly — so a test can put the profile and the + * running policy deliberately out of step, the way a configuration reload does. + * + * @param avoidSlowReplicas ignored for {@link BasicLoadBalancingPolicy}, which has no + * slow-replica-avoidance mechanism to latch. + */ + private static T loadBalancing( + Class policyClass, boolean avoidSlowReplicas, int maxNodesPerRemoteDc) { + T policy = mock(policyClass); + when(policy.getMaxNodesPerRemoteDc()).thenReturn(maxNodesPerRemoteDc); + if (policy instanceof DefaultLoadBalancingPolicy) { + when(((DefaultLoadBalancingPolicy) policy).isAvoidingSlowReplicas()) + .thenReturn(avoidSlowReplicas); + } + return policy; + } + + /** + * A timestamp generator that assigns timestamps client-side, which is what every built-in but + * {@link ServerSideTimestampGenerator} does. + * + *

A mock of the concrete built-in rather than of the interface: the reporter recognizes the + * driver's own generators by type, so a bare {@code mock(TimestampGenerator.class)} would be the + * unrecognized case and silently omit {@code client-timestamps} from every report built through + * this helper. Mockito's inline mock maker (the default since 5.x) instruments the class itself + * instead of subclassing it, so the mock satisfies the reporter's {@code instanceof}. + */ + private static TimestampGenerator clientSideGenerator() { + return mock(AtomicTimestampGenerator.class); + } + + private JsonNode report(DefaultDriverConfigReporter reporter) throws Exception { + return MAPPER.readTree(reporter.buildJson()); + } + + /** A real default execution profile with the given customizations applied. */ + private DriverExecutionProfile defaults(Consumer customizer) { + OptionsMap map = OptionsMap.driverDefaults(); + customizer.accept(map); + return DriverConfigLoader.fromMap(map).getInitialConfig().getDefaultProfile(); + } + + /** Reporter over default config + the Java-default policy set. */ + private DefaultDriverConfigReporter defaultsReporter(Consumer customizer) { + return reporterWith( + defaults(customizer), + exponentialReconnection(), + mock(DefaultRetryPolicy.class), + mock(NoSpeculativeExecutionPolicy.class), + loadBalancing(DefaultLoadBalancingPolicy.class), + clientSideGenerator(), + Optional.empty()); + } + + private DefaultDriverConfigReporter reporterWith( + DriverExecutionProfile profile, + ReconnectionPolicy reconnection, + RetryPolicy retry, + SpeculativeExecutionPolicy speculative, + LoadBalancingPolicy loadBalancing, + TimestampGenerator timestamps, + Optional ssl) { + return reporterWith( + profile, reconnection, retry, speculative, loadBalancing, timestamps, ssl, null); + } + + /** Same as the 7-arg overload, with an optional programmatic ({@code withLocalDatacenter}) DC. */ + private DefaultDriverConfigReporter reporterWith( + DriverExecutionProfile profile, + ReconnectionPolicy reconnection, + RetryPolicy retry, + SpeculativeExecutionPolicy speculative, + LoadBalancingPolicy loadBalancing, + TimestampGenerator timestamps, + Optional ssl, + String programmaticLocalDc) { + // tls.enabled reads the low-level handler factory, which DefaultDriverContext derives from the + // engine factory when SSL was configured through the public API; mirror that wrapping here. + return reporterWith( + profile, + reconnection, + retry, + speculative, + loadBalancing, + timestamps, + ssl, + ssl.map(JdkSslHandlerFactory::new), + programmaticLocalDc); + } + + /** + * Same as the 8-arg overload, with the SSL handler factory set independently of the engine + * factory — as an override of {@code DefaultDriverContext.buildSslHandlerFactory()} would. + */ + private DefaultDriverConfigReporter reporterWith( + DriverExecutionProfile profile, + ReconnectionPolicy reconnection, + RetryPolicy retry, + SpeculativeExecutionPolicy speculative, + LoadBalancingPolicy loadBalancing, + TimestampGenerator timestamps, + Optional ssl, + Optional sslHandler, + String programmaticLocalDc) { + return new DefaultDriverConfigReporter( + contextWith( + profile, + reconnection, + retry, + speculative, + loadBalancing, + timestamps, + ssl, + sslHandler, + programmaticLocalDc)); + } + + /** + * The context the reporters above read from. Separate from {@link #reporterWith} only so that a + * test can build a reporter subclass over it, the way the {@code simpleName} seam needs. + */ + private InternalDriverContext contextWith( + DriverExecutionProfile profile, + ReconnectionPolicy reconnection, + RetryPolicy retry, + SpeculativeExecutionPolicy speculative, + LoadBalancingPolicy loadBalancing, + TimestampGenerator timestamps, + Optional ssl, + Optional sslHandler, + String programmaticLocalDc) { + InternalDriverContext ctx = mock(InternalDriverContext.class); + DriverConfig config = mock(DriverConfig.class); + when(ctx.getConfig()).thenReturn(config); + when(config.getDefaultProfile()).thenReturn(profile); + when(ctx.getReconnectionPolicy()).thenReturn(reconnection); + when(ctx.getRetryPolicy(DriverExecutionProfile.DEFAULT_NAME)).thenReturn(retry); + when(ctx.getSpeculativeExecutionPolicy(DriverExecutionProfile.DEFAULT_NAME)) + .thenReturn(speculative); + when(ctx.getLoadBalancingPolicy(DriverExecutionProfile.DEFAULT_NAME)).thenReturn(loadBalancing); + when(ctx.getTimestampGenerator()).thenReturn(timestamps); + when(ctx.getSslEngineFactory()).thenReturn(ssl); + when(ctx.getSslHandlerFactory()).thenReturn(sslHandler); + when(ctx.getLocalDatacenter(DriverExecutionProfile.DEFAULT_NAME)) + .thenReturn(programmaticLocalDc); + return ctx; + } + + /** A minimal {@link DriverContext} good enough to construct a real built-in policy instance. */ + private DriverContext policyConstructionContext() { + DriverContext ctx = mock(DriverContext.class); + DriverConfig config = mock(DriverConfig.class); + DriverExecutionProfile profile = defaults(map -> {}); + when(ctx.getConfig()).thenReturn(config); + when(config.getDefaultProfile()).thenReturn(profile); + when(config.getProfile(DriverExecutionProfile.DEFAULT_NAME)).thenReturn(profile); + when(ctx.getSessionName()).thenReturn("test-session"); + return ctx; + } } diff --git a/integration-tests/src/test/java/com/datastax/oss/driver/core/config/DriverConfigReportingAssertions.java b/integration-tests/src/test/java/com/datastax/oss/driver/core/config/DriverConfigReportingAssertions.java new file mode 100644 index 00000000000..df3ee46ca56 --- /dev/null +++ b/integration-tests/src/test/java/com/datastax/oss/driver/core/config/DriverConfigReportingAssertions.java @@ -0,0 +1,67 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.datastax.oss.driver.core.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; + +/** Shared assertions for the driver-config-reporting integration tests. */ +class DriverConfigReportingAssertions { + + // FAIL_ON_TRAILING_TOKENS rejects a valid JSON value followed by garbage; the payload is read + // below via readValue(..), which honors this feature reliably (readTree historically does not). + private static final ObjectMapper OBJECT_MAPPER = + JsonMapper.builder().enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).build(); + + private DriverConfigReportingAssertions() {} + + /** + * Asserts that a {@code DRIVER_CONFIG} value is a well-formed stage-2 report: valid JSON whose + * {@code version} is the integer {@code 1} and that carries the full configuration payload + * (checked here via the always-present, backend-agnostic {@code query.load-balancing.policy} + * group). Guards against an incorrect schema version, a malformed blob, or an empty/stage-1-only + * payload slipping through a mere key-presence check. + * + * @return the parsed report, so callers can assert on backend-specific groups too. + */ + static JsonNode assertDriverConfigPayload(String driverConfig) { + JsonNode root; + try { + root = OBJECT_MAPPER.readValue(driverConfig, JsonNode.class); + } catch (JsonProcessingException e) { + throw new AssertionError("DRIVER_CONFIG is not valid JSON: " + driverConfig, e); + } + assertThat(root.path("version").isInt()) + .as("version is an integer in %s", driverConfig) + .isTrue(); + assertThat(root.path("version").intValue()).isEqualTo(1); + JsonNode loadBalancingPolicy = root.path("query").path("load-balancing").path("policy"); + assertThat(loadBalancingPolicy.isObject()) + .as("query.load-balancing.policy is an object in %s", driverConfig) + .isTrue(); + assertThat(loadBalancingPolicy.path("type").isTextual()) + .as("query.load-balancing.policy.type is present in %s", driverConfig) + .isTrue(); + return root; + } +} diff --git a/integration-tests/src/test/java/com/datastax/oss/driver/core/config/DriverConfigReportingCcmIT.java b/integration-tests/src/test/java/com/datastax/oss/driver/core/config/DriverConfigReportingCcmIT.java index f3e453d289f..2683b61f9b9 100644 --- a/integration-tests/src/test/java/com/datastax/oss/driver/core/config/DriverConfigReportingCcmIT.java +++ b/integration-tests/src/test/java/com/datastax/oss/driver/core/config/DriverConfigReportingCcmIT.java @@ -17,6 +17,7 @@ */ package com.datastax.oss.driver.core.config; +import static com.datastax.oss.driver.core.config.DriverConfigReportingAssertions.assertDriverConfigPayload; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; @@ -32,14 +33,12 @@ import com.datastax.oss.driver.categories.ParallelizableTests; import com.datastax.oss.driver.internal.core.context.InternalDriverContext; import com.datastax.oss.driver.internal.core.context.StartupOptionsBuilder; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.InetSocketAddress; import java.time.Duration; import java.util.Collections; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @@ -57,11 +56,14 @@ * Simulacron only proves what the driver sends, this confirms that a real server * accepts the extra {@code STARTUP} keys and stores them, so that (a) {@code * SESSION_ID} is present on every one of the session's connections with a single shared value, and - * (b) {@code DRIVER_CONFIG} is stored for exactly one connection (the control connection). + * (b) {@code DRIVER_CONFIG} is stored for exactly one connection, which is the control connection — + * matched by address and port against the control connection's channel, so the check cannot be + * satisfied by a pooled connection. * - *

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

The report itself describes the driver's own configuration, so it reads identically on both + * backends; only the table that exposes the stored options differs: ScyllaDB uses {@code + * system.clients.client_options}, while Apache Cassandra exposes it in {@code + * system_views.clients.client_options} (added in Cassandra 4.1). */ @Category(ParallelizableTests.class) @BackendRequirement( @@ -76,8 +78,6 @@ public class DriverConfigReportingCcmIT { private static final String DRIVER_NAME = "ScyllaDB Java Driver"; - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private static final CcmRule CCM_RULE = CcmRule.getInstance(); private static final SessionRule SESSION_RULE = @@ -134,34 +134,48 @@ public void should_store_session_id_on_all_connections_and_driver_config_on_cont rows.stream().map(row -> clientOptions(row).get("SESSION_ID")).collect(Collectors.toSet()); assertThat(sessionIds).containsExactly(sessionId(session)); - // (b) DRIVER_CONFIG is stored for exactly one connection (the control connection), and its - // value round-trips through the server intact as the stage-1 payload: valid JSON carrying - // exactly the schema version. - List driverConfigs = + // (b) DRIVER_CONFIG is stored for exactly one connection, and that connection is the control + // one — identified independently of the reported options, by the local address and port of the + // control connection's channel (which is what the server records as the client's address). + List withDriverConfig = rows.stream() - .map(row -> clientOptions(row).get("DRIVER_CONFIG")) - .filter(Objects::nonNull) + .filter(row -> clientOptions(row).get("DRIVER_CONFIG") != null) .collect(Collectors.toList()); - assertThat(driverConfigs).hasSize(1); - assertStageOnePayload(driverConfigs.get(0)); + assertThat(withDriverConfig).hasSize(1); + + Row controlRow = withDriverConfig.get(0); + InetSocketAddress controlAddress = controlConnectionAddress(session); + assertThat(controlRow.getInetAddress("address")).isEqualTo(controlAddress.getAddress()); + assertThat(controlRow.getInt("port")).isEqualTo(controlAddress.getPort()); + + // Its value round-trips through the server intact as the stage-2 payload: valid JSON carrying + // the schema version and the full configuration. + JsonNode report = assertDriverConfigPayload(clientOptions(controlRow).get("DRIVER_CONFIG")); + + // The report is configuration, not observation, so every field reads the same on both backends + // — including the server-side internal-query timeout, even though only ScyllaDB actually gets + // the "USING TIMEOUT" clause that turns advanced.metadata.schema.request-timeout into a + // server-side limit. + JsonNode timeout = report.path("control-plane").path("queries").path("system").path("timeout"); + assertThat(timeout.path("server-side-ms").asLong()).isPositive(); + + // Likewise the shard-awareness intent: a property of the configuration, not of the peer. + assertThat( + report.path("connection").path("pool").path("shard-aware").path("enabled").asBoolean()) + .isTrue(); } /** - * Asserts that a {@code DRIVER_CONFIG} value is the stage-1 payload: well-formed JSON whose - * {@code version} is the integer {@code 1}. Guards against an incorrect schema version or a - * malformed blob slipping through a mere key-presence check. + * The local address of the control connection's channel — the source address of that TCP + * connection, and therefore what the server records in the clients table's {@code address} and + * {@code port} columns (CCM connects directly, with no proxy or address translation in between). */ - private static void assertStageOnePayload(String driverConfig) { - JsonNode root; - try { - root = OBJECT_MAPPER.readTree(driverConfig); - } catch (JsonProcessingException e) { - throw new AssertionError("DRIVER_CONFIG is not valid JSON: " + driverConfig, e); - } - assertThat(root.path("version").isInt()) - .as("version is an integer in %s", driverConfig) - .isTrue(); - assertThat(root.path("version").intValue()).isEqualTo(1); + private InetSocketAddress controlConnectionAddress(CqlSession session) { + return (InetSocketAddress) + ((InternalDriverContext) session.getContext()) + .getControlConnection() + .channel() + .localAddress(); } /** diff --git a/integration-tests/src/test/java/com/datastax/oss/driver/core/config/DriverConfigReportingSimulacronIT.java b/integration-tests/src/test/java/com/datastax/oss/driver/core/config/DriverConfigReportingSimulacronIT.java index 11b5d286635..262459f84a4 100644 --- a/integration-tests/src/test/java/com/datastax/oss/driver/core/config/DriverConfigReportingSimulacronIT.java +++ b/integration-tests/src/test/java/com/datastax/oss/driver/core/config/DriverConfigReportingSimulacronIT.java @@ -17,6 +17,7 @@ */ package com.datastax.oss.driver.core.config; +import static com.datastax.oss.driver.core.config.DriverConfigReportingAssertions.assertDriverConfigPayload; import static com.datastax.oss.driver.internal.core.context.DefaultDriverConfigReporter.DRIVER_CONFIG_KEY; import static com.datastax.oss.driver.internal.core.context.StartupOptionsBuilder.CLIENT_ID_KEY; import static com.datastax.oss.driver.internal.core.context.StartupOptionsBuilder.SESSION_ID_KEY; @@ -33,9 +34,6 @@ import com.datastax.oss.protocol.internal.request.Startup; import com.datastax.oss.simulacron.common.cluster.ClusterSpec; import com.datastax.oss.simulacron.common.cluster.QueryLog; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import java.net.SocketAddress; import java.util.List; import java.util.Map; @@ -59,7 +57,7 @@ * whatever {@code advanced.driver-config-reporting.enabled} is set to — it is an * innate startup option, not part of configuration reporting; *

  • {@code DRIVER_CONFIG} is present only on the control connection, and only when {@code - * advanced.driver-config-reporting.enabled} is true. + * advanced.driver-config-reporting.enabled} is true (which is the default). * * *

    The control connection is identified independently of the reported options: it is the only @@ -75,8 +73,6 @@ @Category(ParallelizableTests.class) public class DriverConfigReportingSimulacronIT { - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - // A single node yields one dedicated control connection plus a pool connection (local.size // defaults to 1), i.e. at least two distinct session connections of which only the control one // registers for events. @@ -118,27 +114,10 @@ public void should_report_session_id_on_all_connections_and_driver_config_only_o assertThat(withDriverConfig).hasSize(1); assertThat(withDriverConfig.get(0).getConnection()).isEqualTo(controlConnection); - // The payload is the stage-1 report: valid JSON carrying exactly the schema version. - assertStageOnePayload(options(withDriverConfig.get(0)).get(DRIVER_CONFIG_KEY)); - } - } - - /** - * Asserts that a {@code DRIVER_CONFIG} value is the stage-1 payload: well-formed JSON whose - * {@code version} is the integer {@code 1}. Guards against an incorrect schema version or a - * malformed blob slipping through a mere key-presence check. - */ - private static void assertStageOnePayload(String driverConfig) { - JsonNode root; - try { - root = OBJECT_MAPPER.readTree(driverConfig); - } catch (JsonProcessingException e) { - throw new AssertionError("DRIVER_CONFIG is not valid JSON: " + driverConfig, e); + // The payload is the stage-2 report: valid JSON carrying the schema version and the full + // configuration (checked here via the always-present query.load-balancing.policy group). + assertDriverConfigPayload(options(withDriverConfig.get(0)).get(DRIVER_CONFIG_KEY)); } - assertThat(root.path("version").isInt()) - .as("version is an integer in %s", driverConfig) - .isTrue(); - assertThat(root.path("version").intValue()).isEqualTo(1); } @Test @@ -163,6 +142,25 @@ public void should_still_report_session_id_when_driver_config_reporting_is_disab } } + @Test + public void should_report_driver_config_by_default() { + // No override for advanced.driver-config-reporting.enabled: exercises the shipped default. + try (CqlSession session = SessionUtils.newSession(SIMULACRON_RULE)) { + awaitControlAndPoolConnected(); + + List startups = sessionStartups(); + assertThat(distinctConnections(startups)).isGreaterThanOrEqualTo(2); + + assertThat(startups).allSatisfy(log -> assertThat(options(log)).containsKey(SESSION_ID_KEY)); + List withDriverConfig = + startups.stream() + .filter(log -> options(log).containsKey(DRIVER_CONFIG_KEY)) + .collect(Collectors.toList()); + assertThat(withDriverConfig).hasSize(1); + assertDriverConfigPayload(options(withDriverConfig.get(0)).get(DRIVER_CONFIG_KEY)); + } + } + /** * The {@code STARTUP} frames of the session's real connections (control + pool), identified by * the always-present {@code CLIENT_ID} option; excludes protocol-version negotiation attempts. diff --git a/upgrade_guide/README.md b/upgrade_guide/README.md index 214399dacc7..eacc1761600 100644 --- a/upgrade_guide/README.md +++ b/upgrade_guide/README.md @@ -19,6 +19,46 @@ under the License. ## Upgrade guide +### 4.19.2.1 + +#### The driver reports a session identifier, and its configuration, at connection time + +Two CQL `STARTUP` options are new. The server stores them in its client-connection system table +(`system.clients` on ScyllaDB, `system_views.clients` on Cassandra 4.1+), so that operators can group +a client's connections and inspect its driver settings while investigating an incident. + +* `SESSION_ID` — a driver-generated identifier, shared by all of a session's connections. It is sent + on **every** connection, unconditionally: it is an innate behavior with no configuration option to + turn it off. It is not derived from `CLIENT_ID`, which remains user-settable and unchanged. +* `DRIVER_CONFIG` — a compact JSON description of the effective configuration of the session's + default execution profile (connection/socket settings, timeouts, + retry/reconnection/speculative-execution/load-balancing policies, connection pooling, query + defaults, and TLS). Only the control connection sends it, since it describes the whole session. + It reports settings only — never credentials, statements or data — and identifies non-built-in + policies by class name: the simple name, or the fully-qualified name when the policy is an + anonymous class (which has no simple name). Reporting it is best-effort: if the report cannot be + built, or would exceed 32 KiB, it is skipped (with a warning) rather than allowed to interfere + with connecting. + +Reporting the configuration is **enabled by default**. To turn it off: + +```properties +datastax-java-driver.advanced.driver-config-reporting.enabled = false +``` + +Note that this option does not affect `SESSION_ID`. + +#### Two `BasicLoadBalancingPolicy` accessors widened from `protected` to `public` + +`BasicLoadBalancingPolicy.getLocalDatacenter()` and `getLocalRack()` are now `public`, so that the +configuration report above can describe the datacenter and rack the policy actually resolved rather +than whatever the profile currently says. + +Binary compatibility is unaffected — an already-compiled subclass keeps working. But if you +[extend `BasicLoadBalancingPolicy`](../manual/core/load_balancing/#custom-implementation) and +override either method, you have to widen your override to `public` in order to recompile: Java does +not allow an override to reduce visibility. + ### 4.19.0.7 #### Cloud private-endpoint support via client routes From cafe925a9f39b19dfd5db67b86f5224bd8a2ff64 Mon Sep 17 00:00:00 2001 From: Mikita Hradovich Date: Tue, 11 Aug 2026 15:17:32 +0200 Subject: [PATCH 6/6] fix: keep the config report off the connection path when Jackson is absent Motivation: Stage 1 made jackson-core and jackson-databind required compile-scope dependencies of core, and DefaultDriverConfigReporter holds an ObjectMapper in a static field. The driver declares Jackson as required but documents that it can be excluded when unused (manual/core/integration), so on such a classpath merely *linking* the default implementation raises a 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 in ProtocolInitHandler could contain it -- and it happens on the connection initialization path. A documented, supported configuration went from "reporting is skipped" to "no connection can be established", the inverse of the invariant this class is written around. Modifications: DefaultDriverContext.buildDriverConfigReporter() now checks for Jackson and returns a NoopDriverConfigReporter when it is absent. Choosing which implementation to instantiate is the only place the check can live, since anything later has already touched the class. Logged unconditionally, unlike the Insights equivalent in buildLifecycleListeners: reporting ships enabled, so someone who trimmed Jackson never opted out of it and would otherwise have no signal that it is off. The GraalVM and integration manuals, and the upgrade guide, now say so. Result: Excluding Jackson costs the report and nothing else; SESSION_ID is unaffected. The absent-classpath path cannot be exercised in-process, so the test pins the part that can be: that the substitute contributes nothing and does not need a context to say so. Co-Authored-By: Claude Sonnet 5 Co-Authored-By: Claude Opus 5 (1M context) --- .../core/context/DefaultDriverContext.java | 27 ++++++++++- .../context/NoopDriverConfigReporter.java | 47 +++++++++++++++++++ .../DefaultDriverConfigReporterTest.java | 13 +++++ manual/core/graalvm/README.md | 7 ++- manual/core/integration/README.md | 11 ++++- upgrade_guide/README.md | 4 +- 6 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 core/src/main/java/com/datastax/oss/driver/internal/core/context/NoopDriverConfigReporter.java diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/context/DefaultDriverContext.java b/core/src/main/java/com/datastax/oss/driver/internal/core/context/DefaultDriverContext.java index 2983ef0787f..e6323b6b84c 100644 --- a/core/src/main/java/com/datastax/oss/driver/internal/core/context/DefaultDriverContext.java +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/context/DefaultDriverContext.java @@ -375,8 +375,33 @@ protected Map buildStartupOptions() { .build(); } + /** + * Returns the component that reports the driver configuration on the control connection's Startup + * message. + * + *

    Guarded by the presence of Jackson, which {@link DefaultDriverConfigReporter} serializes the + * report with. The driver declares Jackson as a required dependency but documents that it can be + * excluded when unused (see {@code manual/core/integration}), so on such a classpath merely + * loading the default implementation raises a {@link NoClassDefFoundError} — an {@code + * Error}, not an exception, and raised while linking the class rather than from any method it + * declares, so the reporter's own fail-safe cannot catch it and neither can its caller. Since + * this runs on the connection initialization path, that would take a documented, supported + * configuration from "reporting is skipped" to "no connection can be established". Choosing the + * implementation here is therefore the only place the check can live. + * + * @see #getDriverConfigReporter() + */ protected DriverConfigReporter buildDriverConfigReporter() { - return new DefaultDriverConfigReporter(this); + if (DefaultDependencyChecker.isPresent(JACKSON)) { + return new DefaultDriverConfigReporter(this); + } + // Logged unconditionally, unlike the Insights equivalent in #buildLifecycleListeners: reporting + // ships enabled, so someone who trimmed Jackson never opted out of it and would otherwise have + // no signal that it is off. + LOG.info( + "Could not initialize driver configuration reporting; " + + "this is normal if Jackson was explicitly excluded from classpath"); + return new NoopDriverConfigReporter(); } protected Map buildLoadBalancingPolicies() { diff --git a/core/src/main/java/com/datastax/oss/driver/internal/core/context/NoopDriverConfigReporter.java b/core/src/main/java/com/datastax/oss/driver/internal/core/context/NoopDriverConfigReporter.java new file mode 100644 index 00000000000..213c3657585 --- /dev/null +++ b/core/src/main/java/com/datastax/oss/driver/internal/core/context/NoopDriverConfigReporter.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.datastax.oss.driver.internal.core.context; + +import java.util.Map; +import net.jcip.annotations.ThreadSafe; + +/** + * A {@link DriverConfigReporter} that reports nothing, used when the driver cannot build a report + * at all. + * + *

    Today that means Jackson is absent from the classpath: the driver declares it as a required + * dependency but documents that it can be excluded (see {@code manual/core/integration}), and + * {@link DefaultDriverConfigReporter} serializes the report with it. Substituting this + * implementation keeps that exclusion working, the same way {@code + * DefaultDriverContext#buildLifecycleListeners()} skips Insights monitoring. + * + *

    Deliberately free of any reference to Jackson, including in the signatures it inherits — a + * single one would make loading this class fail for exactly the deployments it exists to + * serve. Note that the failure it avoids is a {@link NoClassDefFoundError} raised while linking the + * default implementation, which is an {@code Error} rather than an exception, so no {@code + * try}/{@code catch} on the connection path could stand in for choosing the right implementation + * here. + */ +@ThreadSafe +public class NoopDriverConfigReporter implements DriverConfigReporter { + + @Override + public void populateControlConnectionOptions(Map startupOptions) { + // nothing to do + } +} diff --git a/core/src/test/java/com/datastax/oss/driver/internal/core/context/DefaultDriverConfigReporterTest.java b/core/src/test/java/com/datastax/oss/driver/internal/core/context/DefaultDriverConfigReporterTest.java index 91dc75d7487..4122f82a399 100644 --- a/core/src/test/java/com/datastax/oss/driver/internal/core/context/DefaultDriverConfigReporterTest.java +++ b/core/src/test/java/com/datastax/oss/driver/internal/core/context/DefaultDriverConfigReporterTest.java @@ -167,6 +167,19 @@ public void should_add_driver_config_when_the_option_is_not_defined() { assertThat(options).containsKey(DefaultDriverConfigReporter.DRIVER_CONFIG_KEY); } + @Test + public void should_report_nothing_at_all_without_jackson() { + // DefaultDriverContext substitutes NoopDriverConfigReporter when Jackson is absent, since + // linking DefaultDriverConfigReporter on such a classpath raises a NoClassDefFoundError — an + // Error, raised before any of its methods run, so neither its own fail-safe nor its caller in + // ProtocolInitHandler could contain it. The absent-classpath path itself cannot be exercised + // in-process; what is checked here is that the substitute contributes nothing and, in + // particular, does not need a context to say so. + Map options = new HashMap<>(); + new NoopDriverConfigReporter().populateControlConnectionOptions(options); + assertThat(options).isEmpty(); + } + // ==================== Fail-safe ==================== @Test diff --git a/manual/core/graalvm/README.md b/manual/core/graalvm/README.md index 5413c090420..a2fc20b0662 100644 --- a/manual/core/graalvm/README.md +++ b/manual/core/graalvm/README.md @@ -152,8 +152,11 @@ following configurations must be added: ### Using the Jackson JSON library [Jackson](https://github.com/FasterXML/jackson) is used in [a few places](../integration#jackson) in -the driver, but is an optional dependency; if you intend to use Jackson, the following -configurations must be added: +the driver, but is an optional dependency. One of those places — driver configuration reporting — is +enabled by default, so the entries below are needed unless you are content for it to be disabled: the +driver detects Jackson reflectively, and without them it will not find it and will skip the report. + +If you intend to use Jackson, the following configurations must be added: 1. Create the following reflection.json file, or add these entries to an existing file: diff --git a/manual/core/integration/README.md b/manual/core/integration/README.md index 4ec1dd59312..964e8388347 100644 --- a/manual/core/integration/README.md +++ b/manual/core/integration/README.md @@ -473,8 +473,10 @@ dependency: [Jackson](https://github.com/FasterXML/jackson) is used: * when Insights monitoring is enabled; -* when [Json codecs](../custom_codecs) are being used. - +* when [Json codecs](../custom_codecs) are being used; +* when driver configuration reporting is enabled — `advanced.driver-config-reporting.enabled`, which + **defaults to true**. The report is serialized to JSON with Jackson. + Jackson is declared as a required dependency, but the driver can operate normally without it. If you don't use any of the above features, you can safely exclude the dependency: @@ -492,6 +494,11 @@ don't use any of the above features, you can safely exclude the dependency: ``` +Note that configuration reporting is the one feature in that list which is enabled by default, so +excluding Jackson turns it off without you having to opt out: the driver logs an informational message +at startup and does not send `DRIVER_CONFIG`. Nothing else changes — `SESSION_ID` is still sent on +every connection, and connecting is unaffected. + #### Esri The geospatial types implementation is based on the [Esri Geometry diff --git a/upgrade_guide/README.md b/upgrade_guide/README.md index eacc1761600..83963f94775 100644 --- a/upgrade_guide/README.md +++ b/upgrade_guide/README.md @@ -38,7 +38,9 @@ a client's connections and inspect its driver settings while investigating an in policies by class name: the simple name, or the fully-qualified name when the policy is an anonymous class (which has no simple name). Reporting it is best-effort: if the report cannot be built, or would exceed 32 KiB, it is skipped (with a warning) rather than allowed to interfere - with connecting. + with connecting. It is serialized with Jackson, which the driver + [allows you to exclude](../manual/core/integration/#jackson); on such a classpath the report is + skipped and an informational message is logged at startup. `SESSION_ID` is unaffected. Reporting the configuration is **enabled by default**. To turn it off: