From c6a0a201d6f0eaf0e09fa8143ed3c29624d3d38f Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Thu, 6 Aug 2026 19:15:53 +0200 Subject: [PATCH 1/7] Reject a query timeout that guarantees every request fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A query timeout of 0 was accepted and then honoured literally: the synchronous paths hand it to Task.Wait, which returns immediately, so every request failed with a TimeoutException before it could complete. Anything below Timeout.Infinite is worse still, making Task.Wait throw. Neither is a configuration anyone can have meant, and both only showed up at the first query rather than where the mistake was made. Only a positive number of milliseconds and Timeout.Infinite, which waits indefinitely, are now accepted. The check sits in Builder.WithQueryTimeout, following WithMaxSchemaAgreementWaitSeconds, and is repeated in Configuration's constructor because ClientOptions is public and can be handed straight to it, bypassing the builder: no cluster should come into existence in that state. Declaring Timeout.Infinite valid means honouring it everywhere, and three callers did not: Metadata.RefreshSchema, GetTable and GetMaterializedView each doubled the timeout by multiplying it, turning -1 into -2, which Task.Wait rejects outright — so a cluster configured to wait indefinitely could neither refresh its schema nor read table or view metadata synchronously. They now scale through RequestOptions.GetQueryAbortTimeout, which preserves the sentinel and which the equivalent two-query waits in KeyspaceMetadata already used. That helper had no tests despite now carrying the invariant, so it gets a fixture too. The other scaled timeouts are safe: HostConnectionPool clamps a non-positive drain delay to its maximum, and Cluster takes the greater of its scaled connect timeout and the metadata abort timeout. Note this rejects a call that used to be accepted, so an application passing 0 today, and failing every synchronous request because of it, now fails while the cluster is being configured instead. --- src/Cassandra.Tests/BuilderTests.cs | 40 ++++++++++++ .../ExecutionProfiles/RequestOptionsTests.cs | 65 +++++++++++++++++++ src/Cassandra/Builder.cs | 24 +++++++ src/Cassandra/Configuration.cs | 5 ++ src/Cassandra/Metadata.cs | 17 ++++- 5 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 src/Cassandra.Tests/ExecutionProfiles/RequestOptionsTests.cs diff --git a/src/Cassandra.Tests/BuilderTests.cs b/src/Cassandra.Tests/BuilderTests.cs index 02f4e05a5..a982b0901 100644 --- a/src/Cassandra.Tests/BuilderTests.cs +++ b/src/Cassandra.Tests/BuilderTests.cs @@ -16,6 +16,7 @@ using System; using System.Linq; +using System.Threading; using System.Net; using Cassandra.Connections; using NUnit.Framework; @@ -249,6 +250,45 @@ public void Should_ThrowArgumentException_When_ProvidedMaxSchemaAgreementsWaitSe Assert.That(ex.Message, Is.EqualTo("Max schema agreement wait must be greater than zero")); } + [Test] + [TestCase(0)] + [TestCase(-2)] + [TestCase(int.MinValue)] + public void Should_ThrowArgumentException_When_ProvidedQueryTimeoutIsInvalid(int queryAbortTimeout) + { + // 0 is not "no timeout": the synchronous paths hand it to Task.Wait, which returns at once, so every + // request would time out before completing. Below Timeout.Infinite, Task.Wait throws instead. + var builder = Cluster.Builder(); + + Assert.Throws(() => builder.WithQueryTimeout(queryAbortTimeout)); + } + + [Test] + [TestCase(0)] + [TestCase(-2)] + public void Should_ThrowArgumentException_When_AConfigurationIsBuiltWithAnInvalidQueryTimeout(int queryAbortTimeout) + { + // ClientOptions is public and can be handed straight to Configuration, bypassing the builder, so the + // check has to hold there too: no cluster may come into existence with a timeout that would make + // every request fail. + Assert.Throws( + () => new TestConfigurationBuilder { ClientOptions = new ClientOptions(false, queryAbortTimeout, null) }.Build()); + } + + [Test] + [TestCase(1)] + [TestCase(30000)] + [TestCase(Timeout.Infinite)] + public void Should_AcceptQueryTimeout_When_ItIsPositiveOrInfinite(int queryAbortTimeout) + { + var config = Cluster.Builder() + .AddContactPoint("192.168.1.10") + .WithQueryTimeout(queryAbortTimeout) + .GetConfiguration(); + + Assert.AreEqual(queryAbortTimeout, config.ClientOptions.QueryAbortTimeout); + } + [Test] public void Should_ReturnCorrectMaxSchemaAgreementsWaitSeconds_When_ValueIsProvidedToBuilder() { diff --git a/src/Cassandra.Tests/ExecutionProfiles/RequestOptionsTests.cs b/src/Cassandra.Tests/ExecutionProfiles/RequestOptionsTests.cs new file mode 100644 index 000000000..732a10020 --- /dev/null +++ b/src/Cassandra.Tests/ExecutionProfiles/RequestOptionsTests.cs @@ -0,0 +1,65 @@ +// +// Copyright (C) ScyllaDB +// +// Licensed 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. +// + +using System; +using System.Threading; + +using NUnit.Framework; +using Assert = NUnit.Framework.Legacy.ClassicAssert; + +namespace Cassandra.Tests.ExecutionProfiles +{ + [TestFixture] + public class RequestOptionsTests + { + [Test] + public void Should_ScaleTheQueryAbortTimeout_By_TheAmountOfQueries() + { + var requestOptions = RequestOptionsTests.RequestOptionsWithQueryTimeout(1000); + + Assert.AreEqual(1000, requestOptions.GetQueryAbortTimeout(1)); + Assert.AreEqual(2000, requestOptions.GetQueryAbortTimeout(2)); + } + + [Test] + public void Should_PreserveTheInfiniteSentinel_When_ScalingTheQueryAbortTimeout() + { + // Scaling has to leave Timeout.Infinite alone: multiplying it would yield -2 or lower, which + // Task.Wait rejects outright, so callers such as Metadata.RefreshSchema would throw for a cluster + // configured to wait indefinitely rather than wait. + var requestOptions = RequestOptionsTests.RequestOptionsWithQueryTimeout(Timeout.Infinite); + + Assert.AreEqual(Timeout.Infinite, requestOptions.GetQueryAbortTimeout(1)); + Assert.AreEqual(Timeout.Infinite, requestOptions.GetQueryAbortTimeout(2)); + } + + [Test] + public void Should_ThrowArgumentException_When_TheAmountOfQueriesIsNotPositive() + { + var requestOptions = RequestOptionsTests.RequestOptionsWithQueryTimeout(1000); + + Assert.Throws(() => requestOptions.GetQueryAbortTimeout(0)); + } + + private static Cassandra.ExecutionProfiles.IRequestOptions RequestOptionsWithQueryTimeout(int queryAbortTimeout) + { + return new TestConfigurationBuilder + { + ClientOptions = new ClientOptions(false, queryAbortTimeout, null) + }.Build().DefaultRequestOptions; + } + } +} diff --git a/src/Cassandra/Builder.cs b/src/Cassandra/Builder.cs index 0b72a7a85..b7ca72164 100644 --- a/src/Cassandra/Builder.cs +++ b/src/Cassandra/Builder.cs @@ -20,6 +20,7 @@ using System.Net; using System.Security.Authentication; using System.Security.Cryptography.X509Certificates; +using System.Threading; using System.Threading.Tasks; using Cassandra.Connections; using Cassandra.Connections.Control; @@ -714,10 +715,33 @@ public Builder WithoutRowSetBuffering() /// this builder public Builder WithQueryTimeout(int queryAbortTimeout) { + Builder.ValidateQueryAbortTimeout(queryAbortTimeout); _queryAbortTimeout = queryAbortTimeout; return this; } + /// + /// Rejects a query timeout that is neither a bound nor the absence of one. + /// + /// Only a positive number of milliseconds and are meaningful. In + /// particular 0 is not "no timeout": the synchronous paths hand this value to , + /// which returns immediately, so every request would fail with a before it + /// could complete. Anything below makes throw + /// instead. Both are rejected here rather than at the first query, so the mistake surfaces while the + /// cluster is being configured. + /// + /// + internal static void ValidateQueryAbortTimeout(int queryAbortTimeout) + { + if (queryAbortTimeout != Timeout.Infinite && queryAbortTimeout <= 0) + { + throw new ArgumentException( + $"Query timeout must be a positive number of milliseconds, or Timeout.Infinite ({Timeout.Infinite}) " + + $"to wait indefinitely, but was {queryAbortTimeout}. A timeout of 0 would make every request " + + "time out before it could complete."); + } + } + /// /// Sets default keyspace name for the created cluster. /// diff --git a/src/Cassandra/Configuration.cs b/src/Cassandra/Configuration.cs index 48e30410f..7c12ddbae 100644 --- a/src/Cassandra/Configuration.cs +++ b/src/Cassandra/Configuration.cs @@ -326,6 +326,11 @@ internal Configuration(Policies policies, ProtocolOptions = protocolOptions; PoolingOptions = poolingOptions; SocketOptions = socketOptions; + // Also validated by Builder.WithQueryTimeout, but ClientOptions is public and can be handed straight + // to this constructor, so the check belongs where every path meets: a cluster must not come into + // existence with a query timeout that would make every request fail. + Builder.ValidateQueryAbortTimeout(clientOptions.QueryAbortTimeout); + ClientOptions = clientOptions; AuthProvider = authProvider; AuthInfoProvider = authInfoProvider; diff --git a/src/Cassandra/Metadata.cs b/src/Cassandra/Metadata.cs index c0c3202d6..64a235401 100644 --- a/src/Cassandra/Metadata.cs +++ b/src/Cassandra/Metadata.cs @@ -352,7 +352,10 @@ public ICollection GetTables(string keyspace) /// a TableMetadata for the specified table in the specified keyspace. public TableMetadata GetTable(string keyspace, string tableName) { - return TaskHelper.WaitToComplete(GetTableAsync(keyspace, tableName), _queryAbortTimeout * 2); + // Through GetQueryAbortTimeout so that Timeout.Infinite survives the scaling; doubling it directly + // yields -2, which Task.Wait rejects. See Metadata.RefreshSchema. + return TaskHelper.WaitToComplete( + GetTableAsync(keyspace, tableName), Configuration.DefaultRequestOptions.GetQueryAbortTimeout(2)); } internal Task GetTableAsync(string keyspace, string tableName) @@ -382,7 +385,10 @@ public MaterializedViewMetadata GetMaterializedView(string keyspace, string name : ksMetadata.GetMaterializedViewMetadata(name); } - return TaskHelper.WaitToComplete(SchemaParser.GetViewAsync(keyspace, name), _queryAbortTimeout * 2); + // Through GetQueryAbortTimeout so that Timeout.Infinite survives the scaling; doubling it directly + // yields -2, which Task.Wait rejects. See Metadata.RefreshSchema. + return TaskHelper.WaitToComplete( + SchemaParser.GetViewAsync(keyspace, name), Configuration.DefaultRequestOptions.GetQueryAbortTimeout(2)); } /// @@ -457,7 +463,12 @@ internal Task GetQueryTraceAsync(QueryTrace trace) /// public bool RefreshSchema(string keyspace = null, string table = null) { - return TaskHelper.WaitToComplete(RefreshSchemaAsync(keyspace, table), Configuration.DefaultRequestOptions.QueryAbortTimeout * 2); + // Through GetQueryAbortTimeout rather than multiplying the timeout here, so that Timeout.Infinite + // survives the scaling: doubling it directly yields -2, which Task.Wait rejects outright, so a cluster + // configured to wait indefinitely could not refresh its schema synchronously at all. The equivalent + // two-query waits in KeyspaceMetadata already go through this helper. + return TaskHelper.WaitToComplete( + RefreshSchemaAsync(keyspace, table), Configuration.DefaultRequestOptions.GetQueryAbortTimeout(2)); } /// From 825ae39b4e9028afd0b0859694faceaa2ced08f2 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Thu, 6 Aug 2026 19:15:53 +0200 Subject: [PATCH 2/7] Reject a connect timeout that fails every connection attempt Same hole as the query timeout, in the other timeout an application can set. SetConnectTimeoutMillis accepted 0 and the driver then honoured it literally: the value is handed to Timer.Change by TaskHelper.TaskCompletionSourceWithTimeout, so the timeout fired at once and every connection attempt failed with a SocketException before it could be established. TcpSocket.Connect, the SSL handshake and the startup and authentication requests in Connection all use it, so nothing could connect. Anything below Timeout.Infinite is worse still, making Timer.Change throw. Only a positive number of milliseconds and Timeout.Infinite, which waits indefinitely, are now accepted. One check is enough here, unlike the query timeout: SetConnectTimeoutMillis is the only writer of the field and SocketOptions has no constructor taking it, so no path can bypass it. SocketOptions had no tests at all, so this adds a fixture for the setting it now validates. Note this rejects a call that used to be accepted, though only one that could never have worked: a cluster configured this way established no connections whatsoever, and now says so while it is being configured. --- src/Cassandra.Tests/SocketOptionsTests.cs | 56 +++++++++++++++++++++++ src/Cassandra/SocketOptions.cs | 15 ++++++ 2 files changed, 71 insertions(+) create mode 100644 src/Cassandra.Tests/SocketOptionsTests.cs diff --git a/src/Cassandra.Tests/SocketOptionsTests.cs b/src/Cassandra.Tests/SocketOptionsTests.cs new file mode 100644 index 000000000..488ba913f --- /dev/null +++ b/src/Cassandra.Tests/SocketOptionsTests.cs @@ -0,0 +1,56 @@ +// +// Copyright (C) ScyllaDB +// +// Licensed 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. +// + +using System; +using System.Threading; + +using NUnit.Framework; +using Assert = NUnit.Framework.Legacy.ClassicAssert; + +namespace Cassandra.Tests +{ + [TestFixture] + public class SocketOptionsTests + { + [Test] + [TestCase(0)] + [TestCase(-2)] + [TestCase(int.MinValue)] + public void Should_ThrowArgumentException_When_TheConnectTimeoutIsNeitherABoundNorTheAbsenceOfOne(int connectTimeoutMillis) + { + // The value is handed to Timer.Change, so 0 fires the timeout at once and fails every connection + // attempt, and anything below Timeout.Infinite makes Timer.Change throw. + Assert.Throws(() => new SocketOptions().SetConnectTimeoutMillis(connectTimeoutMillis)); + } + + [Test] + [TestCase(1)] + [TestCase(5000)] + [TestCase(Timeout.Infinite)] + public void Should_AcceptTheConnectTimeout_When_ItIsPositiveOrInfinite(int connectTimeoutMillis) + { + var options = new SocketOptions().SetConnectTimeoutMillis(connectTimeoutMillis); + + Assert.AreEqual(connectTimeoutMillis, options.ConnectTimeoutMillis); + } + + [Test] + public void Should_DefaultTheConnectTimeout() + { + Assert.AreEqual(SocketOptions.DefaultConnectTimeoutMillis, new SocketOptions().ConnectTimeoutMillis); + } + } +} diff --git a/src/Cassandra/SocketOptions.cs b/src/Cassandra/SocketOptions.cs index aff65ae3b..d7662ed86 100644 --- a/src/Cassandra/SocketOptions.cs +++ b/src/Cassandra/SocketOptions.cs @@ -14,6 +14,9 @@ // limitations under the License. // +using System; +using System.Threading; + namespace Cassandra { /// @@ -137,6 +140,18 @@ public int DefunctReadTimeoutThreshold /// public SocketOptions SetConnectTimeoutMillis(int connectTimeoutMillis) { + // Only a positive number of milliseconds and Timeout.Infinite are meaningful. The value is handed to + // Timer.Change, so 0 fires the timeout immediately and fails every connection attempt before it can + // be established, and anything below Timeout.Infinite makes Timer.Change throw. Rejected here rather + // than at the first connection, so the mistake surfaces while the cluster is being configured. + if (connectTimeoutMillis != Timeout.Infinite && connectTimeoutMillis <= 0) + { + throw new ArgumentException( + $"Connect timeout must be a positive number of milliseconds, or Timeout.Infinite " + + $"({Timeout.Infinite}) to wait indefinitely, but was {connectTimeoutMillis}. A timeout of 0 " + + "would fail every connection attempt immediately."); + } + _connectTimeoutMillis = connectTimeoutMillis; return this; } From 65d7cd13dd3a8149ee651af774891b934adb1be0 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Thu, 6 Aug 2026 19:15:53 +0200 Subject: [PATCH 3/7] Expose whether the TLS options verify the server host name The driver hands the server name to SslStream, which reports a name mismatch as SslPolicyErrors.RemoteCertificateNameMismatch. Both the callback SSLOptions installs by default and .NET's own validation, used when the callback is null, reject that, so the host name is verified in both cases; an application supplied callback decides for itself and what it decides is not introspectable. Answering that question needs the default callback to be recognizable, so it moves into a static field rather than being attached as a method group at every field initialization. Diagnostic only: nothing about how a connection is authenticated changes. The answer is therefore nullable: true for the driver's default callback and for null, and null for an application supplied one. Not false, because a callback that ignores a name mismatch cannot be told apart from one that enforces it, so the driver knows neither that the host name is checked nor that it goes unchecked. Needed by the driver configuration report, whose tls group reports host name verification only when it is known and omits the key otherwise. --- src/Cassandra/SSLOptions.cs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/Cassandra/SSLOptions.cs b/src/Cassandra/SSLOptions.cs index b2aef4ea8..22e1919aa 100644 --- a/src/Cassandra/SSLOptions.cs +++ b/src/Cassandra/SSLOptions.cs @@ -28,7 +28,15 @@ namespace Cassandra public class SSLOptions { private readonly static Logger _logger = new Logger(typeof(SSLOptions)); - private RemoteCertificateValidationCallback _remoteCertValidationCallback = ValidateServerCertificate; + + /// + /// The callback installed unless the application provides its own. Held in a field so that + /// can recognize it without depending on delegate-to-method-group + /// comparison semantics. + /// + private readonly static RemoteCertificateValidationCallback DefaultCertValidationCallback = ValidateServerCertificate; + + private RemoteCertificateValidationCallback _remoteCertValidationCallback = SSLOptions.DefaultCertValidationCallback; private SslProtocols _sslProtocol = SslProtocols.Tls; private bool _checkCertificateRevocation; private X509CertificateCollection _certificateCollection = new X509CertificateCollection(); @@ -42,6 +50,23 @@ public RemoteCertificateValidationCallback RemoteCertValidationCallback get { return _remoteCertValidationCallback; } } + /// + /// Whether the server host name is verified against its certificate, or null when that cannot be + /// determined. + /// + /// The driver hands the server name to , which reports a name + /// mismatch as . Both the callback the driver + /// installs by default and .NET's own validation (used when the callback is null) reject that, so + /// the host name is verified in both cases. An application supplied callback decides for itself and what + /// it decides is not introspectable, so the answer is null rather than false: the driver + /// cannot vouch for the verification, but neither can it claim the host name goes unchecked. + /// + /// + internal bool? VerifiesHostName => + _remoteCertValidationCallback == null || _remoteCertValidationCallback == SSLOptions.DefaultCertValidationCallback + ? true + : (bool?)null; + /// /// Ssl Protocol used for communication with Cassandra hosts. /// From 2324f06e6d8e3a95a9a0d9bc961bf0cbdb36c34b Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Thu, 6 Aug 2026 19:15:53 +0200 Subject: [PATCH 4/7] Expose whether the local datacenter was explicitly configured LocalDc alone cannot answer this: Initialize overwrites the field with the datacenter of the host the control connection uses when none was configured, so an explicit preference and an inferred one look the same afterwards. Recorded in the constructor, where the distinction is still available, and kept internal since only the driver needs it. Needed by the driver configuration report, whose node-location-preference group distinguishes an explicit datacenter (dc) from an inferred one (dc-auto). --- src/Cassandra/Policies/DCAwareRoundRobinPolicy.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/Cassandra/Policies/DCAwareRoundRobinPolicy.cs b/src/Cassandra/Policies/DCAwareRoundRobinPolicy.cs index d66b17a9d..a213f40b7 100644 --- a/src/Cassandra/Policies/DCAwareRoundRobinPolicy.cs +++ b/src/Cassandra/Policies/DCAwareRoundRobinPolicy.cs @@ -41,6 +41,7 @@ public class DCAwareRoundRobinPolicy : IExtendedLoadBalancingPolicy private static readonly Logger Logger = new Logger(typeof(DCAwareRoundRobinPolicy)); private string _localDc; + private readonly bool _localDcIsExplicit; private readonly int _usedHostsPerRemoteDc; private readonly int _maxIndex = Int32.MaxValue - 10000; @@ -102,6 +103,7 @@ public DCAwareRoundRobinPolicy(string localDc) : this(localDc, 0) public DCAwareRoundRobinPolicy(string localDc, int usedHostsPerRemoteDc) { _localDc = localDc; + _localDcIsExplicit = localDc != null; _usedHostsPerRemoteDc = usedHostsPerRemoteDc; } @@ -110,6 +112,17 @@ public DCAwareRoundRobinPolicy(string localDc, int usedHostsPerRemoteDc) /// public string LocalDc => _localDc; + /// + /// Whether was provided by the application rather than inferred by + /// from the datacenter of the host the control connection uses. + /// + /// alone cannot answer this: overwrites the field when no + /// datacenter was configured, so both cases look the same afterwards. The driver configuration report + /// needs the distinction to tell an explicit datacenter preference from an inferred one. + /// + /// + internal bool LocalDcIsExplicit => _localDcIsExplicit; + /// /// Gets the number of hosts per remote datacenter that should be considered. This value is provided in the constructor. /// From a4830aca1655c2b3e9e2ee3ba04216f5676ae3ff Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Thu, 6 Aug 2026 19:15:53 +0200 Subject: [PATCH 5/7] Ship the normative driver config schema and its validator Embed the v1 DRIVER_CONFIG JSON Schema in the test assembly verbatim, so that the report can be checked against the normative document that the ScyllaDB drivers share rather than against a restatement of it in test code, and reference JsonSchema.Net to evaluate it. Pinned to the 8.0.x line, the last one published under a plain MIT license: from 9.0.0 the NuGet binaries carry an Open Source Maintenance Fee EULA that asks users with revenue-generating use and at least US$10,000 annual gross revenue for a monthly fee. The source stays MIT either way, and the agreement covers only the pre-compiled binaries, which is exactly what a PackageReference consumes. Referenced only for net8 and net9. It reaches System.Text.Json 10 and System.Collections.Immutable 10 through Json.More.Net, whose netstandard2.0 group requires them outright so they cannot be pinned lower, and those warn on net6 and net7. The report is one code path with no per-framework behaviour, so validating it on the newer targets establishes its conformance for all of them. Test scope only, so nothing is added to what the driver ships. Vendored data and project plumbing, no logic yet. --- src/Cassandra.Tests/Cassandra.Tests.csproj | 19 + .../driver-config-report-v1.schema.json | 1027 +++++++++++++++++ 2 files changed, 1046 insertions(+) create mode 100644 src/Cassandra.Tests/Requests/driver-config-report-v1.schema.json diff --git a/src/Cassandra.Tests/Cassandra.Tests.csproj b/src/Cassandra.Tests/Cassandra.Tests.csproj index cc260a25e..1f9e51bfc 100644 --- a/src/Cassandra.Tests/Cassandra.Tests.csproj +++ b/src/Cassandra.Tests/Cassandra.Tests.csproj @@ -16,6 +16,14 @@ $(DefineConstants);NETCOREAPP + + + $(DefineConstants);JSON_SCHEMA_VALIDATOR + TargetFramework=netstandard2.0 @@ -33,6 +41,12 @@ + + TargetFramework=netstandard2.0 @@ -41,6 +55,11 @@ + + + + diff --git a/src/Cassandra.Tests/Requests/driver-config-report-v1.schema.json b/src/Cassandra.Tests/Requests/driver-config-report-v1.schema.json new file mode 100644 index 000000000..918c29507 --- /dev/null +++ b/src/Cassandra.Tests/Requests/driver-config-report-v1.schema.json @@ -0,0 +1,1027 @@ +{ + "$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", + "orphaned" + ], + "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." + } + } + } + } + }, + "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." + } + } + } + } +} From 0bcd0578f8fd3220d17287b848fa8a37f7cddade Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Thu, 6 Aug 2026 19:15:53 +0200 Subject: [PATCH 6/7] Report the full driver configuration on the control connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the schema-version-only placeholder with the report itself, so that an operator investigating an incident can see how the client is actually configured. The v1 schema nests everything under three groups: connection (its timeouts, request capacity, pool, socket, reconnection policy and tls), control-plane (the system-query and schema-agreement timeouts) and query (the per-request defaults, the retry, load balancing and speculative execution policies, and the datacenter preference). Built from the default execution profile rather than from Policies and QueryOptions directly, because a profile can override the read timeout and the policies, and what applies to a request that names no profile is what this report describes. The reporter therefore holds the Configuration and reads it when it builds a report, which is on every control connection handshake, so an inferred datacenter is reported once it becomes known. Conventions the schema imposes: kebab-case keys, nested objects, and omission rather than null of anything with no value. That extends to a configured value the schema cannot express while the key is optional, so a disabled read timeout, a disabled SO_LINGER and a non-positive socket buffer are left out instead of being reported as a number the schema rejects. Where only the key is optional and its group is not, the group stays and the number goes: that is how a disabled connect timeout and an infinite request timeout are reported. Reconciling the driver's options with the schema: - connection.read and the query policies come from the default profile; query.defaults.request.timeout-ms is the whole-request bound (QueryAbortTimeout) while connection.read bounds a single host, so the two describe different settings rather than the same one twice. - connection.requests.in-flight.max is whichever of two per connection limits binds first: the threshold PoolingOptions configures, above which HostConnectionPool rejects a borrow, and the size of the stream identifier pool, which Connection fixes at 2048, or 128 for single-byte stream ids, whatever the pool is configured with. Past that ceiling requests wait for an identifier rather than travelling, so reporting the configured value alone would overstate what a connection can do. Connection gains a static overload of GetMaxConcurrentRequests so both it and the report read the same constant; the report assumes the highest supported protocol version, as the pooling defaults already do, which is the one ScyllaDB negotiates. orphaned.max is SocketOptions.DefunctReadTimeoutThreshold: the driver counts the operations that timed out without a response, whose stream identifiers it cannot reuse, and HostConnectionPool.CheckHealth closes and replaces the connection once it reaches that count, which is what the schema describes. A negative threshold behaves like 0, so it is clamped. - control-plane.queries.system reports MetadataAbortTimeout, the bound ControlConnection actually applies to internal queries. There is no client-configurable server-side timeout, so server-side-ms is omitted. - The load balancing group is derived from the whole policy chain, walked once and shared with the datacenter preference. The schema has exactly one built-in shape, the token-aware policy, so a chain with token awareness reports load-distribution "shuffle" — TokenAwarePolicy starts the local replicas of a query plan at a pseudo-random index rather than rotating them deterministically. Its flags describe the whole chain, so they are only filled in when every policy in it is one the driver knows; a chain without token awareness, or one reaching an application supplied policy whose query plans this code cannot see, is reported as custom and named after the outermost policy the application configured. Its datacenter preference survives that, node-preference being a sibling of the policy rather than part of it. fallback-to-non-preferred-nodes is whether a request may go to a node outside the reported preference: for a datacenter-aware policy, whether it keeps hosts per remote datacenter. Round robin reports false, not because it stays local — it marks every host local, so a query can land on a remote one — but because it declares no preference to fall outside of, and none is reported for such a chain. Cross-driver decision. adaptive-ordering is omitted: the driver does not reorder candidates on runtime signals. So is max-retries on every retry policy: the built-ins have fixed rules rather than a configurable limit, which is what the schema reads its absence as. connection.node-preference is omitted too — it describes a datacenter or rack set on the session or cluster itself, and this driver has no such setting, the preference living only in the load balancing policy. - The retry chain looks through the decorators that pass the decision through unchanged: LoggingRetryPolicy, which logs its child's decision and returns it, and the internal WrappedExtendedRetryPolicy the driver puts around a plain IRetryPolicy; without the latter, a policy such as DowngradingConsistencyRetryPolicy would be reported as custom. A decorator that overrides the decision is not looked through, and leaves the group reported as custom: IdempotenceAwareRetryPolicy rethrows non-idempotent write timeouts and request errors instead of asking its child, so naming the child's type would promise retry rules that two of the four decision points never reach. RetryLoadBalancingPolicy is excluded from the load balancing flags for the same reason, its query plan re-enumerating the child's in an unbounded loop and sleeping between passes. query.retry.backoff is omitted throughout, no built-in policy delays a retry. - FixedReconnectionPolicy is reported as custom: one delay per attempt with the last repeating forever matches no built-in shape. - connection.pool carries only shard-aware, which reports configuration intent; at runtime the port must also be advertised and reachable. Pooling defaults come from the highest supported protocol version, because PoolingOptions is null until one is negotiated. - connection.tls is absent when TLS is off, and otherwise reports only whether the host name is verified. - connection.write is omitted. TcpSocket does assign the connect timeout to the socket's SendTimeout, but .NET only honours that for synchronous sends while the driver writes asynchronously, so reporting it would claim a bound that is not in force under a key the application never set. connection.heartbeat is reserved-empty in v1. - socket.reuse-address is a constant false, the platform default, because the driver sets SO_REUSEADDR on no socket. Deliberately not derived from SocketOptions.ReuseAddress: that option never meant SO_REUSEADDR, having been handed to Socket.Disconnect(reuseSocket) until that code was replaced, and nothing has read it since. Reporting it would claim the flag is set on the client sockets when it never is. - query.defaults.page is omitted when paging is unlimited. int.MaxValue is how the driver spells that, and QueryProtocolOptions turns it into -1 and leaves the page-size flag unset, so no limit reaches the server and there is no bound to report. - query.defaults.client-timestamps and tls.hostname-verification are reported only when the driver knows the answer, and omitted otherwise, which the schema reads as unknown. An ITimestampGenerator hands assignment back to the coordinator by returning long.MinValue and may decide that per request; a certificate validation callback that ignores a name mismatch cannot be told apart from one that enforces it. In both cases an application supplied implementation leaves the driver unable to claim either answer, so it claims neither. Neither field is ever false: no configuration makes server-side timestamps or disabled verification knowable. The sibling java drivers can report false for timestamps because they have a ServerSideTimestampGenerator to recognize; this driver has no such class, so its own generators are what can be recognized. - A datacenter configured as the empty string is reported as no datacenter at all: the schema requires a non-empty name and the policy would reject it when it initializes anyway. One field carries a value the schema cannot express, the driver not validating it on the way in: the in-flight maximum, which must be positive while SetMaxRequestsPerConnection takes any int. It is reported as-is, because fabricating an in-range value would misreport a setting an operator may have chosen deliberately and dropping the whole report over one field would lose everything else, and a test asserts that replacing that single field makes the document conform so the violation stays pinned to it. It is also logged, so a report which will not validate is visible in the driver's log rather than only in this class's documentation. A serial default consistency needs no such handling: the driver supports it, RequestHandler routing such a request as an LWT, and the schema's enum lists both levels. query.defaults.request needs no such compromise: Timeout.Infinite is the one value meaning there is no bound, and both the group and its key are optional, so that case drops the group. Every other value reaching the report is positive, a query timeout that would fail every request being rejected when the cluster is configured. connection.connect.timeout-ms follows the same rule for the same reason. The report is validated against the normative v1 schema, including a negative test proving that additionalProperties:false is enforced and so that the conformance assertions are not vacuous. --- .../Requests/DriverConfigReporterTests.cs | 1298 ++++++++++++++++- .../Requests/StartupOptionsFactoryTests.cs | 19 +- src/Cassandra/Configuration.cs | 4 +- src/Cassandra/Connections/Connection.cs | 12 +- .../Requests/DriverConfigReporter.cs | 757 +++++++++- 5 files changed, 2056 insertions(+), 34 deletions(-) diff --git a/src/Cassandra.Tests/Requests/DriverConfigReporterTests.cs b/src/Cassandra.Tests/Requests/DriverConfigReporterTests.cs index 730a64a96..c0872d1c6 100644 --- a/src/Cassandra.Tests/Requests/DriverConfigReporterTests.cs +++ b/src/Cassandra.Tests/Requests/DriverConfigReporterTests.cs @@ -16,8 +16,28 @@ using System; using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Security; +using System.Reflection; +using System.Security.Cryptography.X509Certificates; using System.Text; +using System.Threading; +#if JSON_SCHEMA_VALIDATOR +using System.Text.Json; +#endif + +using Cassandra.ExecutionProfiles; using Cassandra.Requests; + +#if JSON_SCHEMA_VALIDATOR +using Json.Schema; +#endif + +using Moq; + +using Newtonsoft.Json.Linq; + using NUnit.Framework; using Assert = NUnit.Framework.Legacy.ClassicAssert; @@ -26,14 +46,24 @@ namespace Cassandra.Tests.Requests [TestFixture] public class DriverConfigReporterTests { +#if JSON_SCHEMA_VALIDATOR + /// + /// The normative v1 schema, embedded verbatim by the test project. Parsed once: it is immutable and + /// building it is the expensive part of a conformance assertion. + /// + private static readonly JsonSchema Schema = DriverConfigReporterTests.LoadSchema(); +#endif + + //// --------------------------------------------------------------------------------------------------- + //// Gating, fail-safe and size limit + //// --------------------------------------------------------------------------------------------------- + [Test] public void Should_ReportSchemaVersion_When_ReportingIsEnabled() { - var options = new Dictionary(); + var report = DriverConfigReporterTests.BuildReport(new TestConfigurationBuilder().Build()); - new DriverConfigReporter().AddStartupOptions(options); - - Assert.AreEqual("{\"version\":" + DriverConfigReporter.SchemaVersion + "}", options[DriverConfigReporter.DriverConfigOption]); + Assert.AreEqual(DriverConfigReporter.SchemaVersion, report["version"].Value()); } [Test] @@ -51,12 +81,12 @@ public void Should_ReportAConfigThatFitsInAFrame() { var options = new Dictionary(); - new DriverConfigReporter().AddStartupOptions(options); + new DriverConfigReporter(new TestConfigurationBuilder().Build()).AddStartupOptions(options); - // Tripwire for when actual config groups land: if the report ever grew past the limit, it would be - // dropped by AddStartupOptions and this Assert.IsTrue would fail with a clear message, instead of - // the indexer below throwing an unrelated KeyNotFoundException. Enforcement of the limit itself is - // covered by Should_NotReportAnything_When_ReportExceedsTheLengthLimit. + // Tripwire for the real report: if it ever grew past the limit it would be dropped by + // AddStartupOptions and this assertion would fail with a clear message, instead of the indexer below + // throwing an unrelated KeyNotFoundException. Enforcement of the limit itself is covered by + // Should_NotReportAnything_When_ReportExceedsTheLengthLimit. Assert.IsTrue(options.ContainsKey(DriverConfigReporter.DriverConfigOption), "The report was dropped, it must have exceeded the length limit."); // The limit is enforced on the encoded length, so the assertion has to measure bytes as well. @@ -69,13 +99,29 @@ public void Should_ReportAConfigThatFitsInAFrame() public void Should_NotReportAnything_When_ReportExceedsTheLengthLimit() { var options = new Dictionary(); - var oversizedReport = new string('a', DriverConfigReporter.MaxDriverConfigLength + 1); + + // Padded with a multi-byte character so the report is under the limit in chars and over it in bytes. + // That pins the check to the UTF-8 byte count, which is what the frame's length prefix counts. + var oversizedReport = new string('ł', DriverConfigReporter.MaxDriverConfigLength / 2 + 1); + Assert.LessOrEqual(oversizedReport.Length, DriverConfigReporter.MaxDriverConfigLength, "The padding must not push the char count over the limit."); + Assert.Greater(Encoding.UTF8.GetByteCount(oversizedReport), DriverConfigReporter.MaxDriverConfigLength); new OversizedDriverConfigReporter(oversizedReport).AddStartupOptions(options); Assert.IsFalse(options.ContainsKey(DriverConfigReporter.DriverConfigOption)); } + [Test] + public void Should_ReportTheConfig_When_ItIsExactlyAtTheLengthLimit() + { + var options = new Dictionary(); + var report = new string('a', DriverConfigReporter.MaxDriverConfigLength); + + new OversizedDriverConfigReporter(report).AddStartupOptions(options); + + Assert.AreEqual(report, options[DriverConfigReporter.DriverConfigOption]); + } + [Test] public void Should_NotReportAnything_When_BuildingTheReportThrows() { @@ -86,11 +132,1164 @@ public void Should_NotReportAnything_When_BuildingTheReportThrows() Assert.IsFalse(options.ContainsKey(DriverConfigReporter.DriverConfigOption)); } + [Test] + public void Should_NotReportAnything_When_AConfiguredValueMakesTheRealReportExceedTheLimit() + { + // The cap exists because parts of the report are user-supplied and unbounded, the datacenter name + // being the one an application can make arbitrarily long, so it is worth reaching through a report the + // reporter really builds rather than only through a stubbed BuildReport. + var longDatacenter = new string('d', DriverConfigReporter.MaxDriverConfigLength); + var options = new Dictionary(); + + new DriverConfigReporter( + DriverConfigReporterTests.WithPolicies(loadBalancingPolicy: new DCAwareRoundRobinPolicy(longDatacenter))) + .AddStartupOptions(options); + + Assert.IsFalse(options.ContainsKey(DriverConfigReporter.DriverConfigOption)); + + // The same configuration with a name of a sane length still reports, so it is the size that dropped it + // and not the shape. + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies(loadBalancingPolicy: new DCAwareRoundRobinPolicy("dc1"))); + + Assert.AreEqual("dc1", report["query"]["load-balancing"]["node-preference"]["local-dc"].Value()); + } + + //// --------------------------------------------------------------------------------------------------- + //// The default report + //// --------------------------------------------------------------------------------------------------- + + [Test] + public void Should_ReportTheDefaultConfiguration() + { + var report = DriverConfigReporterTests.BuildReport(DriverConfigReporterTests.DefaultConfiguration()); + + Assert.AreEqual( + "{\"version\":1," + + "\"connection\":{" + + "\"connect\":{\"timeout-ms\":5000}," + + "\"read\":{\"timeout-ms\":12000}," + + "\"requests\":{\"in-flight\":{\"max\":2048},\"orphaned\":{\"max\":64}}," + + "\"pool\":{\"shard-aware\":{\"enabled\":true}}," + + "\"socket\":{\"tcp-no-delay\":true,\"keep-alive\":true,\"reuse-address\":false}," + + "\"reconnection\":{\"policy\":{\"type\":\"exponential\",\"base-ms\":1000,\"max-ms\":600000}}}," + + "\"control-plane\":{" + + "\"queries\":{\"system\":{\"timeout\":{\"client-side-ms\":300000}}}," + + "\"schema\":{\"agreement\":{\"timeout-ms\":10000}}}," + + "\"query\":{" + + "\"defaults\":{\"page\":{\"size\":5000},\"consistency\":\"LOCAL_ONE\",\"serial-consistency\":\"SERIAL\"," + + "\"idempotence\":false,\"client-timestamps\":true,\"request\":{\"timeout-ms\":60000}}," + + "\"retry\":{\"policy\":{\"type\":\"standard-error-aware\"}}," + + "\"load-balancing\":{\"policy\":{\"type\":\"token-aware\",\"load-distribution\":\"shuffle\",\"fallback-to-non-preferred-nodes\":false}," + + "\"node-preference\":{\"type\":\"dc-auto\"}}}}", + report.ToString(Newtonsoft.Json.Formatting.None)); + } + + [Test] + public void Should_OmitTheSpeculativeExecutionGroup_When_ThereIsNoSpeculativeExecution() + { + var report = DriverConfigReporterTests.BuildReport(DriverConfigReporterTests.DefaultConfiguration()); + + Assert.IsNull(report["query"]["speculative-execution"]); + } + + [Test] + public void Should_OmitTheConnectionNodePreference() + { + // That group is for drivers that let an application set a preferred datacenter or rack on the session + // or cluster itself. This driver has no such setting, the preference living only inside + // DCAwareRoundRobinPolicy, so it is reported under query.load-balancing and nowhere else. + var report = DriverConfigReporterTests.BuildReport(DriverConfigReporterTests.DefaultConfiguration()); + + Assert.IsNull(report["connection"]["node-preference"]); + Assert.IsNotNull(report["query"]["load-balancing"]["node-preference"]); + } + + [Test] + public void Should_OmitTheTlsGroup_When_TlsIsDisabled() + { + // The group carries no "enabled" flag; its absence is what says TLS is off. + var report = DriverConfigReporterTests.BuildReport(DriverConfigReporterTests.DefaultConfiguration()); + + Assert.IsNull(report["connection"]["tls"]); + } + + //// --------------------------------------------------------------------------------------------------- + //// connection: timeouts, requests, pool, socket + //// --------------------------------------------------------------------------------------------------- + + [Test] + public void Should_ReportTheConfiguredTimeouts() + { + var config = new TestConfigurationBuilder + { + SocketOptions = new SocketOptions().SetConnectTimeoutMillis(1234).SetReadTimeoutMillis(4321), + ProtocolOptions = new ProtocolOptions().SetMaxSchemaAgreementWaitSeconds(7), + ClientOptions = new ClientOptions(false, 9876, null) + }.Build(); + + var report = DriverConfigReporterTests.BuildReport(config); + + Assert.AreEqual(1234, report["connection"]["connect"]["timeout-ms"].Value()); + Assert.AreEqual(4321, report["connection"]["read"]["timeout-ms"].Value()); + Assert.AreEqual(7000, report["control-plane"]["schema"]["agreement"]["timeout-ms"].Value()); + Assert.AreEqual(9876, report["query"]["defaults"]["request"]["timeout-ms"].Value()); + + // There is no configurable write timeout, so the group is never reported. + Assert.IsNull(report["connection"]["write"]); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportTheProfileReadTimeout_When_TheDefaultProfileOverridesIt() + { + var config = new TestConfigurationBuilder + { + SocketOptions = new SocketOptions().SetReadTimeoutMillis(4321), + ExecutionProfiles = new Dictionary + { + { Configuration.DefaultExecutionProfileName, new ExecutionProfileBuilder().WithReadTimeoutMillis(999).CastToClass().Build() } + } + }.Build(); + + var report = DriverConfigReporterTests.BuildReport(config); + + Assert.AreEqual(999, report["connection"]["read"]["timeout-ms"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_OmitTheReadGroup_When_ReadTimeoutsAreDisabled() + { + var config = new TestConfigurationBuilder + { + SocketOptions = new SocketOptions().SetReadTimeoutMillis(0) + }.Build(); + + var report = DriverConfigReporterTests.BuildReport(config); + + Assert.IsNull(report["connection"]["read"]); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_OmitTheConnectTimeout_But_KeepTheGroup_When_ItIsInfinite() + { + // The group is required while its timeout is not, so an unbounded connect leaves an empty object. A + // connect timeout of 0 cannot reach here: it fails every attempt, so configuring one throws. + var config = new TestConfigurationBuilder + { + SocketOptions = new SocketOptions().SetConnectTimeoutMillis(Timeout.Infinite) + }.Build(); + + var report = DriverConfigReporterTests.BuildReport(config); + + Assert.IsNotNull(report["connection"]["connect"]); + Assert.IsNull(report["connection"]["connect"]["timeout-ms"]); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportTheConfiguredRequestCapacity() + { + var config = new TestConfigurationBuilder + { + PoolingOptions = new PoolingOptions().SetMaxRequestsPerConnection(512), + SocketOptions = new SocketOptions().SetDefunctReadTimeoutThreshold(16) + }.Build(); + + var report = DriverConfigReporterTests.BuildReport(config); + + var requests = report["connection"]["requests"]; + Assert.AreEqual(512, requests["in-flight"]["max"].Value()); + // The requests the driver stopped waiting for, after which it replaces the connection. + Assert.AreEqual(16, requests["orphaned"]["max"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportZeroOrphanedRequests_When_TheThresholdIsNegative() + { + // SetDefunctReadTimeoutThreshold does not validate its argument, and a threshold of 0 or below both + // mean the connection goes on the first timed-out operation, so clamping is exact. + var config = new TestConfigurationBuilder + { + SocketOptions = new SocketOptions().SetDefunctReadTimeoutThreshold(-1) + }.Build(); + + var report = DriverConfigReporterTests.BuildReport(config); + + Assert.AreEqual(0, report["connection"]["requests"]["orphaned"]["max"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportTheInFlightMaximumAsIs_When_ItIsNotPositive() + { + // Required and positive, and SetMaxRequestsPerConnection does not validate its argument, so the + // configured value is reported rather than an invented in-range one. See the reporter's type + // documentation. + var config = new TestConfigurationBuilder + { + PoolingOptions = new PoolingOptions().SetMaxRequestsPerConnection(0) + }.Build(); + + var report = DriverConfigReporterTests.BuildReport(config); + + Assert.AreEqual(0, report["connection"]["requests"]["in-flight"]["max"].Value()); + + // That one field is the only thing wrong with the document: putting a positive value in its place + // makes the whole report conform. +#if JSON_SCHEMA_VALIDATOR + Assert.IsFalse(DriverConfigReporterTests.ConformsToSchema(report)); +#endif + report["connection"]["requests"]["in-flight"]["max"] = 1; + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportTheStreamIdCeiling_When_TheConfiguredMaximumExceedsIt() + { + // A connection has 2048 stream identifiers, so it can never have 40000 requests in flight however the + // pool is configured: past the ceiling they wait for an identifier. The binding limit is what the + // schema asks for. + var config = new TestConfigurationBuilder + { + PoolingOptions = new PoolingOptions().SetMaxRequestsPerConnection(40000) + }.Build(); + + var report = DriverConfigReporterTests.BuildReport(config); + + Assert.AreEqual(2048, report["connection"]["requests"]["in-flight"]["max"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportTheConfiguredMaximum_When_ItIsBelowTheStreamIdCeiling() + { + // Below the ceiling the pool's threshold is what a request actually hits first. + var config = new TestConfigurationBuilder + { + PoolingOptions = new PoolingOptions().SetMaxRequestsPerConnection(512) + }.Build(); + + var report = DriverConfigReporterTests.BuildReport(config); + + Assert.AreEqual(512, report["connection"]["requests"]["in-flight"]["max"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportShardAwarenessAsDisabled_When_ItIsDisabled() + { + var config = new TestConfigurationBuilder + { + PoolingOptions = new PoolingOptions().DisableShardAwareness() + }.Build(); + + var report = DriverConfigReporterTests.BuildReport(config); + + Assert.IsFalse(report["connection"]["pool"]["shard-aware"]["enabled"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportTheConfiguredSocketOptions() + { + var config = new TestConfigurationBuilder + { + SocketOptions = new SocketOptions() + .SetTcpNoDelay(false) + .SetKeepAlive(false) + .SetSoLinger(3) + .SetReceiveBufferSize(4096) + .SetSendBufferSize(8192) + }.Build(); + + var report = DriverConfigReporterTests.BuildReport(config); + + var socket = report["connection"]["socket"]; + Assert.IsFalse(socket["tcp-no-delay"].Value()); + Assert.IsFalse(socket["keep-alive"].Value()); + Assert.AreEqual(3, socket["linger"]["interval-s"].Value()); + Assert.AreEqual(4096, socket["receive-buffer"]["size-bytes"].Value()); + Assert.AreEqual(8192, socket["send-buffer"]["size-bytes"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_AlwaysReportReuseAddressAsOff_Even_When_TheDeadOptionIsSet() + { + // SocketOptions.ReuseAddress never meant SO_REUSEADDR: it used to be handed to + // Socket.Disconnect(reuseSocket) and has been read by nothing since. The driver sets SO_REUSEADDR on + // no socket, so the platform default is the truth, and reporting the option would claim otherwise. + var report = DriverConfigReporterTests.BuildReport( + new TestConfigurationBuilder { SocketOptions = new SocketOptions().SetReuseAddress(true) }.Build()); + + Assert.IsFalse(report["connection"]["socket"]["reuse-address"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportAZeroLinger_But_OmitANegativeOne() + { + // The schema admits a non-negative interval, so 0 is reportable; a negative one disables lingering + // close, which the schema has no room for, and the group is optional. + var zero = DriverConfigReporterTests.BuildReport( + new TestConfigurationBuilder { SocketOptions = new SocketOptions().SetSoLinger(0) }.Build()); + var negative = DriverConfigReporterTests.BuildReport( + new TestConfigurationBuilder { SocketOptions = new SocketOptions().SetSoLinger(-1) }.Build()); + + Assert.AreEqual(0, zero["connection"]["socket"]["linger"]["interval-s"].Value()); + Assert.IsNull(negative["connection"]["socket"]["linger"]); + DriverConfigReporterTests.AssertConformsToSchema(zero); + } + + [Test] + public void Should_OmitTheBufferSizes_When_TheyAreNotPositive() + { + var config = new TestConfigurationBuilder + { + SocketOptions = new SocketOptions().SetReceiveBufferSize(0).SetSendBufferSize(-1) + }.Build(); + + var report = DriverConfigReporterTests.BuildReport(config); + + Assert.IsNull(report["connection"]["socket"]["receive-buffer"]); + Assert.IsNull(report["connection"]["socket"]["send-buffer"]); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + //// --------------------------------------------------------------------------------------------------- + //// control-plane + //// --------------------------------------------------------------------------------------------------- + + [Test] + public void Should_OmitTheSystemQueryTimeout_But_KeepTheEnclosingObject_When_ItIsDisabled() + { + var config = new TestConfigurationBuilder + { + SocketOptions = new SocketOptions().SetMetadataAbortTimeout(0) + }.Build(); + + var report = DriverConfigReporterTests.BuildReport(config); + + var timeout = report["control-plane"]["queries"]["system"]["timeout"]; + Assert.IsNotNull(timeout); + Assert.IsNull(timeout["client-side-ms"]); + // There is no client-configurable server-side timeout, so it is never reported. + Assert.IsNull(timeout["server-side-ms"]); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportZeroSchemaAgreement_When_TheConfiguredWaitIsNegative() + { + // ProtocolOptions accepts a negative wait even though Builder rejects one, and a negative wait + // behaves exactly like not waiting, which the schema does admit. + var config = new TestConfigurationBuilder + { + ProtocolOptions = new ProtocolOptions().SetMaxSchemaAgreementWaitSeconds(-5) + }.Build(); + + var report = DriverConfigReporterTests.BuildReport(config); + + Assert.AreEqual(0, report["control-plane"]["schema"]["agreement"]["timeout-ms"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + //// --------------------------------------------------------------------------------------------------- + //// Policies + //// --------------------------------------------------------------------------------------------------- + + [Test] + public void Should_ReportAConstantReconnectionPolicy() + { + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies(reconnectionPolicy: new ConstantReconnectionPolicy(250))); + + var policy = report["connection"]["reconnection"]["policy"]; + Assert.AreEqual("constant", policy["type"].Value()); + Assert.AreEqual(250, policy["delay-ms"].Value()); + Assert.IsNull(policy["max-attempts"]); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportAFixedReconnectionPolicyAsCustom() + { + // One delay per attempt, with the last one repeating forever, matches none of the schema's built-in + // reconnection shapes. + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies(reconnectionPolicy: new FixedReconnectionPolicy(100, 200))); + + var policy = report["connection"]["reconnection"]["policy"]; + Assert.AreEqual("custom", policy["type"].Value()); + Assert.AreEqual("FixedReconnectionPolicy", policy["name"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportACustomReconnectionPolicy() + { + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies(reconnectionPolicy: new FakeReconnectionPolicy())); + + var policy = report["connection"]["reconnection"]["policy"]; + Assert.AreEqual("custom", policy["type"].Value()); + Assert.AreEqual("FakeReconnectionPolicy", policy["name"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportAFallthroughRetryPolicy() + { + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies(retryPolicy: FallthroughRetryPolicy.Instance)); + + Assert.AreEqual("fallthrough", report["query"]["retry"]["policy"]["type"].Value()); + // No built-in retry policy inserts a delay between attempts, which the schema also requires of a + // fallthrough policy specifically, nor does any of them carry a configurable retry limit. + Assert.IsNull(report["query"]["retry"]["backoff"]); + Assert.IsNull(report["query"]["retry"]["policy"]["max-retries"]); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportADowngradingConsistencyRetryPolicy() + { + // Only implements IRetryPolicy, so the driver puts a WrappedExtendedRetryPolicy around it. Looking + // through that wrapper is what keeps this from being reported as a custom policy. +#pragma warning disable 618 + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies(retryPolicy: DowngradingConsistencyRetryPolicy.Instance)); +#pragma warning restore 618 + + Assert.AreEqual("downgrading-consistency", report["query"]["retry"]["policy"]["type"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + [TestCaseSource(nameof(DriverConfigReporterTests.BuiltInRetryPolicies))] + public void Should_OmitTheRetryLimit_For_EveryBuiltInRetryPolicy(IRetryPolicy builtIn, string expectedType) + { + // The schema admits an optional max-retries on every built-in branch but fallthrough, and reads its + // absence as "no explicit retry limit configured". The driver's policies have fixed, non-configurable + // rules rather than a limit, so it is never reported. + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies(retryPolicy: builtIn)); + + var policy = report["query"]["retry"]["policy"]; + Assert.AreEqual(expectedType, policy["type"].Value()); + Assert.IsNull(policy["max-retries"]); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + private static IEnumerable BuiltInRetryPolicies() + { + yield return new object[] { new DefaultRetryPolicy(), "standard-error-aware" }; + yield return new object[] { FallthroughRetryPolicy.Instance, "fallthrough" }; +#pragma warning disable 618 + yield return new object[] { DowngradingConsistencyRetryPolicy.Instance, "downgrading-consistency" }; +#pragma warning restore 618 + } + + [Test] + public void Should_ReportTheDecoratedRetryPolicy_When_TheDecoratorPassesTheDecisionThrough() + { + // LoggingRetryPolicy logs its child's decision and returns it unchanged, so the child is what decides + // the retries and the schema has no shape for the decorator itself. + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies( + retryPolicy: new LoggingRetryPolicy(FallthroughRetryPolicy.Instance))); + + Assert.AreEqual("fallthrough", report["query"]["retry"]["policy"]["type"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportACustomRetryPolicy_When_IdempotenceAwareWrapsABuiltIn() + { + // IdempotenceAwareRetryPolicy rethrows non-idempotent write timeouts and request errors instead of + // asking its child, so reporting the child's type would promise retry rules that two of the four + // decision points never reach. + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies( + retryPolicy: new IdempotenceAwareRetryPolicy(FallthroughRetryPolicy.Instance))); + + var policy = report["query"]["retry"]["policy"]; + Assert.AreEqual("custom", policy["type"].Value()); + Assert.AreEqual("IdempotenceAwareRetryPolicy", policy["name"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportACustomRetryPolicy_By_TheOutermostName_When_IdempotenceAwareIsNested() + { + // The chain stops at the opaque decorator, so nothing built-in is found and the name is the outermost + // policy the application configured. + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies( + retryPolicy: new LoggingRetryPolicy(new IdempotenceAwareRetryPolicy(FallthroughRetryPolicy.Instance)))); + + var policy = report["query"]["retry"]["policy"]; + Assert.AreEqual("custom", policy["type"].Value()); + Assert.AreEqual("LoggingRetryPolicy", policy["name"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportACustomRetryPolicy_By_TheNameTheApplicationConfigured() + { + // Named after the policy the application handed to the builder, not after the internal + // WrappedExtendedRetryPolicy the driver puts around a plain IRetryPolicy. + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies(retryPolicy: new FakeRetryPolicy())); + + var policy = report["query"]["retry"]["policy"]; + Assert.AreEqual("custom", policy["type"].Value()); + Assert.AreEqual("FakeRetryPolicy", policy["name"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportACustomRetryPolicy_By_TheDecoratorName_When_ItDecoratesACustomPolicy() + { + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies(retryPolicy: new LoggingRetryPolicy(new FakeRetryPolicy()))); + + var policy = report["query"]["retry"]["policy"]; + Assert.AreEqual("custom", policy["type"].Value()); + Assert.AreEqual("LoggingRetryPolicy", policy["name"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportAConstantSpeculativeExecutionPolicy() + { + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies(speculativeExecutionPolicy: new ConstantSpeculativeExecutionPolicy(150, 3))); + + var policy = report["query"]["speculative-execution"]["policy"]; + Assert.AreEqual("constant", policy["type"].Value()); + Assert.AreEqual(3, policy["max-executions"].Value()); + Assert.AreEqual(150, policy["delay-ms"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportACustomSpeculativeExecutionPolicy() + { + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies(speculativeExecutionPolicy: new FakeSpeculativeExecutionPolicy())); + + var policy = report["query"]["speculative-execution"]["policy"]; + Assert.AreEqual("custom", policy["type"].Value()); + Assert.AreEqual("FakeSpeculativeExecutionPolicy", policy["name"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportATokenAwareLoadBalancingPolicy() + { + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies( + loadBalancingPolicy: new TokenAwarePolicy(new DCAwareRoundRobinPolicy("dc2")))); + + var policy = report["query"]["load-balancing"]["policy"]; + Assert.AreEqual("token-aware", policy["type"].Value()); + // TokenAwarePolicy starts the local replicas at a pseudo-random index for every query plan. + Assert.AreEqual("shuffle", policy["load-distribution"].Value()); + Assert.IsFalse(policy["fallback-to-non-preferred-nodes"].Value()); + // The driver does not reorder candidates on runtime signals. + Assert.IsNull(policy["adaptive-ordering"]); + + // The datacenter preference comes from a policy two levels into the chain. + var preference = report["query"]["load-balancing"]["node-preference"]; + Assert.AreEqual("dc", preference["type"].Value()); + Assert.AreEqual("dc2", preference["local-dc"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_NotReportFallback_For_ARoundRobinChild() + { + // Round robin treats every host as local, so in a multi-datacenter cluster a query can land on a + // remote node — but it declares no preference for a request to fall outside of, and none is reported + // under node-preference, which is what the flag is defined against. Cross-driver decision: false is + // the least misleading answer available, the schema requiring the flag either way. + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies(loadBalancingPolicy: new TokenAwarePolicy(new RoundRobinPolicy()))); + + var policy = report["query"]["load-balancing"]["policy"]; + Assert.AreEqual("token-aware", policy["type"].Value()); + Assert.IsFalse(policy["fallback-to-non-preferred-nodes"].Value()); + Assert.IsNull(report["query"]["load-balancing"]["node-preference"]); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportACustomLoadBalancingPolicy_When_ADriverPolicyWrapsAnApplicationOne() + { + // The flags describe the whole chain, and this one reaches a policy whose query plans the reporter + // cannot see, so they cannot be derived. Reporting the built-in shape would assert a load + // distribution and a fallback behaviour that nothing here knows to be true. + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies(loadBalancingPolicy: new TokenAwarePolicy(new FakeLoadBalancingPolicy()))); + + var policy = report["query"]["load-balancing"]["policy"]; + Assert.AreEqual("custom", policy["type"].Value()); + Assert.AreEqual("TokenAwarePolicy", policy["name"].Value()); + Assert.IsNull(policy["load-distribution"]); + Assert.IsNull(policy["fallback-to-non-preferred-nodes"]); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportFallback_When_RemoteDatacenterHostsAreUsed() + { +#pragma warning disable 618 + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies( + loadBalancingPolicy: new TokenAwarePolicy(new DCAwareRoundRobinPolicy("dc1", 2)))); +#pragma warning restore 618 + + Assert.IsTrue(report["query"]["load-balancing"]["policy"]["fallback-to-non-preferred-nodes"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportADefaultLoadBalancingPolicyWithLocalDc() + { + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies(loadBalancingPolicy: new DefaultLoadBalancingPolicy("dc3"))); + + Assert.AreEqual("token-aware", report["query"]["load-balancing"]["policy"]["type"].Value()); + Assert.AreEqual("dc3", report["query"]["load-balancing"]["node-preference"]["local-dc"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportAnInferredDatacenterPreference_When_NoDatacenterIsConfigured() + { + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies(loadBalancingPolicy: new DCAwareRoundRobinPolicy())); + + // The datacenter is not known while the first control connection is being opened, so the preference + // is reported as inferred with no name yet. + var preference = report["query"]["load-balancing"]["node-preference"]; + Assert.AreEqual("dc-auto", preference["type"].Value()); + Assert.IsNull(preference["local-dc"]); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportTheInferredDatacenter_Once_ThePolicyHasDiscoveredIt() + { + // The report is rebuilt for every control connection, so a later one describes a policy that has + // since inferred its datacenter: still "dc-auto", but now with the name it settled on. + var clusterMock = new Mock(); + clusterMock.Setup(c => c.AllHosts()).Returns(new[] { TestHelper.CreateHost("127.0.0.1", "dc9") }); + + var dcAware = new DCAwareRoundRobinPolicy(); + dcAware.Initialize(clusterMock.Object); + + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies(loadBalancingPolicy: new TokenAwarePolicy(dcAware))); + + var preference = report["query"]["load-balancing"]["node-preference"]; + Assert.AreEqual("dc-auto", preference["type"].Value()); + Assert.AreEqual("dc9", preference["local-dc"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportAnInferredDatacenterPreference_When_TheConfiguredDatacenterIsEmpty() + { + // The schema requires a non-empty name, and a policy configured this way would reject every + // datacenter when it initializes, so an empty name is treated as no name at all. + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies(loadBalancingPolicy: new DCAwareRoundRobinPolicy(string.Empty))); + + var preference = report["query"]["load-balancing"]["node-preference"]; + Assert.AreEqual("dc-auto", preference["type"].Value()); + Assert.IsNull(preference["local-dc"]); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + [TestCaseSource(nameof(DriverConfigReporterTests.NonTokenAwareBuiltInPolicies))] + public void Should_ReportABuiltInPolicyAsCustom_When_ItIsNotTokenAware(ILoadBalancingPolicy builtIn, string expectedName) + { + // The schema describes exactly one built-in load balancing shape, the token-aware policy, so a + // built-in chain without token awareness has nothing to be reported under but "custom". + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies(loadBalancingPolicy: builtIn)); + + var policy = report["query"]["load-balancing"]["policy"]; + Assert.AreEqual("custom", policy["type"].Value()); + Assert.AreEqual(expectedName, policy["name"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + private static IEnumerable NonTokenAwareBuiltInPolicies() + { + yield return new object[] { new RoundRobinPolicy(), "RoundRobinPolicy" }; + yield return new object[] { new DCAwareRoundRobinPolicy("dc1"), "DCAwareRoundRobinPolicy" }; + } + + [Test] + public void Should_ReportTheDatacenterPreference_When_TheChainIsReportedAsCustom() + { + // node-preference is a sibling of the policy rather than part of it, so a chain the schema has no + // built-in shape for still contributes its datacenter preference. + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies(loadBalancingPolicy: new DCAwareRoundRobinPolicy("dc1"))); + + Assert.AreEqual("custom", report["query"]["load-balancing"]["policy"]["type"].Value()); + + var preference = report["query"]["load-balancing"]["node-preference"]; + Assert.AreEqual("dc", preference["type"].Value()); + Assert.AreEqual("dc1", preference["local-dc"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportACustomLoadBalancingPolicy() + { + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies(loadBalancingPolicy: new FakeLoadBalancingPolicy())); + + var policy = report["query"]["load-balancing"]["policy"]; + Assert.AreEqual("custom", policy["type"].Value()); + Assert.AreEqual("FakeLoadBalancingPolicy", policy["name"].Value()); + Assert.IsNull(report["query"]["load-balancing"]["node-preference"]); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportACustomLoadBalancingPolicy_By_TheOutermostConfiguredName() + { + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies( + loadBalancingPolicy: new RetryLoadBalancingPolicy(new FakeLoadBalancingPolicy(), new ConstantReconnectionPolicy(1)))); + + Assert.AreEqual("RetryLoadBalancingPolicy", report["query"]["load-balancing"]["policy"]["name"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportACustomLoadBalancingPolicy_When_RetryLoadBalancingPolicyWrapsABuiltInChain() + { + // Every policy underneath is one the driver knows, but RetryLoadBalancingPolicy is not a transparent + // delegator: its plan re-enumerates the child's in an unbounded loop and sleeps between passes. Flags + // taken from the chain below would describe plain token-aware routing and hide that entirely. + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies( + loadBalancingPolicy: new RetryLoadBalancingPolicy( + new TokenAwarePolicy(new DCAwareRoundRobinPolicy("dc1")), new ConstantReconnectionPolicy(100)))); + + var policy = report["query"]["load-balancing"]["policy"]; + Assert.AreEqual("custom", policy["type"].Value()); + Assert.AreEqual("RetryLoadBalancingPolicy", policy["name"].Value()); + Assert.IsNull(policy["load-distribution"]); + + // The datacenter preference below it is still in force: it delegates Distance to the child, so which + // nodes are local is unchanged. + var preference = report["query"]["load-balancing"]["node-preference"]; + Assert.AreEqual("dc", preference["type"].Value()); + Assert.AreEqual("dc1", preference["local-dc"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_StopWalkingTheLoadBalancingChain_At_TheBound() + { + // Nested past the reporter's chain bound of 16. The walk must stop rather than follow an arbitrarily + // deep chain, which is observable here: the datacenter-aware policy sits below the bound and so is + // never seen, leaving no node preference to report. + ILoadBalancingPolicy policy = new DCAwareRoundRobinPolicy("dc1"); + for (var i = 0; i < 20; i++) + { + policy = new TokenAwarePolicy(policy); + } + + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies(loadBalancingPolicy: policy)); + + Assert.AreEqual("token-aware", report["query"]["load-balancing"]["policy"]["type"].Value()); + Assert.IsNull(report["query"]["load-balancing"]["node-preference"]); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_StopWalkingTheRetryChain_At_TheBound() + { + // Same bound on the retry walk: the fallthrough policy below it is never reached, so the group falls + // back to naming the outermost policy instead of reporting the built-in type. + IRetryPolicy policy = FallthroughRetryPolicy.Instance; + for (var i = 0; i < 20; i++) + { + policy = new LoggingRetryPolicy(policy); + } + + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies(retryPolicy: policy)); + + var reported = report["query"]["retry"]["policy"]; + Assert.AreEqual("custom", reported["type"].Value()); + Assert.AreEqual("LoggingRetryPolicy", reported["name"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportTheProfilePolicies_When_TheDefaultProfileOverridesThem() + { + var config = new TestConfigurationBuilder + { + ExecutionProfiles = new Dictionary + { + { + Configuration.DefaultExecutionProfileName, + new ExecutionProfileBuilder() + .WithLoadBalancingPolicy(new RoundRobinPolicy()) + .WithRetryPolicy(FallthroughRetryPolicy.Instance) + .WithSpeculativeExecutionPolicy(new ConstantSpeculativeExecutionPolicy(50, 2)) + .CastToClass() + .Build() + } + } + }.Build(); + + var report = DriverConfigReporterTests.BuildReport(config); + + Assert.AreEqual("RoundRobinPolicy", report["query"]["load-balancing"]["policy"]["name"].Value()); + Assert.AreEqual("fallthrough", report["query"]["retry"]["policy"]["type"].Value()); + Assert.AreEqual("constant", report["query"]["speculative-execution"]["policy"]["type"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + //// --------------------------------------------------------------------------------------------------- + //// query defaults and tls + //// --------------------------------------------------------------------------------------------------- + + [Test] + public void Should_ReportTheConfiguredQueryDefaults() + { + var config = new TestConfigurationBuilder + { + QueryOptions = new QueryOptions() + .SetConsistencyLevel(ConsistencyLevel.LocalQuorum) + .SetSerialConsistencyLevel(ConsistencyLevel.LocalSerial) + .SetPageSize(100) + .SetDefaultIdempotence(true) + }.Build(); + + var report = DriverConfigReporterTests.BuildReport(config); + + var queryDefaults = report["query"]["defaults"]; + Assert.AreEqual("LOCAL_QUORUM", queryDefaults["consistency"].Value()); + Assert.AreEqual("LOCAL_SERIAL", queryDefaults["serial-consistency"].Value()); + Assert.AreEqual(100, queryDefaults["page"]["size"].Value()); + Assert.IsTrue(queryDefaults["idempotence"].Value()); + Assert.IsTrue(queryDefaults["client-timestamps"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportClientTimestamps_For_TheBuiltInGenerator() + { + // The built-in always returns a real timestamp, so the driver certainly assigns one client-side. + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies(timestampGenerator: new AtomicMonotonicTimestampGenerator())); + + Assert.IsTrue(report["query"]["defaults"]["client-timestamps"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + + // The Windows generator is covered by the same check through inheritance, which is asserted rather + // than exercised: constructing it needs Kernel32, so it cannot run on every platform. + Assert.IsTrue( + typeof(AtomicMonotonicTimestampGenerator).IsAssignableFrom(typeof(AtomicMonotonicWinApiTimestampGenerator))); + } + + [Test] + public void Should_OmitClientTimestamps_When_TheApplicationSuppliesItsOwnGenerator() + { + // An ITimestampGenerator hands assignment back to the coordinator by returning long.MinValue, and may + // do so per request, so the driver cannot vouch for client-side assignment. Same refusal to claim an + // unverifiable property as tls.hostname-verification. + var report = DriverConfigReporterTests.BuildReport( + DriverConfigReporterTests.WithPolicies(timestampGenerator: new ServerSideTimestampGenerator())); + + Assert.IsNull(report["query"]["defaults"]["client-timestamps"]); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_OmitThePageGroup_When_PagingIsDisabled() + { + // int.MaxValue is how the driver spells "do not page": QueryProtocolOptions turns it into -1 and + // leaves the page-size flag unset, so the server is sent no limit at all. Reporting the number would + // claim a two-billion-row bound that nothing enforces. + var config = new TestConfigurationBuilder { QueryOptions = new QueryOptions().SetPageSize(int.MaxValue) }.Build(); + + var report = DriverConfigReporterTests.BuildReport(config); + + Assert.IsNull(report["query"]["defaults"]["page"]); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_OmitTheRequestGroup_When_TheTimeoutIsInfinite() + { + // Timeout.Infinite is the only value that means there is no bound, Task.Wait waiting forever only for + // -1. Both the group and its key are optional, so the group goes rather than being left empty. + var config = new TestConfigurationBuilder + { + ClientOptions = new ClientOptions(false, Timeout.Infinite, null) + }.Build(); + + var report = DriverConfigReporterTests.BuildReport(config); + + Assert.IsNull(report["query"]["defaults"]["request"]); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportTls_When_ItIsEnabled() + { + var config = new TestConfigurationBuilder + { + ProtocolOptions = new ProtocolOptions(ProtocolOptions.DefaultPort, new SSLOptions()) + }.Build(); + + var report = DriverConfigReporterTests.BuildReport(config); + + // The callback the driver installs by default rejects a host name mismatch. + Assert.IsTrue(report["connection"]["tls"]["hostname-verification"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_OmitHostnameVerification_When_TheApplicationSuppliesItsOwnValidation() + { + // What an application supplied callback accepts is not introspectable, so the report must not claim + // a verification the driver cannot vouch for. + var sslOptions = new SSLOptions().SetRemoteCertValidationCallback( + (object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors errors) => true); + var config = new TestConfigurationBuilder + { + ProtocolOptions = new ProtocolOptions(ProtocolOptions.DefaultPort, sslOptions) + }.Build(); + + var report = DriverConfigReporterTests.BuildReport(config); + + // The group stays, since TLS is on; only the fact the driver cannot establish goes missing. + Assert.IsNotNull(report["connection"]["tls"]); + Assert.IsNull(report["connection"]["tls"]["hostname-verification"]); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + public void Should_ReportHostnameVerification_When_TheValidationCallbackIsNull() + { + // A null callback leaves .NET's own certificate validation in place, which does verify the host name. + var sslOptions = new SSLOptions().SetRemoteCertValidationCallback(null); + var config = new TestConfigurationBuilder + { + ProtocolOptions = new ProtocolOptions(ProtocolOptions.DefaultPort, sslOptions) + }.Build(); + + var report = DriverConfigReporterTests.BuildReport(config); + + Assert.IsTrue(report["connection"]["tls"]["hostname-verification"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + //// --------------------------------------------------------------------------------------------------- + //// Schema conformance + //// --------------------------------------------------------------------------------------------------- + + [Test] + public void Should_ProduceAReportThatConformsToTheSchema() + { + DriverConfigReporterTests.AssertConformsToSchema( + DriverConfigReporterTests.BuildReport(DriverConfigReporterTests.DefaultConfiguration())); + } + + [Test] + public void Should_RejectAnUnknownTopLevelKey() + { + // Proves the schema's additionalProperties:false really is enforced by the validator, so the + // conformance assertions above are not vacuous. + var report = DriverConfigReporterTests.BuildReport(DriverConfigReporterTests.DefaultConfiguration()); + report["not-in-the-schema"] = true; + +#if JSON_SCHEMA_VALIDATOR + Assert.IsFalse(DriverConfigReporterTests.ConformsToSchema(report)); +#endif + } + + [Test] + [TestCase(ConsistencyLevel.Any, "ANY")] + [TestCase(ConsistencyLevel.One, "ONE")] + [TestCase(ConsistencyLevel.Two, "TWO")] + [TestCase(ConsistencyLevel.Three, "THREE")] + [TestCase(ConsistencyLevel.Quorum, "QUORUM")] + [TestCase(ConsistencyLevel.All, "ALL")] + [TestCase(ConsistencyLevel.LocalQuorum, "LOCAL_QUORUM")] + [TestCase(ConsistencyLevel.EachQuorum, "EACH_QUORUM")] + [TestCase(ConsistencyLevel.LocalOne, "LOCAL_ONE")] + [TestCase(ConsistencyLevel.Serial, "SERIAL")] + [TestCase(ConsistencyLevel.LocalSerial, "LOCAL_SERIAL")] + public void Should_ReportEveryConsistencyLevelTheSchemaLists(ConsistencyLevel consistency, string expected) + { + var config = new TestConfigurationBuilder + { + QueryOptions = new QueryOptions().SetConsistencyLevel(consistency) + }.Build(); + + var report = DriverConfigReporterTests.BuildReport(config); + + Assert.AreEqual(expected, report["query"]["defaults"]["consistency"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + [Test] + [TestCase(ConsistencyLevel.Serial, "SERIAL")] + [TestCase(ConsistencyLevel.LocalSerial, "LOCAL_SERIAL")] + public void Should_ReportASerialDefaultConsistency(ConsistencyLevel consistency, string expected) + { + // QueryOptions accepts a serial level as the default consistency and RequestHandler routes such a + // request as an LWT, so it is a real configuration; the schema's enum lists both levels. + var config = new TestConfigurationBuilder + { + QueryOptions = new QueryOptions().SetConsistencyLevel(consistency) + }.Build(); + + var report = DriverConfigReporterTests.BuildReport(config); + + Assert.AreEqual(expected, report["query"]["defaults"]["consistency"].Value()); + DriverConfigReporterTests.AssertConformsToSchema(report); + } + + //// --------------------------------------------------------------------------------------------------- + //// Helpers + //// --------------------------------------------------------------------------------------------------- + + /// + /// A configuration built the way builds one: no explicit + /// , so the defaults for the negotiated protocol version apply. + /// + private static Configuration DefaultConfiguration() + { + return new TestConfigurationBuilder { PoolingOptions = null }.Build(); + } + + private static Configuration WithPolicies( + ILoadBalancingPolicy loadBalancingPolicy = null, + IReconnectionPolicy reconnectionPolicy = null, + IRetryPolicy retryPolicy = null, + ISpeculativeExecutionPolicy speculativeExecutionPolicy = null, + ITimestampGenerator timestampGenerator = null) + { + return new TestConfigurationBuilder + { + PoolingOptions = null, + Policies = new Cassandra.Policies( + loadBalancingPolicy ?? Cassandra.Policies.DefaultLoadBalancingPolicy, + reconnectionPolicy ?? Cassandra.Policies.DefaultReconnectionPolicy, + retryPolicy ?? Cassandra.Policies.DefaultRetryPolicy, + speculativeExecutionPolicy ?? Cassandra.Policies.DefaultSpeculativeExecutionPolicy, + timestampGenerator ?? Cassandra.Policies.DefaultTimestampGenerator, + null) + }.Build(); + } + + private static JObject BuildReport(Configuration configuration) + { + var options = new Dictionary(); + + new DriverConfigReporter(configuration).AddStartupOptions(options); + + Assert.IsTrue(options.ContainsKey(DriverConfigReporter.DriverConfigOption), "The report was dropped."); + return JObject.Parse(options[DriverConfigReporter.DriverConfigOption]); + } + + /// + /// Asserts that satisfies the normative v1 schema. Does nothing on a target + /// framework without the validator (see JSON_SCHEMA_VALIDATOR in the project file): the report is one code + /// path with no per-framework behaviour, so the net8/net9 runs establish its conformance everywhere. + /// + private static void AssertConformsToSchema(JObject report) + { +#if JSON_SCHEMA_VALIDATOR + var results = DriverConfigReporterTests.Evaluate(report); + + Assert.IsTrue( + results.IsValid, + "The report does not conform to the v1 schema: " + DriverConfigReporterTests.Describe(results) + + Environment.NewLine + report.ToString(Newtonsoft.Json.Formatting.None)); +#endif + } + +#if JSON_SCHEMA_VALIDATOR + private static bool ConformsToSchema(JObject report) + { + return DriverConfigReporterTests.Evaluate(report).IsValid; + } + + private static EvaluationResults Evaluate(JObject report) + { + using (var document = JsonDocument.Parse(report.ToString(Newtonsoft.Json.Formatting.None))) + { + return DriverConfigReporterTests.Schema.Evaluate( + document.RootElement, new EvaluationOptions { OutputFormat = OutputFormat.List }); + } + } + + /// + /// The failing nodes of , for the assertion message. Every branch of a + /// discriminated union that did not match contributes a failure of its own, so this is a diagnostic aid + /// rather than a list of things that are actually wrong — which is why nothing asserts on its contents. + /// + private static string Describe(EvaluationResults results) + { + var failures = results.Details + .Where(detail => !detail.IsValid && detail.Errors != null && detail.Errors.Count > 0) + .SelectMany(detail => detail.Errors.Select( + error => detail.InstanceLocation + ": " + error.Key + " " + error.Value)); + + return string.Join("; ", failures); + } + + private static JsonSchema LoadSchema() + { + const string resourceName = "Cassandra.Tests.Requests.driver-config-report-v1.schema.json"; + var assembly = typeof(DriverConfigReporterTests).GetTypeInfo().Assembly; + + using (var stream = assembly.GetManifestResourceStream(resourceName)) + { + if (stream == null) + { + throw new InvalidOperationException( + "Could not find the embedded schema '" + resourceName + "'. Available resources: " + + string.Join(", ", assembly.GetManifestResourceNames())); + } + + using (var reader = new StreamReader(stream)) + { + return JsonSchema.FromText(reader.ReadToEnd()); + } + } + } +#endif + private class OversizedDriverConfigReporter : DriverConfigReporter { private readonly string _report; - public OversizedDriverConfigReporter(string report) + public OversizedDriverConfigReporter(string report) : base(new TestConfigurationBuilder().Build()) { _report = report; } @@ -103,10 +1302,87 @@ protected override string BuildReport() private class ThrowingDriverConfigReporter : DriverConfigReporter { + public ThrowingDriverConfigReporter() : base(new TestConfigurationBuilder().Build()) + { + } + protected override string BuildReport() { throw new InvalidOperationException("Simulated failure while building the report."); } } + + private class FakeLoadBalancingPolicy : ILoadBalancingPolicy + { + public void Initialize(ICluster cluster) + { + } + + public HostDistance Distance(Host host) + { + return HostDistance.Local; + } + + public IEnumerable NewQueryPlan(string keyspace, IStatement query) + { + return Enumerable.Empty(); + } + } + + private class FakeReconnectionPolicy : IReconnectionPolicy + { + public IReconnectionSchedule NewSchedule() + { + return null; + } + } + + private class FakeRetryPolicy : IRetryPolicy + { + public RetryDecision OnReadTimeout( + IStatement query, ConsistencyLevel cl, int requiredResponses, int receivedResponses, bool dataRetrieved, int nbRetry) + { + return RetryDecision.Rethrow(); + } + + public RetryDecision OnWriteTimeout( + IStatement query, ConsistencyLevel cl, string writeType, int requiredAcks, int receivedAcks, int nbRetry) + { + return RetryDecision.Rethrow(); + } + + public RetryDecision OnUnavailable(IStatement query, ConsistencyLevel cl, int requiredReplica, int aliveReplica, int nbRetry) + { + return RetryDecision.Rethrow(); + } + } + + /// + /// Hands timestamp assignment to the coordinator the documented way, by returning + /// , which is exactly the case a hardcoded true would misreport. + /// + private class ServerSideTimestampGenerator : ITimestampGenerator + { + public long Next() + { + return long.MinValue; + } + } + + private class FakeSpeculativeExecutionPolicy : ISpeculativeExecutionPolicy + { + public void Dispose() + { + } + + public void Initialize(ICluster cluster) + { + } + + public ISpeculativeExecutionPlan NewPlan(string keyspace, IStatement statement) + { + return null; + } + } } } diff --git a/src/Cassandra.Tests/Requests/StartupOptionsFactoryTests.cs b/src/Cassandra.Tests/Requests/StartupOptionsFactoryTests.cs index 80ac9bc5f..52c91913c 100644 --- a/src/Cassandra.Tests/Requests/StartupOptionsFactoryTests.cs +++ b/src/Cassandra.Tests/Requests/StartupOptionsFactoryTests.cs @@ -18,6 +18,7 @@ using System.Reflection; using Cassandra.Helpers; using Cassandra.Requests; +using Newtonsoft.Json.Linq; using NUnit.Framework; using Assert = NUnit.Framework.Legacy.ClassicAssert; @@ -30,7 +31,7 @@ public class StartupOptionsFactoryTests public void Should_ReturnCorrectProtocolStartupOptions_When_OptionsAreSet() { var sessionId = Guid.NewGuid(); - var factory = new StartupOptionsFactory(Guid.NewGuid(), sessionId, null, null, new DriverConfigReporter()); + var factory = new StartupOptionsFactory(Guid.NewGuid(), sessionId, null, null, new DriverConfigReporter(new TestConfigurationBuilder().Build())); var options = factory.CreateStartupOptions(new ProtocolOptions().SetNoCompact(true).SetCompression(CompressionType.Snappy)); @@ -63,7 +64,7 @@ public void Should_ReturnCorrectProtocolStartupOptions_When_OptionsAreSet() public void Should_NotReturnOptions_When_OptionsAreNull() { var clusterId = Guid.NewGuid(); - var factory = new StartupOptionsFactory(clusterId, null, null, new DriverConfigReporter()); + var factory = new StartupOptionsFactory(clusterId, null, null, new DriverConfigReporter(new TestConfigurationBuilder().Build())); var options = factory.CreateStartupOptions(new ProtocolOptions().SetNoCompact(true).SetCompression(CompressionType.Snappy)); @@ -75,7 +76,7 @@ public void Should_NotReturnOptions_When_OptionsAreNull() [Test] public void Should_ReportTheSameSessionId_When_OptionsAreBuiltForSeveralConnections() { - var factory = new StartupOptionsFactory(Guid.NewGuid(), null, null, new DriverConfigReporter()); + var factory = new StartupOptionsFactory(Guid.NewGuid(), null, null, new DriverConfigReporter(new TestConfigurationBuilder().Build())); var controlConnectionOptions = factory.CreateStartupOptions(new ProtocolOptions(), null, true); var poolOptions = factory.CreateStartupOptions(new ProtocolOptions(), null, false); @@ -87,8 +88,8 @@ public void Should_ReportTheSameSessionId_When_OptionsAreBuiltForSeveralConnecti public void Should_ReportDistinctSessionIds_When_ThereAreSeveralClusters() { var clusterId = Guid.NewGuid(); - var firstFactory = new StartupOptionsFactory(clusterId, null, null, new DriverConfigReporter()); - var secondFactory = new StartupOptionsFactory(clusterId, null, null, new DriverConfigReporter()); + var firstFactory = new StartupOptionsFactory(clusterId, null, null, new DriverConfigReporter(new TestConfigurationBuilder().Build())); + var secondFactory = new StartupOptionsFactory(clusterId, null, null, new DriverConfigReporter(new TestConfigurationBuilder().Build())); var firstOptions = firstFactory.CreateStartupOptions(new ProtocolOptions()); var secondOptions = secondFactory.CreateStartupOptions(new ProtocolOptions()); @@ -99,17 +100,19 @@ public void Should_ReportDistinctSessionIds_When_ThereAreSeveralClusters() [Test] public void Should_ReportDriverConfig_When_OptionsAreForTheControlConnection() { - var factory = new StartupOptionsFactory(Guid.NewGuid(), null, null, new DriverConfigReporter()); + var factory = new StartupOptionsFactory(Guid.NewGuid(), null, null, new DriverConfigReporter(new TestConfigurationBuilder().Build())); var options = factory.CreateStartupOptions(new ProtocolOptions(), null, true); - Assert.AreEqual("{\"version\":1}", options["DRIVER_CONFIG"]); + // What the report contains is covered by DriverConfigReporterTests; here it only has to arrive. + Assert.AreEqual( + DriverConfigReporter.SchemaVersion, JObject.Parse(options["DRIVER_CONFIG"])["version"].Value()); } [Test] public void Should_NotReportDriverConfig_When_OptionsAreNotForTheControlConnection() { - var factory = new StartupOptionsFactory(Guid.NewGuid(), null, null, new DriverConfigReporter()); + var factory = new StartupOptionsFactory(Guid.NewGuid(), null, null, new DriverConfigReporter(new TestConfigurationBuilder().Build())); var options = factory.CreateStartupOptions(new ProtocolOptions(), null, false); diff --git a/src/Cassandra/Configuration.cs b/src/Cassandra/Configuration.cs index 7c12ddbae..52a2f6828 100644 --- a/src/Cassandra/Configuration.cs +++ b/src/Cassandra/Configuration.cs @@ -339,7 +339,9 @@ internal Configuration(Policies policies, ClusterId, ApplicationVersion, ApplicationName, - DriverConfigReportingEnabled ? new DriverConfigReporter() : null); + // Handing `this` out of a constructor is safe here: the reporter only reads the configuration when + // it builds a report, which happens on a control connection handshake, long after this returns. + DriverConfigReportingEnabled ? new DriverConfigReporter(this) : null); SessionFactory = sessionFactory ?? new SessionFactory(); RequestOptionsMapper = requestOptionsMapper ?? new RequestOptionsMapper(); MetadataSyncOptions = metadataSyncOptions?.Clone() ?? new MetadataSyncOptions(); diff --git a/src/Cassandra/Connections/Connection.cs b/src/Cassandra/Connections/Connection.cs index 0387dfd74..ad5fcfbe0 100644 --- a/src/Cassandra/Connections/Connection.cs +++ b/src/Cassandra/Connections/Connection.cs @@ -220,7 +220,17 @@ private void DecrementInFlight() /// public int GetMaxConcurrentRequests(ISerializer serializer) { - if (!serializer.ProtocolVersion.Uses2BytesStreamIds()) + return Connection.GetMaxConcurrentRequests(serializer.ProtocolVersion); + } + + /// + /// The size of a connection's stream identifier pool, and so the number of requests it can have in flight + /// before further ones have to wait for an identifier to come free. Depends only on the protocol version, + /// never on how the pool is configured. + /// + internal static int GetMaxConcurrentRequests(ProtocolVersion protocolVersion) + { + if (!protocolVersion.Uses2BytesStreamIds()) { return 128; } diff --git a/src/Cassandra/Requests/DriverConfigReporter.cs b/src/Cassandra/Requests/DriverConfigReporter.cs index a27bdbc84..9b940273d 100644 --- a/src/Cassandra/Requests/DriverConfigReporter.cs +++ b/src/Cassandra/Requests/DriverConfigReporter.cs @@ -17,45 +17,104 @@ using System; using System.Collections.Generic; using System.Text; +using System.Threading; +using Cassandra.ExecutionProfiles; using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace Cassandra.Requests { /// + /// + /// Builds the JSON description of the effective driver configuration that the control connection reports + /// under the DRIVER_CONFIG STARTUP option. + /// + /// The document follows the cross-driver v1 schema: kebab-case keys, nested objects, and omission of + /// any key or group that has no value. Nothing is ever written as null, and the same rule applies + /// where a configured value falls outside what the schema can express but the key is optional: a + /// disabled read timeout, a disabled SO_LINGER and a non-positive buffer size are left out rather + /// than reported as a number the schema rejects. + /// + /// + /// Where the driver cannot establish a fact at all, rather than merely lacking a way to express it, the key + /// is omitted too: the schema reads an absent client-timestamps or hostname-verification as + /// "unknown", which is the honest answer for an application supplied timestamp generator or certificate + /// validation callback. + /// + /// + /// Known limitation. One field remains that the schema cannot express and the driver does not + /// validate: takes any , while + /// in-flight.max is required and must be positive. Such a value is reported as-is — + /// fabricating an in-range one would misreport a setting the operator may have chosen deliberately, and + /// dropping the whole report over a single field would lose everything else — so the document is accurate but + /// fails validation on that one field. It is also logged, so the mismatch is visible at runtime. + /// + /// + /// That field reports whichever of two per-connection limits binds first: the configured threshold above + /// which rejects a borrow with a + /// , and the size of the connection's stream identifier pool, which + /// Connection.GetMaxConcurrentRequests fixes at 2048 — or 128 for single-byte stream ids — + /// independently of how the pool is configured. They coincide at the default and the configured value binds + /// below it; above it the stream identifiers do, since further requests wait for one rather than travelling + /// concurrently. + /// + /// internal class DriverConfigReporter : IDriverConfigReporter { /// /// STARTUP option holding the JSON description of the effective driver configuration. /// - internal const string DriverConfigOption = "DRIVER_CONFIG"; + public const string DriverConfigOption = "DRIVER_CONFIG"; /// /// Major version of the reported configuration schema. Adding keys to the report is backwards /// compatible and does not bump it, only changing or removing the meaning of an existing key does. /// - internal const int SchemaVersion = 1; + public const int SchemaVersion = 1; /// /// Upper bound for the length, in bytes, of the DRIVER_CONFIG value. /// /// prefixes every STARTUP value with an unchecked 16 bit /// length, so a longer value would silently truncate that prefix modulo 65536 while still writing the - /// whole body, corrupting the frame and failing the handshake. The report is a handful of bytes for - /// now, but the configuration groups added later describe user supplied values, such as the settings - /// of custom policies, and can grow arbitrarily large. Enforcing a limit here keeps a connection from - /// ever being broken by what is only a diagnostic aid. + /// whole body, corrupting the frame and failing the handshake. Note that nothing throws on that path, + /// so it is not a failure the try/catch in could contain. + /// + /// + /// Most of the report is fixed-shape, but parts of it are user supplied and unbounded — datacenter + /// names and the type names of custom policies — so enforcing a limit here keeps a connection from ever + /// being broken by what is only a diagnostic aid. /// /// /// 32 KiB rather than the protocol's own 65535 byte ceiling for this prefix: real world reports are /// expected to be well under a couple kilobytes, so this leaves ample headroom while still being far - /// short of the point where the value would stop protecting anything. + /// short of the point where the value would stop protecting anything. It is also the limit the other + /// ScyllaDB drivers apply. + /// + /// + public const int MaxDriverConfigLength = 32 * 1024; + + /// + /// Upper bound on the number of policies visited while walking a load balancing or retry policy chain. + /// + /// The built-in chains are three policies deep at most, so reaching this bound means a malformed chain + /// rather than a legitimately deep one. Only the driver's own wrapper policies are followed and none of + /// them can be built cyclically, so this is insurance against a future chainable policy rather than a + /// reachable case today — and it is the one failure mode could not + /// contain, since it would hang the cluster initialization path rather than throw. /// /// - internal const int MaxDriverConfigLength = 32 * 1024; + private const int MaxPolicyChainLength = 16; private static readonly Logger Logger = new Logger(typeof(DriverConfigReporter)); + private readonly Configuration _configuration; + + internal DriverConfigReporter(Configuration configuration) + { + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + } + public void AddStartupOptions(IDictionary startupOptions) { string report; @@ -84,12 +143,14 @@ public void AddStartupOptions(IDictionary startupOptions) /// /// Builds the JSON configuration report. It is built for every control connection rather than cached, - /// so that it always describes the configuration as it is at that point in time. + /// so that it always describes the configuration as it is at that point in time. That matters for the + /// datacenter a infers, which is unknown while the first control + /// connection is being opened and known by the time a later one is. /// /// /// protected virtual so tests can override it (via InternalsVisibleTo) to exercise the - /// oversize and exception guards in , which the fixed schema-only - /// report produced here cannot trigger on its own. + /// oversize and exception guards in , which the real report cannot + /// trigger on its own. /// protected virtual string BuildReport() { @@ -99,10 +160,680 @@ protected virtual string BuildReport() } /// - /// Extension point for subclasses to add further configuration groups to the report. Empty for now. + /// Populates the configuration groups onto the report root from , its + /// policies and its default execution profile. + /// + private void PopulateConfig(JObject report) + { + // The default execution profile is what applies to a request that names no profile, so it, rather + // than Policies/QueryOptions on their own, is the effective configuration this report describes. + var requestOptions = _configuration.DefaultRequestOptions; + + report["connection"] = Connection(requestOptions); + report["control-plane"] = ControlPlane(); + report["query"] = Query(requestOptions); + } + + private JObject Connection(IRequestOptions requestOptions) + { + var socketOptions = _configuration.SocketOptions; + var connection = new JObject(); + + // The group is required but its timeout is not, and Timeout.Infinite means there is no bound, so that + // case leaves the group empty. Nothing else needs handling: a connect timeout is either positive or + // Timeout.Infinite, a cluster being unable to configure one that would fail every connection attempt + // (see SocketOptions.SetConnectTimeoutMillis). + var connect = new JObject(); + if (socketOptions.ConnectTimeoutMillis > 0) + { + connect["timeout-ms"] = socketOptions.ConnectTimeoutMillis; + } + + connection["connect"] = connect; + + // The profile's read timeout rather than SocketOptions.ReadTimeoutMillis: an execution profile can + // override it, and this reports what actually applies by default. Optional group and positive-only, + // and a non-positive read timeout disables read timeouts, so omit rather than report a rejected + // number. + if (requestOptions.ReadTimeoutMillis > 0) + { + connection["read"] = new JObject { ["timeout-ms"] = requestOptions.ReadTimeoutMillis }; + } + + // "write" is omitted: there is no configurable write timeout. TcpSocket does assign the connect + // timeout to the socket's SendTimeout, but .NET only honours that for synchronous sends while the + // driver writes asynchronously, so reporting it would claim a bound that is not in force — and it + // would report the connect timeout under a key the application never set. + // + // "heartbeat" is omitted too: it is a reserved-empty placeholder in v1, so the heartbeat interval + // (PoolingOptions.GetHeartBeatInterval) has no home in this schema version. + // + // "node-preference" is for drivers that let an application set a preferred datacenter or rack on the + // session or cluster itself, independently of the load balancing policy. This driver has no such + // setting — the preference exists only inside DCAwareRoundRobinPolicy — so the group is omitted here + // and reported under query.load-balancing, where the schema puts a preference that comes from the + // policy. + + // Resolved once and shared by the two groups that need it: PoolingOptions is null until a protocol + // version is negotiated, which happens after this report is built, and GetOrCreatePoolingOptions + // builds a fresh instance of the defaults on every call rather than storing one. ScyllaDB always + // negotiates a version that uses those defaults, and a configured value takes precedence regardless. + var pooling = _configuration.GetOrCreatePoolingOptions(ProtocolVersion.MaxSupported); + + connection["requests"] = Requests(pooling); + connection["pool"] = Pool(pooling); + connection["socket"] = Socket(); + connection["reconnection"] = new JObject { ["policy"] = ReconnectionPolicy() }; + + // Optional group, absent when TLS is off; there is no longer a boolean saying so. + var tls = Tls(); + if (tls != null) + { + connection["tls"] = tls; + } + + return connection; + } + + private JObject Requests(PoolingOptions pooling) + { + // What the schema asks for is how many requests one connection may have in flight, which is whichever + // of two limits binds first: the pool's configured admission threshold, above which + // HostConnectionPool rejects a borrow with a BusyPoolException, and the size of the connection's + // stream identifier pool, beyond which further requests wait for an identifier instead of travelling. + // Reporting the configured value alone would overstate the ceiling whenever it is set above the + // stream-id pool. + // + // The stream-id pool depends on the negotiated protocol version, which is not available here, so the + // highest supported version stands in — the same assumption the pooling defaults above already make, + // and the one ScyllaDB always negotiates. Forcing an older protocol would make the real pool 128 and + // this an overstatement again, which is the residual inaccuracy of not threading the negotiated + // version into the report. + // + // Required and positive, and SetMaxRequestsPerConnection does not validate its argument, so a + // non-positive value survives the Math.Min and is reported as-is. + var maxRequests = Math.Min( + pooling.GetMaxRequestsPerConnection(), + Connections.Connection.GetMaxConcurrentRequests(ProtocolVersion.MaxSupported)); + if (maxRequests < 1) + { + DriverConfigReporter.WarnUnrepresentable( + "connection.requests.in-flight.max", maxRequests, "the schema requires a positive maximum"); + } + + var inFlight = new JObject { ["max"] = maxRequests }; + + // The requests a client stopped waiting for, whose stream identifiers cannot be reused: the driver + // counts timed-out operations per connection and HostConnectionPool.CheckHealth closes and replaces + // a connection once it reaches this threshold. Required and non-negative, and + // SetDefunctReadTimeoutThreshold does not validate its argument; a threshold of 0 or below both mean + // the connection goes on the first timed-out operation, so clamping is exact rather than invented. + var orphaned = new JObject + { + ["max"] = Math.Max(0, _configuration.SocketOptions.DefunctReadTimeoutThreshold) + }; + + return new JObject { ["in-flight"] = inFlight, ["orphaned"] = orphaned }; + } + + private static JObject Pool(PoolingOptions pooling) + { + // Reports configuration intent. At runtime the shard-aware port must also be advertised by the + // server and be reachable, otherwise the driver falls back to the regular port transparently. + return new JObject + { + ["shard-aware"] = new JObject { ["enabled"] = !pooling.GetDisableShardAwareness() } + }; + } + + private JObject Socket() + { + var options = _configuration.SocketOptions; + var socket = new JObject(); + + // TcpNoDelay and KeepAlive always have a value, both defaulting to on, and TcpSocket applies both to + // every socket it opens, so these report the effective state. + socket["tcp-no-delay"] = options.TcpNoDelay ?? true; + socket["keep-alive"] = options.KeepAlive ?? true; + + // Always the platform default, which is off, because the driver never sets SO_REUSEADDR on a socket. + // + // Deliberately not derived from SocketOptions.ReuseAddress: that option never meant SO_REUSEADDR. It + // used to be handed to Socket.Disconnect(reuseSocket) — whether the socket itself may be reused for + // another connection, an unrelated thing — and has been read by nothing at all since that code was + // replaced. Reporting it here would tell an operator that SO_REUSEADDR is set on the client sockets + // when it never is, so the constant is the only truthful answer to a flag the schema requires. + socket["reuse-address"] = false; + + // The three groups below are optional, so a value the schema cannot express is omitted rather than + // emitted: a negative SO_LINGER disables lingering close (0 is still reported, the schema admits a + // non-negative interval) and a non-positive buffer size leaves the platform default in place. + if (options.SoLinger.HasValue && options.SoLinger.Value >= 0) + { + socket["linger"] = new JObject { ["interval-s"] = options.SoLinger.Value }; + } + + if (options.ReceiveBufferSize.HasValue && options.ReceiveBufferSize.Value > 0) + { + socket["receive-buffer"] = new JObject { ["size-bytes"] = options.ReceiveBufferSize.Value }; + } + + if (options.SendBufferSize.HasValue && options.SendBufferSize.Value > 0) + { + socket["send-buffer"] = new JObject { ["size-bytes"] = options.SendBufferSize.Value }; + } + + return socket; + } + + private JObject Tls() + { + var sslOptions = _configuration.ProtocolOptions.SslOptions; + if (sslOptions == null) + { + return null; + } + + // The group's presence is what says TLS is enabled, so it stays even when nothing inside it is known: + // the schema reads an absent hostname-verification as exactly that, rather than as unverified. + // + // False is never reported, because no configuration makes disabled verification knowable. The driver + // installs a callback that rejects a name mismatch, and .NET's own validation does the same when the + // callback is null; an application supplied one is opaque, and one that ignores the mismatch cannot be + // told apart from one that enforces it. That is why this differs from socket.reuse-address, where a + // constant false is right precisely because the driver provably never sets the option. + var tls = new JObject(); + if (sslOptions.VerifiesHostName.HasValue) + { + tls["hostname-verification"] = sslOptions.VerifiesHostName.Value; + } + + return tls; + } + + private JObject ControlPlane() + { + var timeout = new JObject(); + + // Internal/system queries run over the control connection are bounded by the metadata abort timeout. + // Optional and positive-only, so a non-positive value, which disables the bound, is omitted; the + // enclosing "timeout" object is required, so it stays even when empty. + var metadataAbortTimeout = _configuration.SocketOptions.MetadataAbortTimeout; + if (metadataAbortTimeout > 0) + { + timeout["client-side-ms"] = metadataAbortTimeout; + } + + // Required and non-negative, and 0 is meaningful (do not wait for agreement). Builder rejects a + // non-positive wait but ProtocolOptions.SetMaxSchemaAgreementWaitSeconds does not, and a negative + // wait behaves exactly like 0, so clamping is exact rather than invented and keeps the required + // field in range. + var schemaAgreementMs = Math.Max(0L, _configuration.ProtocolOptions.MaxSchemaAgreementWaitSeconds * 1000L); + + // There is no client-configurable server-side ("USING TIMEOUT") timeout, so server-side-ms is omitted. + return new JObject + { + ["queries"] = new JObject + { + ["system"] = new JObject { ["timeout"] = timeout } + }, + ["schema"] = new JObject + { + ["agreement"] = new JObject { ["timeout-ms"] = schemaAgreementMs } + } + }; + } + + private JObject Query(IRequestOptions requestOptions) + { + // The load balancing policy chain feeds both the policy and the node preference, so it is walked + // once here and handed to both. + var lbChain = DriverConfigReporter.PolicyChain(requestOptions.LoadBalancingPolicy); + + var loadBalancing = new JObject { ["policy"] = DriverConfigReporter.LoadBalancingPolicy(lbChain) }; + + // node-preference is optional: omitted when the policy chain carries no datacenter notion. It is a + // sibling of the policy rather than part of it, so it is still reported for a policy the driver + // describes as custom. + var nodePreference = DriverConfigReporter.NodeLocationPreference(lbChain); + if (nodePreference != null) + { + loadBalancing["node-preference"] = nodePreference; + } + + var query = new JObject + { + ["defaults"] = QueryDefaults(requestOptions), + + // "backoff" is omitted throughout: no built-in retry policy inserts a delay between attempts. + ["retry"] = new JObject { ["policy"] = DriverConfigReporter.RetryPolicy(requestOptions) }, + ["load-balancing"] = loadBalancing + }; + + // speculative-execution is optional: omitted when there is no speculative execution. + var speculativeExecution = DriverConfigReporter.SpeculativeExecutionPolicy(requestOptions); + if (speculativeExecution != null) + { + query["speculative-execution"] = new JObject { ["policy"] = speculativeExecution }; + } + + return query; + } + + private JObject QueryDefaults(IRequestOptions requestOptions) + { + var queryDefaults = new JObject(); + + // QueryOptions spells "do not page" as int.MaxValue, which QueryProtocolOptions turns into -1 and then + // leaves the page-size flag unset, so no limit ever reaches the server. The schema's page group is + // absent exactly when paging is not limited, so that case omits it rather than reporting a bound of + // two billion rows that nothing enforces. The lower guard is defensive: QueryOptions rejects a + // non-positive page size, and the schema requires a positive one. + if (requestOptions.PageSize > 0 && requestOptions.PageSize != int.MaxValue) + { + queryDefaults["page"] = new JObject { ["size"] = requestOptions.PageSize }; + } + + // Required. A serial level is a configuration the driver supports — RequestHandler routes a request + // whose effective consistency is serial as an LWT — and the schema's enum lists both of them, so + // there is nothing to reconcile here. + queryDefaults["consistency"] = DriverConfigReporter.ConsistencyName(requestOptions.ConsistencyLevel); + + // Unlike the schema's optional serial-consistency, the driver always has a value for it; it is only + // reported when it really is one of the two serial levels the schema lists. + if (requestOptions.SerialConsistencyLevel.IsSerialConsistencyLevel()) + { + queryDefaults["serial-consistency"] = DriverConfigReporter.ConsistencyName(requestOptions.SerialConsistencyLevel); + } + + queryDefaults["idempotence"] = requestOptions.DefaultIdempotence; + + // Reported only for the driver's own generators, which always return a real timestamp, so the driver is + // certain to assign one client-side. An ITimestampGenerator may return long.MinValue to hand + // assignment back to the coordinator — QueryProtocolOptions then sends no timestamp — and it may + // decide that per request, so for an application supplied generator neither answer is true. The schema + // reads the key's absence as exactly that unknown, so it is omitted rather than denied. The sibling + // java drivers test for their built-in ServerSideTimestampGenerator instead; this driver has no such + // class, so its own generators are what can be recognized. + // + // False is never reported, because no configuration makes server-side assignment knowable: there is no + // server-side generator to recognize, and the one case that would be certain — a protocol older than + // v3, where QueryProtocolOptions never consults the generator at all — depends on the negotiated + // version, which is not available here and which ScyllaDB never negotiates. + if (requestOptions.TimestampGenerator is AtomicMonotonicTimestampGenerator) + { + queryDefaults["client-timestamps"] = true; + } + + // The overall client-side bound on a request, as opposed to connection.read.timeout-ms, which bounds + // how long a single host has to answer. Timeout.Infinite means there is no bound, and both the group + // and its key are optional, so that case drops the group entirely rather than reporting an empty one. + // Every other value reaching here is a positive number of milliseconds, since a cluster cannot be + // configured with anything else (see Builder.ValidateQueryAbortTimeout), so nothing needs coercing. + if (requestOptions.QueryAbortTimeout != Timeout.Infinite) + { + queryDefaults["request"] = new JObject { ["timeout-ms"] = requestOptions.QueryAbortTimeout }; + } + + return queryDefaults; + } + + private JObject ReconnectionPolicy() + { + var policy = _configuration.Policies.ReconnectionPolicy; + + if (policy is ExponentialReconnectionPolicy exponential) + { + // The constructor enforces the schema's base-ms <= max-ms invariant, which JSON Schema cannot + // express, so both values are always in range. The built-in policies never give up, so + // max-attempts is omitted. + return new JObject + { + ["type"] = "exponential", + ["base-ms"] = exponential.BaseDelayMs, + ["max-ms"] = exponential.MaxDelayMs + }; + } + + if (policy is ConstantReconnectionPolicy constant) + { + return new JObject { ["type"] = "constant", ["delay-ms"] = constant.ConstantDelayMs }; + } + + // FixedReconnectionPolicy takes one delay per attempt and repeats the last one forever, which none + // of the schema's built-in shapes describes, so it falls through to "custom" like a user policy. + return DriverConfigReporter.CustomPolicy(policy); + } + + private static JObject RetryPolicy(IRequestOptions requestOptions) + { + var chain = DriverConfigReporter.RetryPolicyChain(requestOptions.RetryPolicy); + + // The policy that decides the retries is what the schema describes, so a decorator that passes the + // decision through is looked through to whatever it wraps; one that overrides it is not, and leaves + // the chain reported as custom. "max-retries" is omitted throughout: the built-in policies have fixed, + // non-configurable rules rather than a retry limit, which is what the schema means by an absent one. + foreach (var policy in chain) + { + if (policy is DefaultRetryPolicy) + { + return new JObject { ["type"] = "standard-error-aware" }; + } + + // Deprecated, but an application can still configure it and the report describes what is + // configured rather than what is recommended. +#pragma warning disable 618 + if (policy is DowngradingConsistencyRetryPolicy) +#pragma warning restore 618 + { + return new JObject { ["type"] = "downgrading-consistency" }; + } + + if (policy is FallthroughRetryPolicy) + { + return new JObject { ["type"] = "fallthrough" }; + } + } + + // Nothing in the chain is a built-in, so it is named after the outermost policy the application + // actually configured. WrappedExtendedRetryPolicy is skipped: the driver puts it around every policy + // that only implements IRetryPolicy, and reporting that name would describe the driver's plumbing + // rather than the application's choice. + foreach (var policy in chain) + { + if (!(policy is RetryPolicyExtensions.WrappedExtendedRetryPolicy)) + { + return DriverConfigReporter.CustomPolicy(policy); + } + } + + return DriverConfigReporter.CustomPolicy(chain[0]); + } + + /// + /// Returns the retry policy chain, outermost policy first, by looking through the decorators that pass + /// the retry decision through unchanged. There is no interface shared by them, hence the type tests. + /// + /// Only , which logs the child's decision and returns it, and the driver's + /// own qualify. + /// deliberately does not: it rethrows non-idempotent write + /// timeouts and request errors instead of asking its child, so the chain stops there and the group is + /// reported as custom. Naming the child's type would promise the child's retry rules while two of the four + /// decision points never reach it. + /// /// - protected virtual void PopulateConfig(JObject report) + private static IList RetryPolicyChain(IRetryPolicy policy) { + var chain = new List(); + var current = policy; + while (current != null && chain.Count < DriverConfigReporter.MaxPolicyChainLength) + { + chain.Add(current); + + if (current is RetryPolicyExtensions.WrappedExtendedRetryPolicy wrapped) + { + current = wrapped.Policy; + } + else if (current is LoggingRetryPolicy logging) + { + current = logging.ChildPolicy; + } + else + { + current = null; + } + } + + if (current != null) + { + DriverConfigReporter.Logger.Warning( + "Stopped walking the retry policy chain after {0} policies, only those are reported.", + DriverConfigReporter.MaxPolicyChainLength); + } + + return chain; + } + + private static JObject SpeculativeExecutionPolicy(IRequestOptions requestOptions) + { + var policy = requestOptions.SpeculativeExecutionPolicy; + + if (policy is NoSpeculativeExecutionPolicy) + { + return null; + } + + if (policy is ConstantSpeculativeExecutionPolicy constant) + { + // The policy's constructor validates both values as strictly positive, so they always satisfy + // the schema. + return new JObject + { + ["type"] = "constant", + ["max-executions"] = constant.MaxSpeculativeExecutions, + ["delay-ms"] = constant.Delay + }; + } + + return DriverConfigReporter.CustomPolicy(policy); + } + + /// + /// The load balancing policy group. The schema describes exactly one built-in shape, the token-aware + /// policy with its normalized capability flags; every other chain is reported as custom, named after the + /// outermost policy the application configured. + /// + /// The flags describe the behaviour of the whole chain, so they can only be filled in when every policy + /// in it is one the driver knows. A chain that reaches an application supplied policy is reported as + /// custom even when a driver policy wraps it: the flags would otherwise assert something about a policy + /// whose query plans this code cannot see. + /// + /// + private static JObject LoadBalancingPolicy(IList chain) + { + var tokenAware = false; + var allRecognized = true; + DCAwareRoundRobinPolicy dcAware = null; + + foreach (var policy in chain) + { + if (policy is TokenAwarePolicy) + { + tokenAware = true; + } + else if (policy is DCAwareRoundRobinPolicy dcAwarePolicy) + { + dcAware = dcAwarePolicy; + } + else if (policy is RoundRobinPolicy || policy is DefaultLoadBalancingPolicy) + { + // Known, and contributing no flag of its own: round robin has no node preference, and + // DefaultLoadBalancingPolicy only delegates. + } + // RetryLoadBalancingPolicy is deliberately absent from that list, so its presence forces the + // custom branch below. It is not a transparent delegator: its query plan re-enumerates the + // child's plan in an unbounded loop, sleeping the enumerating thread between passes when no + // ReconnectionEvent handler cancels it. Flags derived from the policies underneath would describe + // ordinary token-aware routing and say nothing about that, which is worse than saying the driver + // does not recognize the chain. + else + { + allRecognized = false; + } + } + + if (!tokenAware || !allRecognized) + { + // A chain without token awareness has no built-in shape to be reported under, even when every + // policy in it is one of the driver's own, and a chain reaching an application supplied policy + // cannot have its flags derived at all. Either way the datacenter preference is still reported, + // since node-preference is a sibling of the policy rather than part of it. + return DriverConfigReporter.CustomPolicy(chain[0]); + } + + // Whether a request may go to a node outside the preference reported under node-preference. A + // datacenter-aware policy keeps a configurable number of hosts per remote datacenter as failover, so + // for it the answer is whether that number is positive. Round robin reports false, and not because + // it never leaves the local datacenter — it treats every host as local, so in a multi-datacenter + // cluster a query can certainly land on a remote one. It reports false because it declares no + // preference for a request to fall outside of: no node-preference is reported for such a chain, which + // is what this flag is defined against. Cross-driver decision, deliberately not "true". +#pragma warning disable 618 + var fallbackToNonPreferred = dcAware != null && dcAware.UsedHostsPerRemoteDc > 0; +#pragma warning restore 618 + + return new JObject + { + ["type"] = "token-aware", + + // TokenAwarePolicy starts the local replicas of a query plan at a pseudo-random index, so it + // randomizes selection across query plans rather than rotating it deterministically. Not + // configurable, hence a constant here. + ["load-distribution"] = "shuffle", + ["fallback-to-non-preferred-nodes"] = fallbackToNonPreferred + + // "adaptive-ordering" is omitted: the driver does not reorder candidates on runtime signals. + }; + } + + private static JObject NodeLocationPreference(IEnumerable chain) + { + DCAwareRoundRobinPolicy dcAware = null; + foreach (var policy in chain) + { + if (policy is DCAwareRoundRobinPolicy dcAwarePolicy) + { + dcAware = dcAwarePolicy; + } + } + + if (dcAware == null) + { + return null; + } + + // An explicitly configured datacenter is reported as such; otherwise the policy infers it from the + // node the control connection uses, which is not known while the first report is being built and is + // by the time a later one is. An empty name is treated as absent: the schema requires a non-empty + // string, and the policy would reject such a datacenter when it initializes anyway. + var localDc = dcAware.LocalDc; + if (string.IsNullOrEmpty(localDc)) + { + return new JObject { ["type"] = "dc-auto" }; + } + + // The driver has no rack-aware policy, so "rack"/"rack-auto" are never reported. + return new JObject + { + ["type"] = dcAware.LocalDcIsExplicit ? "dc" : "dc-auto", + ["local-dc"] = localDc + }; + } + + /// + /// Returns the load balancing policy chain, outermost policy first. Both the + /// load-balancing-policy and the node-location-preference groups are derived from it, + /// since the policy an application configures is normally a wrapper around the one that decides the + /// datacenter. + /// + private static IList PolicyChain(ILoadBalancingPolicy policy) + { + var chain = new List(); + var current = policy; + while (current != null && chain.Count < DriverConfigReporter.MaxPolicyChainLength) + { + chain.Add(current); + current = DriverConfigReporter.ChildPolicy(current); + } + + if (current != null) + { + DriverConfigReporter.Logger.Warning( + "Stopped walking the load balancing policy chain after {0} policies, only those are reported.", + DriverConfigReporter.MaxPolicyChainLength); + } + + return chain; + } + + /// + /// The policy delegates to, or null when it is not one of the driver's + /// wrapper policies. There is no interface shared by the wrappers, hence the type tests. + /// + private static ILoadBalancingPolicy ChildPolicy(ILoadBalancingPolicy policy) + { + if (policy is DefaultLoadBalancingPolicy defaultPolicy) + { + return defaultPolicy.ChildPolicy; + } + + if (policy is TokenAwarePolicy tokenAware) + { + return tokenAware.ChildPolicy; + } + + if (policy is RetryLoadBalancingPolicy retry) + { + return retry.LoadBalancingPolicy; + } + + return null; + } + + /// + /// Logs that a configured value has no representation in the schema and is therefore reported as it + /// stands, so that the report failing validation on that one field is discoverable from the driver's log + /// rather than only from this type's documentation. See the type documentation for why such a value is + /// neither coerced into range nor allowed to suppress the whole report. + /// + private static void WarnUnrepresentable(string key, object value, string constraint) + { + DriverConfigReporter.Logger.Warning( + "The driver configuration report describes {0} as {1}, which the report schema cannot express " + + "({2}). It is reported as configured, so the report describes this cluster accurately but does " + + "not validate against the schema on that field.", key, value, constraint); + } + + private static JObject CustomPolicy(object policy) + { + return new JObject { ["type"] = "custom", ["name"] = policy.GetType().Name }; + } + + /// + /// The schema's name for a consistency level. Spelled out rather than derived from the enum, whose + /// members are pascal-cased and one of which () has a name the schema + /// would not accept as-is. + /// + private static string ConsistencyName(ConsistencyLevel consistency) + { + switch (consistency) + { + case ConsistencyLevel.Any: + return "ANY"; + case ConsistencyLevel.One: + return "ONE"; + case ConsistencyLevel.Two: + return "TWO"; + case ConsistencyLevel.Three: + return "THREE"; + case ConsistencyLevel.Quorum: + return "QUORUM"; + case ConsistencyLevel.All: + return "ALL"; + case ConsistencyLevel.LocalQuorum: + return "LOCAL_QUORUM"; + case ConsistencyLevel.EachQuorum: + return "EACH_QUORUM"; + case ConsistencyLevel.Serial: + return "SERIAL"; + case ConsistencyLevel.LocalSerial: + return "LOCAL_SERIAL"; + case ConsistencyLevel.LocalOne: + return "LOCAL_ONE"; + default: + // Not a level the driver defines; report the number so the report stays truthful. + return ((int)consistency).ToString(); + } } } } From fc0042d97819334b4210085ed15bba24a61fb8b2 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Thu, 6 Aug 2026 19:15:53 +0200 Subject: [PATCH 7/7] Add integration tests for the full driver config report Verify over an actual connection to Simulacron that what the builder is given is what the server is told: the configured timeouts, the request capacity, each policy in its discriminated shape, the explicit datacenter preference and the query defaults, plus the absence of the tls group when TLS is off. A second test covers the default policy chain, which infers the datacenter from the node the control connection uses and so reports the preference as dc-auto with no name yet, since the report is built before that node is known. Every claim these tests make about DRIVER_CONFIG is a claim about which connections carry it, so the startup options are kept paired with the connection that sent them and the number of distinct connections is what gets checked. Counting startup messages instead, as an earlier draft did, would also be satisfied by a run where only the control connection had opened, or where one connection sent two of them. The SESSION_ID test that asserts every connection agrees is routed through the same helper, for the same reason. The shape of the whole document and its conformance to the schema are covered by the unit tests; these only pin that the report survives the wire intact. --- .../Core/StartupOptionsTests.cs | 140 ++++++++++++++++-- 1 file changed, 125 insertions(+), 15 deletions(-) diff --git a/src/Cassandra.IntegrationTests/Core/StartupOptionsTests.cs b/src/Cassandra.IntegrationTests/Core/StartupOptionsTests.cs index b21962071..29457fc23 100644 --- a/src/Cassandra.IntegrationTests/Core/StartupOptionsTests.cs +++ b/src/Cassandra.IntegrationTests/Core/StartupOptionsTests.cs @@ -15,6 +15,7 @@ // using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -68,16 +69,126 @@ public async Task Should_ReportDriverConfig_OnlyOnTheControlConnection() _cluster.Connect(); + // That exactly one connection reports the option, and that a pool connection was there not to, is + // asserted by the helper. + var report = await GetDriverConfigReportAsync().ConfigureAwait(false); + + Assert.AreEqual(DriverConfigReporter.SchemaVersion, report["version"].Value()); + } + + [Test] + public async Task Should_ReportTheEffectiveConfiguration_On_TheControlConnection() + { + _simulacronCluster = await SimulacronCluster.CreateNewAsync(1).ConfigureAwait(false); + _cluster = BuildClusterBuilder() + .WithLoadBalancingPolicy(new TokenAwarePolicy(new DCAwareRoundRobinPolicy("dc1"))) + .WithReconnectionPolicy(new ConstantReconnectionPolicy(500)) + .WithRetryPolicy(FallthroughRetryPolicy.Instance) + .WithSpeculativeExecutionPolicy(new ConstantSpeculativeExecutionPolicy(100, 2)) + .WithQueryOptions(new QueryOptions().SetConsistencyLevel(ConsistencyLevel.LocalQuorum).SetPageSize(1234)) + .WithSocketOptions(new SocketOptions().SetConnectTimeoutMillis(3000).SetReadTimeoutMillis(7000)) + .Build(); + + _cluster.Connect(); + + var report = await GetDriverConfigReportAsync().ConfigureAwait(false); + + // Every configured setting arrives over a real connection, in the shape the schema prescribes. The + // full set of groups and the conformance of the document itself are covered by the unit tests; this + // asserts that what the builder was given is what the server is told. + var connection = report["connection"]; + Assert.AreEqual(3000, connection["connect"]["timeout-ms"].Value()); + Assert.AreEqual(7000, connection["read"]["timeout-ms"].Value()); + Assert.AreEqual(PoolingOptions.DefaultMaxRequestsPerConnection, connection["requests"]["in-flight"]["max"].Value()); + Assert.AreEqual("constant", connection["reconnection"]["policy"]["type"].Value()); + Assert.AreEqual(500, connection["reconnection"]["policy"]["delay-ms"].Value()); + // The group is absent rather than carrying a flag when TLS is off. + Assert.IsNull(connection["tls"]); + + var query = report["query"]; + Assert.AreEqual("fallthrough", query["retry"]["policy"]["type"].Value()); + Assert.AreEqual("constant", query["speculative-execution"]["policy"]["type"].Value()); + Assert.AreEqual(2, query["speculative-execution"]["policy"]["max-executions"].Value()); + Assert.AreEqual("token-aware", query["load-balancing"]["policy"]["type"].Value()); + Assert.AreEqual("shuffle", query["load-balancing"]["policy"]["load-distribution"].Value()); + Assert.AreEqual("dc", query["load-balancing"]["node-preference"]["type"].Value()); + Assert.AreEqual("dc1", query["load-balancing"]["node-preference"]["local-dc"].Value()); + Assert.AreEqual("LOCAL_QUORUM", query["defaults"]["consistency"].Value()); + Assert.AreEqual(1234, query["defaults"]["page"]["size"].Value()); + } + + [Test] + public async Task Should_ReportAnInferredDatacenter_When_NoneIsConfigured() + { + _simulacronCluster = await SimulacronCluster.CreateNewAsync(1).ConfigureAwait(false); + _cluster = BuildClusterBuilder().Build(); + + _cluster.Connect(); + + var report = await GetDriverConfigReportAsync().ConfigureAwait(false); + + // The default policy chain infers the datacenter from the node the control connection uses, which is + // not known while the report is being built, so only the preference itself is reported. + var preference = report["query"]["load-balancing"]["node-preference"]; + Assert.AreEqual("dc-auto", preference["type"].Value()); + Assert.IsNull(preference["local-dc"]); + } + + /// + /// The startup options every connection sent, each paired with the connection that sent it, having checked + /// that a pool connection opened alongside the control connection. + /// + /// + /// Kept paired rather than flattened to a list of options because every claim these tests make about + /// DRIVER_CONFIG is a claim about which connections carry it, so the other connections have + /// to be visible to be asserted about. Counting startup messages instead would also be satisfied by a run + /// where only the control connection had opened, or where one connection sent two of them, which is why the + /// number of distinct connections is what gets checked. + /// + private async Task> GetStartupsByConnectionAsync() + { var startupLogs = await _simulacronCluster.GetQueriesAsync(null, QueryType.Startup).ConfigureAwait(false); - var startupMessages = startupLogs.Select(log => log.Frame.GetStartupMessage()).ToList(); + var startups = startupLogs + .Select(log => new StartupOnConnection(log.Connection, log.Frame.GetStartupMessage())) + .ToList(); - Assert.GreaterOrEqual(startupMessages.Count, 2, "Expected at least the control connection and one pool connection"); + Assert.GreaterOrEqual( + startups.Select(startup => startup.Connection).Distinct().Count(), + 2, + "Expected at least the control connection and one pool connection"); - var driverConfigMessages = startupMessages.Where(m => m.ContainsKey(DriverConfigReporter.DriverConfigOption)).ToList(); - Assert.AreEqual(1, driverConfigMessages.Count, "Only the control connection should report the DRIVER_CONFIG option"); + return startups; + } - var report = JObject.Parse(driverConfigMessages.Single()[DriverConfigReporter.DriverConfigOption]); - Assert.AreEqual(DriverConfigReporter.SchemaVersion, report["version"].Value()); + /// + /// The single DRIVER_CONFIG report the control connection sent, having checked that no other + /// connection sent one. Which connection is the control connection is known by elimination, since + /// Simulacron identifies a connection only by its client socket. + /// + private async Task GetDriverConfigReportAsync() + { + var startups = await GetStartupsByConnectionAsync().ConfigureAwait(false); + var reporting = startups + .Where(startup => startup.Options.ContainsKey(DriverConfigReporter.DriverConfigOption)) + .ToList(); + + Assert.AreEqual( + 1, reporting.Count, "Exactly one connection, the control connection, should report the DRIVER_CONFIG option"); + + return JObject.Parse(reporting.Single().Options[DriverConfigReporter.DriverConfigOption]); + } + + private class StartupOnConnection + { + public StartupOnConnection(string connection, IDictionary options) + { + Connection = connection; + Options = options; + } + + public string Connection { get; } + + public IDictionary Options { get; } } [Test] @@ -88,12 +199,10 @@ public async Task Should_NotReportDriverConfig_When_ReportingIsDisabled() _cluster.Connect(); - var startupLogs = await _simulacronCluster.GetQueriesAsync(null, QueryType.Startup).ConfigureAwait(false); - var startupMessages = startupLogs.Select(log => log.Frame.GetStartupMessage()).ToList(); + var startups = await GetStartupsByConnectionAsync().ConfigureAwait(false); - Assert.GreaterOrEqual(startupMessages.Count, 2, "Expected at least the control connection and one pool connection"); Assert.IsTrue( - startupMessages.All(m => !m.ContainsKey(DriverConfigReporter.DriverConfigOption)), + startups.All(startup => !startup.Options.ContainsKey(DriverConfigReporter.DriverConfigOption)), "No connection should report the DRIVER_CONFIG option when reporting is disabled"); } @@ -105,11 +214,12 @@ public async Task Should_ReportTheSameSessionId_For_EveryConnectionOfTheSameClus _cluster.Connect(); - var startupLogs = await _simulacronCluster.GetQueriesAsync(null, QueryType.Startup).ConfigureAwait(false); - var sessionIds = startupLogs - .Select(log => log.Frame.GetStartupMessage()[StartupOptionsFactory.SessionIdOption]) - .Distinct() - .ToList(); + // Through the helper, so that "every connection" is checked against connections that actually opened: + // a run with only the control connection would satisfy this on its own. + var sessionIds = (await GetStartupsByConnectionAsync().ConfigureAwait(false)) + .Select(startup => startup.Options[StartupOptionsFactory.SessionIdOption]) + .Distinct() + .ToList(); Assert.AreEqual(1, sessionIds.Count, "Every connection of the same Cluster instance should report the same SESSION_ID"); Assert.IsTrue(Guid.TryParse(sessionIds.Single(), out _), "SESSION_ID should be a valid guid");