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"); 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/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/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.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.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." + } + } + } + } +} 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/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..52a2f6828 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; @@ -334,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/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)); } /// 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. /// 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(); + } } } } 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. /// 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; }