diff --git a/src/Cassandra.IntegrationTests/Core/ConnectionTests.cs b/src/Cassandra.IntegrationTests/Core/ConnectionTests.cs index c50994c69..3eaf05484 100644 --- a/src/Cassandra.IntegrationTests/Core/ConnectionTests.cs +++ b/src/Cassandra.IntegrationTests/Core/ConnectionTests.cs @@ -654,7 +654,7 @@ public void Wrong_Ip_Init_Throws_Exception() config.ServerNameResolver, null), config, - new StartupRequestFactory(config.StartupOptionsFactory), + new StartupRequestFactory(config.StartupOptionsFactory, isControlConnection: false), NullConnectionObserver.Instance)) { var ex = Assert.Throws(() => TaskHelper.WaitToComplete(connection.Open())); @@ -668,7 +668,7 @@ public void Wrong_Ip_Init_Throws_Exception() config.ServerNameResolver, null), config, - new StartupRequestFactory(config.StartupOptionsFactory), + new StartupRequestFactory(config.StartupOptionsFactory, isControlConnection: false), NullConnectionObserver.Instance)) { Assert.Throws(() => TaskHelper.WaitToComplete(connection.Open())); @@ -869,7 +869,7 @@ private Connection CreateConnection(ProtocolVersion protocolVersion, Configurati new SerializerManager(protocolVersion).GetCurrentSerializer(), new ConnectionEndPoint(new IPEndPoint(IPAddress.Parse(contactPoint ?? _testCluster.InitialContactPoint), 9042), config.ServerNameResolver, null), config, - new StartupRequestFactory(config.StartupOptionsFactory), + new StartupRequestFactory(config.StartupOptionsFactory, isControlConnection: false), NullConnectionObserver.Instance); } diff --git a/src/Cassandra.IntegrationTests/Core/StartupOptionsTests.cs b/src/Cassandra.IntegrationTests/Core/StartupOptionsTests.cs new file mode 100644 index 000000000..b21962071 --- /dev/null +++ b/src/Cassandra.IntegrationTests/Core/StartupOptionsTests.cs @@ -0,0 +1,144 @@ +// +// 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.Linq; +using System.Threading.Tasks; + +using Cassandra.IntegrationTests.SimulacronAPI.Models.Logs; +using Cassandra.IntegrationTests.TestBase; +using Cassandra.IntegrationTests.TestClusterManagement.Simulacron; +using Cassandra.Requests; +using Cassandra.Tests; + +using Newtonsoft.Json.Linq; + +using NUnit.Framework; +using Assert = NUnit.Framework.Legacy.ClassicAssert; + +namespace Cassandra.IntegrationTests.Core +{ + /// + /// Verifies the SESSION_ID and DRIVER_CONFIG STARTUP options that the driver reports to the cluster + /// (see StartupOptionsFactory and DriverConfigReporter) over an actual connection. + /// + [TestFixture, Category(TestCategory.Short)] + public class StartupOptionsTests : TestGlobals + { + private SimulacronCluster _simulacronCluster; + private Cluster _cluster; + private Cluster _secondCluster; + + [TearDown] + public void TearDown() + { + _cluster?.Dispose(); + _cluster = null; + _secondCluster?.Dispose(); + _secondCluster = null; + _simulacronCluster?.Dispose(); + _simulacronCluster = null; + } + + private Builder BuildClusterBuilder() + { + return ClusterBuilder() + .AddContactPoint(_simulacronCluster.InitialContactPoint) + .WithPoolingOptions(new PoolingOptions().SetCoreConnectionsPerHost(HostDistance.Local, 1)); + } + + [Test] + public async Task Should_ReportDriverConfig_OnlyOnTheControlConnection() + { + _simulacronCluster = await SimulacronCluster.CreateNewAsync(1).ConfigureAwait(false); + _cluster = BuildClusterBuilder().Build(); + + _cluster.Connect(); + + var startupLogs = await _simulacronCluster.GetQueriesAsync(null, QueryType.Startup).ConfigureAwait(false); + var startupMessages = startupLogs.Select(log => log.Frame.GetStartupMessage()).ToList(); + + Assert.GreaterOrEqual(startupMessages.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"); + + var report = JObject.Parse(driverConfigMessages.Single()[DriverConfigReporter.DriverConfigOption]); + Assert.AreEqual(DriverConfigReporter.SchemaVersion, report["version"].Value()); + } + + [Test] + public async Task Should_NotReportDriverConfig_When_ReportingIsDisabled() + { + _simulacronCluster = await SimulacronCluster.CreateNewAsync(1).ConfigureAwait(false); + _cluster = BuildClusterBuilder().WithDriverConfigReporting(false).Build(); + + _cluster.Connect(); + + var startupLogs = await _simulacronCluster.GetQueriesAsync(null, QueryType.Startup).ConfigureAwait(false); + var startupMessages = startupLogs.Select(log => log.Frame.GetStartupMessage()).ToList(); + + Assert.GreaterOrEqual(startupMessages.Count, 2, "Expected at least the control connection and one pool connection"); + Assert.IsTrue( + startupMessages.All(m => !m.ContainsKey(DriverConfigReporter.DriverConfigOption)), + "No connection should report the DRIVER_CONFIG option when reporting is disabled"); + } + + [Test] + public async Task Should_ReportTheSameSessionId_For_EveryConnectionOfTheSameCluster() + { + _simulacronCluster = await SimulacronCluster.CreateNewAsync(1).ConfigureAwait(false); + _cluster = BuildClusterBuilder().Build(); + + _cluster.Connect(); + + var startupLogs = await _simulacronCluster.GetQueriesAsync(null, QueryType.Startup).ConfigureAwait(false); + var sessionIds = startupLogs + .Select(log => log.Frame.GetStartupMessage()[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"); + } + + [Test] + public async Task Should_ReportDistinctSessionIds_For_DifferentClusterInstances() + { + _simulacronCluster = await SimulacronCluster.CreateNewAsync(1).ConfigureAwait(false); + _cluster = BuildClusterBuilder().Build(); + _secondCluster = BuildClusterBuilder().Build(); + + _cluster.Connect(); + _secondCluster.Connect(); + + var startupLogs = await _simulacronCluster.GetQueriesAsync(null, QueryType.Startup).ConfigureAwait(false); + var sessionIdsByClusterId = startupLogs + .Select(log => log.Frame.GetStartupMessage()) + .GroupBy(m => m[StartupOptionsFactory.ClientIdOption], m => m[StartupOptionsFactory.SessionIdOption]) + .ToDictionary(g => g.Key, g => g.Distinct().ToList()); + + Assert.AreEqual(2, sessionIdsByClusterId.Count, "Expected startup options from two distinct Cluster instances"); + foreach (var sessionIds in sessionIdsByClusterId.Values) + { + Assert.AreEqual(1, sessionIds.Count, "Every connection of the same Cluster instance should report the same SESSION_ID"); + } + + var distinctSessionIds = sessionIdsByClusterId.Values.SelectMany(ids => ids).Distinct().ToList(); + Assert.AreEqual(2, distinctSessionIds.Count, "Different Cluster instances should report distinct SESSION_IDs"); + } + } +} diff --git a/src/Cassandra.IntegrationTests/SimulacronAPI/Models/Logs/Frame.cs b/src/Cassandra.IntegrationTests/SimulacronAPI/Models/Logs/Frame.cs index 31aa34225..a0fb40070 100644 --- a/src/Cassandra.IntegrationTests/SimulacronAPI/Models/Logs/Frame.cs +++ b/src/Cassandra.IntegrationTests/SimulacronAPI/Models/Logs/Frame.cs @@ -70,6 +70,11 @@ public BatchMessage GetBatchMessage() return GetTypedMessage(); } + public Dictionary GetStartupMessage() + { + return GetTypedMessage()?.Options; + } + private T GetTypedMessage() { return MessageJson == null ? default : JsonConvert.DeserializeObject(MessageJson); diff --git a/src/Cassandra.IntegrationTests/SimulacronAPI/Models/Logs/StartupMessage.cs b/src/Cassandra.IntegrationTests/SimulacronAPI/Models/Logs/StartupMessage.cs new file mode 100644 index 000000000..61ca87ac1 --- /dev/null +++ b/src/Cassandra.IntegrationTests/SimulacronAPI/Models/Logs/StartupMessage.cs @@ -0,0 +1,27 @@ +// +// 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.Collections.Generic; +using Newtonsoft.Json; + +namespace Cassandra.IntegrationTests.SimulacronAPI.Models.Logs +{ + public class StartupMessage : BaseMessage + { + [JsonProperty("options")] + public Dictionary Options { get; set; } + } +} diff --git a/src/Cassandra.Tests/BuilderTests.cs b/src/Cassandra.Tests/BuilderTests.cs index 4315b6e9f..02f4e05a5 100644 --- a/src/Cassandra.Tests/BuilderTests.cs +++ b/src/Cassandra.Tests/BuilderTests.cs @@ -311,5 +311,24 @@ public void Should_ReturnDefaultInsightsMonitoringEnabled_When_NotProvidedToBuil Assert.AreEqual(expected, cluster.Configuration.MonitorReportingOptions.MonitorReportingEnabled); Assert.AreEqual(MonitorReportingOptions.DefaultStatusEventDelayMilliseconds, cluster.Configuration.MonitorReportingOptions.StatusEventDelayMilliseconds); } + + [Test] + public void Should_ReturnDefaultDriverConfigReportingEnabled_When_NotProvidedToBuilder() + { + var cluster = Cluster.Builder() + .AddContactPoint("192.168.1.10") + .Build(); + Assert.AreEqual(Configuration.DefaultDriverConfigReportingEnabled, cluster.Configuration.DriverConfigReportingEnabled); + } + + [Test] + public void Should_ReturnDriverConfigReportingDisabled_When_ProvidedToBuilder() + { + var cluster = Cluster.Builder() + .AddContactPoint("192.168.1.10") + .WithDriverConfigReporting(false) + .Build(); + Assert.IsFalse(cluster.Configuration.DriverConfigReportingEnabled); + } } } \ No newline at end of file diff --git a/src/Cassandra.Tests/ConnectionTests.cs b/src/Cassandra.Tests/ConnectionTests.cs index 262a4c1f2..612739c5b 100644 --- a/src/Cassandra.Tests/ConnectionTests.cs +++ b/src/Cassandra.Tests/ConnectionTests.cs @@ -47,7 +47,7 @@ private static Mock GetConnectionMock(Configuration config = null, I serializer?.GetCurrentSerializer() ?? new SerializerManager(ProtocolVersion.MaxSupported).GetCurrentSerializer(), new ConnectionEndPoint(ConnectionTests.Address, config.ServerNameResolver, null), config, - new StartupRequestFactory(config.StartupOptionsFactory), + new StartupRequestFactory(config.StartupOptionsFactory, isControlConnection: false), NullConnectionObserver.Instance); } @@ -352,7 +352,7 @@ private static byte[] GetResultBuffer(short streamId, ProtocolVersion version = return new byte[] { //header - header, 0, bytes[0], bytes[1], ResultResponse.OpCode, 0, 0, 0, 4, + header, 0, bytes[0], bytes[1], ResultResponse.OpCode, 0, 0, 0, 4, //body 0, 0, 0, 1 }; @@ -361,7 +361,7 @@ private static byte[] GetResultBuffer(short streamId, ProtocolVersion version = return new byte[] { //header - header, 0, (byte)streamId, ResultResponse.OpCode, 0, 0, 0, 4, + header, 0, (byte)streamId, ResultResponse.OpCode, 0, 0, 0, 4, //body 0, 0, 0, 1 }; diff --git a/src/Cassandra.Tests/HostConnectionPoolTests.cs b/src/Cassandra.Tests/HostConnectionPoolTests.cs index 522842424..171accf29 100644 --- a/src/Cassandra.Tests/HostConnectionPoolTests.cs +++ b/src/Cassandra.Tests/HostConnectionPoolTests.cs @@ -79,7 +79,7 @@ private static IConnection CreateConnection(byte lastIpByte = 1, Configuration c new SerializerManager(ProtocolVersion.MaxSupported).GetCurrentSerializer(), new ConnectionEndPoint(GetIpEndPoint(lastIpByte), config.ServerNameResolver, null), config, - new StartupRequestFactory(config.StartupOptionsFactory), + new StartupRequestFactory(config.StartupOptionsFactory, isControlConnection: false), NullConnectionObserver.Instance); } diff --git a/src/Cassandra.Tests/Requests/DriverConfigReporterTests.cs b/src/Cassandra.Tests/Requests/DriverConfigReporterTests.cs new file mode 100644 index 000000000..730a64a96 --- /dev/null +++ b/src/Cassandra.Tests/Requests/DriverConfigReporterTests.cs @@ -0,0 +1,112 @@ +// +// 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.Collections.Generic; +using System.Text; +using Cassandra.Requests; +using NUnit.Framework; +using Assert = NUnit.Framework.Legacy.ClassicAssert; + +namespace Cassandra.Tests.Requests +{ + [TestFixture] + public class DriverConfigReporterTests + { + [Test] + public void Should_ReportSchemaVersion_When_ReportingIsEnabled() + { + var options = new Dictionary(); + + new DriverConfigReporter().AddStartupOptions(options); + + Assert.AreEqual("{\"version\":" + DriverConfigReporter.SchemaVersion + "}", options[DriverConfigReporter.DriverConfigOption]); + } + + [Test] + public void Should_NotReportAnything_When_ReporterIsNull() + { + var factory = new StartupOptionsFactory(Guid.NewGuid(), null, null, null); + + var options = factory.CreateStartupOptions(new ProtocolOptions(), null, true); + + Assert.IsFalse(options.ContainsKey(DriverConfigReporter.DriverConfigOption)); + } + + [Test] + public void Should_ReportAConfigThatFitsInAFrame() + { + var options = new Dictionary(); + + new DriverConfigReporter().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. + 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. + Assert.LessOrEqual( + Encoding.UTF8.GetByteCount(options[DriverConfigReporter.DriverConfigOption]), + DriverConfigReporter.MaxDriverConfigLength); + } + + [Test] + public void Should_NotReportAnything_When_ReportExceedsTheLengthLimit() + { + var options = new Dictionary(); + var oversizedReport = new string('a', DriverConfigReporter.MaxDriverConfigLength + 1); + + new OversizedDriverConfigReporter(oversizedReport).AddStartupOptions(options); + + Assert.IsFalse(options.ContainsKey(DriverConfigReporter.DriverConfigOption)); + } + + [Test] + public void Should_NotReportAnything_When_BuildingTheReportThrows() + { + var options = new Dictionary(); + + new ThrowingDriverConfigReporter().AddStartupOptions(options); + + Assert.IsFalse(options.ContainsKey(DriverConfigReporter.DriverConfigOption)); + } + + private class OversizedDriverConfigReporter : DriverConfigReporter + { + private readonly string _report; + + public OversizedDriverConfigReporter(string report) + { + _report = report; + } + + protected override string BuildReport() + { + return _report; + } + } + + private class ThrowingDriverConfigReporter : DriverConfigReporter + { + protected override string BuildReport() + { + throw new InvalidOperationException("Simulated failure while building the report."); + } + } + } +} diff --git a/src/Cassandra.Tests/Requests/StartupOptionsFactoryTests.cs b/src/Cassandra.Tests/Requests/StartupOptionsFactoryTests.cs index 5eb7cb65c..80ac9bc5f 100644 --- a/src/Cassandra.Tests/Requests/StartupOptionsFactoryTests.cs +++ b/src/Cassandra.Tests/Requests/StartupOptionsFactoryTests.cs @@ -29,11 +29,13 @@ public class StartupOptionsFactoryTests [Test] public void Should_ReturnCorrectProtocolStartupOptions_When_OptionsAreSet() { - var factory = new StartupOptionsFactory(Guid.NewGuid(), null, null); + var sessionId = Guid.NewGuid(); + var factory = new StartupOptionsFactory(Guid.NewGuid(), sessionId, null, null, new DriverConfigReporter()); var options = factory.CreateStartupOptions(new ProtocolOptions().SetNoCompact(true).SetCompression(CompressionType.Snappy)); - Assert.AreEqual(6, options.Count); + Assert.AreEqual(7, options.Count); + Assert.AreEqual(sessionId.ToString(), options["SESSION_ID"]); Assert.AreEqual("snappy", options["COMPRESSION"]); Assert.AreEqual("true", options["NO_COMPACT"]); var driverName = options["DRIVER_NAME"]; @@ -61,13 +63,58 @@ public void Should_ReturnCorrectProtocolStartupOptions_When_OptionsAreSet() public void Should_NotReturnOptions_When_OptionsAreNull() { var clusterId = Guid.NewGuid(); - var factory = new StartupOptionsFactory(clusterId, null, null); + var factory = new StartupOptionsFactory(clusterId, null, null, new DriverConfigReporter()); var options = factory.CreateStartupOptions(new ProtocolOptions().SetNoCompact(true).SetCompression(CompressionType.Snappy)); - Assert.AreEqual(6, options.Count); + Assert.AreEqual(7, options.Count); Assert.IsFalse(options.ContainsKey("APPLICATION_NAME")); Assert.IsFalse(options.ContainsKey("APPLICATION_VERSION")); } + + [Test] + public void Should_ReportTheSameSessionId_When_OptionsAreBuiltForSeveralConnections() + { + var factory = new StartupOptionsFactory(Guid.NewGuid(), null, null, new DriverConfigReporter()); + + var controlConnectionOptions = factory.CreateStartupOptions(new ProtocolOptions(), null, true); + var poolOptions = factory.CreateStartupOptions(new ProtocolOptions(), null, false); + + Assert.AreEqual(controlConnectionOptions["SESSION_ID"], poolOptions["SESSION_ID"]); + } + + [Test] + 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 firstOptions = firstFactory.CreateStartupOptions(new ProtocolOptions()); + var secondOptions = secondFactory.CreateStartupOptions(new ProtocolOptions()); + + Assert.AreNotEqual(firstOptions["SESSION_ID"], secondOptions["SESSION_ID"]); + } + + [Test] + public void Should_ReportDriverConfig_When_OptionsAreForTheControlConnection() + { + var factory = new StartupOptionsFactory(Guid.NewGuid(), null, null, new DriverConfigReporter()); + + var options = factory.CreateStartupOptions(new ProtocolOptions(), null, true); + + Assert.AreEqual("{\"version\":1}", options["DRIVER_CONFIG"]); + } + + [Test] + public void Should_NotReportDriverConfig_When_OptionsAreNotForTheControlConnection() + { + var factory = new StartupOptionsFactory(Guid.NewGuid(), null, null, new DriverConfigReporter()); + + var options = factory.CreateStartupOptions(new ProtocolOptions(), null, false); + + Assert.IsTrue(options.ContainsKey("SESSION_ID")); + Assert.IsFalse(options.ContainsKey("DRIVER_CONFIG")); + } } } \ No newline at end of file diff --git a/src/Cassandra.Tests/TestConfigurationBuilder.cs b/src/Cassandra.Tests/TestConfigurationBuilder.cs index fb27ae95a..7bcb472b6 100644 --- a/src/Cassandra.Tests/TestConfigurationBuilder.cs +++ b/src/Cassandra.Tests/TestConfigurationBuilder.cs @@ -53,7 +53,7 @@ internal class TestConfigurationBuilder public MetadataSyncOptions MetadataSyncOptions { get; set; } = new MetadataSyncOptions(); - public IStartupOptionsFactory StartupOptionsFactory { get; set; } = new StartupOptionsFactory(Guid.NewGuid(), Configuration.DefaultApplicationVersion, Builder.DefaultApplicationName); + public IStartupOptionsFactory StartupOptionsFactory { get; set; } public IRequestOptionsMapper RequestOptionsMapper { get; set; } = new RequestOptionsMapper(); @@ -91,6 +91,8 @@ internal class TestConfigurationBuilder public bool? AllowBetaProtocolVersions { get; set; } + public bool? DriverConfigReportingEnabled { get; set; } + public IContactPointParser ContactPointParser { get; set; } public IServerNameResolver ServerNameResolver { get; set; } @@ -156,7 +158,8 @@ public Configuration Build() SchemaParserFactory, SupportedOptionsInitializerFactory, ProtocolVersionNegotiator, - ServerEventsSubscriber); + ServerEventsSubscriber, + driverConfigReportingEnabled: DriverConfigReportingEnabled); } } } \ No newline at end of file diff --git a/src/Cassandra/Builder.cs b/src/Cassandra/Builder.cs index 77851eb6e..0b72a7a85 100644 --- a/src/Cassandra/Builder.cs +++ b/src/Cassandra/Builder.cs @@ -75,6 +75,7 @@ public class Builder : IInitializer private string _sessionName; private bool? _keepContactPointsUnresolved; private bool? _allowBetaProtocolVersions; + private bool? _driverConfigReportingEnabled; public Builder() { @@ -186,7 +187,8 @@ public Configuration GetConfiguration() typeSerializerDefinitions, _keepContactPointsUnresolved, _allowBetaProtocolVersions, - requestTracker: _requestTracker); + requestTracker: _requestTracker, + driverConfigReportingEnabled: _driverConfigReportingEnabled); return config; } @@ -1102,6 +1104,21 @@ internal Builder WithMonitorReporting(MonitorReportingOptions options) return this; } + /// + /// Determines whether the driver describes its effective configuration to the cluster while setting up + /// the control connection, so that operators can inspect the settings of a client while investigating + /// an incident. The description is sent as the DRIVER_CONFIG startup option and ScyllaDB exposes + /// it in the client_options column of its clients table. + /// + /// If not set, the configuration is reported. + /// Flag that controls whether the driver configuration is reported. + /// This Builder. + public Builder WithDriverConfigReporting(bool enabled) + { + _driverConfigReportingEnabled = enabled; + return this; + } + /// /// Build the cluster with the configured set of initial contact points and policies. /// diff --git a/src/Cassandra/Configuration.cs b/src/Cassandra/Configuration.cs index 29d42bd7b..48e30410f 100644 --- a/src/Cassandra/Configuration.cs +++ b/src/Cassandra/Configuration.cs @@ -50,6 +50,7 @@ public class Configuration { internal const string DefaultExecutionProfileName = "default"; internal const string DefaultSessionName = "s"; + internal const bool DefaultDriverConfigReportingEnabled = true; /// /// Gets the policies set for the cluster. @@ -198,7 +199,9 @@ public class Configuration public bool ApplicationNameWasGenerated { get; } /// - /// A unique identifier for the created cluster instance. + /// An identifier for the created cluster instance. Generated by the driver unless explicitly set via + /// , in which case it is application supplied and not guaranteed to + /// be unique, so distinct instances may share the same value. /// public Guid ClusterId { get; } @@ -208,6 +211,15 @@ public class Configuration /// public bool AllowBetaProtocolVersions { get; } + /// + /// + /// + /// + /// This is captured once into the when this + /// is built, since it is immutable afterwards; it is not re-read per control connection. + /// + public bool DriverConfigReportingEnabled { get; } + /// /// The key is the execution profile name and the value is the IRequestOptions instance /// built from the execution profile with that key. @@ -299,7 +311,8 @@ internal Configuration(Policies policies, ISupportedOptionsInitializerFactory supportedOptionsInitializerFactory = null, IProtocolVersionNegotiator protocolVersionNegotiator = null, IServerEventsSubscriber serverEventsSubscriber = null, - IRequestTracker requestTracker = null) + IRequestTracker requestTracker = null, + bool? driverConfigReportingEnabled = null) { AddressTranslator = addressTranslator ?? throw new ArgumentNullException(nameof(addressTranslator)); QueryOptions = queryOptions ?? throw new ArgumentNullException(nameof(queryOptions)); @@ -316,7 +329,12 @@ internal Configuration(Policies policies, ClientOptions = clientOptions; AuthProvider = authProvider; AuthInfoProvider = authInfoProvider; - StartupOptionsFactory = startupOptionsFactory ?? new StartupOptionsFactory(ClusterId, ApplicationVersion, ApplicationName); + DriverConfigReportingEnabled = driverConfigReportingEnabled ?? Configuration.DefaultDriverConfigReportingEnabled; + StartupOptionsFactory = startupOptionsFactory ?? new StartupOptionsFactory( + ClusterId, + ApplicationVersion, + ApplicationName, + DriverConfigReportingEnabled ? new DriverConfigReporter() : null); SessionFactory = sessionFactory ?? new SessionFactory(); RequestOptionsMapper = requestOptionsMapper ?? new RequestOptionsMapper(); MetadataSyncOptions = metadataSyncOptions?.Clone() ?? new MetadataSyncOptions(); diff --git a/src/Cassandra/Connections/ConnectionFactory.cs b/src/Cassandra/Connections/ConnectionFactory.cs index 39cb69aeb..31563261f 100644 --- a/src/Cassandra/Connections/ConnectionFactory.cs +++ b/src/Cassandra/Connections/ConnectionFactory.cs @@ -27,7 +27,11 @@ public IConnection Create( ISerializer serializer, IConnectionEndPoint endPoint, Configuration configuration, IConnectionObserver connectionObserver) { return new Connection( - serializer, endPoint, configuration, new StartupRequestFactory(configuration.StartupOptionsFactory), connectionObserver); + serializer, + endPoint, + configuration, + new StartupRequestFactory(configuration.StartupOptionsFactory, isControlConnection: false), + connectionObserver); } public IConnection CreateUnobserved(ISerializer serializer, IConnectionEndPoint endPoint, Configuration configuration) @@ -36,7 +40,7 @@ public IConnection CreateUnobserved(ISerializer serializer, IConnectionEndPoint serializer, endPoint, configuration, - new StartupRequestFactory(configuration.StartupOptionsFactory), + new StartupRequestFactory(configuration.StartupOptionsFactory, isControlConnection: true), NullConnectionObserver.Instance); } } diff --git a/src/Cassandra/Connections/IConnectionFactory.cs b/src/Cassandra/Connections/IConnectionFactory.cs index 8ab15fc4f..2e754731a 100644 --- a/src/Cassandra/Connections/IConnectionFactory.cs +++ b/src/Cassandra/Connections/IConnectionFactory.cs @@ -27,7 +27,8 @@ internal interface IConnectionFactory IConnection Create(ISerializer serializer, IConnectionEndPoint endPoint, Configuration configuration, IConnectionObserver connectionObserver); /// - /// Create an unobserved connection (without a ). Usually used for control connections. + /// Create an unobserved connection (without a ). Used for control connections, + /// which are the only ones reporting the driver configuration on startup. /// IConnection CreateUnobserved(ISerializer serializer, IConnectionEndPoint endPoint, Configuration configuration); } diff --git a/src/Cassandra/Requests/DriverConfigReporter.cs b/src/Cassandra/Requests/DriverConfigReporter.cs new file mode 100644 index 000000000..a27bdbc84 --- /dev/null +++ b/src/Cassandra/Requests/DriverConfigReporter.cs @@ -0,0 +1,108 @@ +// +// 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.Collections.Generic; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Cassandra.Requests +{ + /// + internal class DriverConfigReporter : IDriverConfigReporter + { + /// + /// STARTUP option holding the JSON description of the effective driver configuration. + /// + internal 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; + + /// + /// 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. + /// + /// + /// 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. + /// + /// + internal const int MaxDriverConfigLength = 32 * 1024; + + private static readonly Logger Logger = new Logger(typeof(DriverConfigReporter)); + + public void AddStartupOptions(IDictionary startupOptions) + { + string report; + try + { + report = BuildReport(); + } + catch (Exception ex) + { + DriverConfigReporter.Logger.Warning( + "Could not build the driver configuration report, it will not be reported to the cluster: {0}", ex); + return; + } + + var length = Encoding.UTF8.GetByteCount(report); + if (length > DriverConfigReporter.MaxDriverConfigLength) + { + DriverConfigReporter.Logger.Warning( + "The driver configuration report is {0} bytes long, which exceeds the {1} bytes limit, " + + "it will not be reported to the cluster.", length, DriverConfigReporter.MaxDriverConfigLength); + return; + } + + startupOptions[DriverConfigReporter.DriverConfigOption] = report; + } + + /// + /// 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. + /// + /// + /// 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. + /// + protected virtual string BuildReport() + { + var report = new JObject { ["version"] = DriverConfigReporter.SchemaVersion }; + PopulateConfig(report); + return report.ToString(Formatting.None); + } + + /// + /// Extension point for subclasses to add further configuration groups to the report. Empty for now. + /// + protected virtual void PopulateConfig(JObject report) + { + } + } +} diff --git a/src/Cassandra/Requests/IDriverConfigReporter.cs b/src/Cassandra/Requests/IDriverConfigReporter.cs new file mode 100644 index 000000000..73be4ca4f --- /dev/null +++ b/src/Cassandra/Requests/IDriverConfigReporter.cs @@ -0,0 +1,38 @@ +// +// 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.Collections.Generic; + +namespace Cassandra.Requests +{ + /// + /// Describes the effective driver configuration to the cluster through the CQL STARTUP options. + /// ScyllaDB exposes them in the client_options column of its clients table, so that operators can + /// inspect the settings of a client while investigating an incident. + /// + internal interface IDriverConfigReporter + { + /// + /// Adds the configuration report to the STARTUP options of the control connection. + /// + /// + /// Implementations must not throw. This runs while a connection is being initialized, so a report that + /// cannot be built has to be left out rather than prevent the connection from being established. + /// + /// The options of the STARTUP request being built. + void AddStartupOptions(IDictionary startupOptions); + } +} diff --git a/src/Cassandra/Requests/IStartupOptionsFactory.cs b/src/Cassandra/Requests/IStartupOptionsFactory.cs index 5a49edfb0..0c8e6f0d5 100644 --- a/src/Cassandra/Requests/IStartupOptionsFactory.cs +++ b/src/Cassandra/Requests/IStartupOptionsFactory.cs @@ -21,6 +21,13 @@ namespace Cassandra.Requests { internal interface IStartupOptionsFactory { - IReadOnlyDictionary CreateStartupOptions(ProtocolOptions options, ISupportedOptionsInitializer supportedOptionsInitializer); + /// The protocol options of the cluster. + /// Supplies the options the server advertised, may be null. + /// + /// Whether the options are being built for the control connection, which is the only one reporting the + /// driver configuration. + /// + IReadOnlyDictionary CreateStartupOptions( + ProtocolOptions options, ISupportedOptionsInitializer supportedOptionsInitializer, bool isControlConnection); } } \ No newline at end of file diff --git a/src/Cassandra/Requests/StartupOptionsFactory.cs b/src/Cassandra/Requests/StartupOptionsFactory.cs index ea0091747..42c202df0 100644 --- a/src/Cassandra/Requests/StartupOptionsFactory.cs +++ b/src/Cassandra/Requests/StartupOptionsFactory.cs @@ -32,6 +32,20 @@ internal class StartupOptionsFactory : IStartupOptionsFactory public const string ApplicationNameOption = "APPLICATION_NAME"; public const string ApplicationVersionOption = "APPLICATION_VERSION"; public const string ClientIdOption = "CLIENT_ID"; + + /// + /// Identifies the instance the connection belongs to, so that the server can + /// correlate its connections with each other. The option name follows the convention shared with the + /// other ScyllaDB drivers, where a session is what this driver calls a cluster, so it is unrelated to + /// . + /// + /// + /// Unlike , which reports the application supplied + /// , this is a random identifier generated by the driver, so + /// distinct clusters do not collide even when the application gives all of them the same cluster id. + /// + public const string SessionIdOption = "SESSION_ID"; + public const string TabletsRoutingV1Option = "TABLETS_ROUTING_V1"; public const string LwtOption = "SCYLLA_LWT_ADD_METADATA_MARK"; @@ -42,21 +56,40 @@ internal class StartupOptionsFactory : IStartupOptionsFactory private readonly string _appName; private readonly string _appVersion; private readonly Guid _clusterId; + private readonly Guid _sessionId; + private readonly IDriverConfigReporter _driverConfigReporter; + + public StartupOptionsFactory(Guid clusterId, string appVersion, string appName, IDriverConfigReporter driverConfigReporter) + : this(clusterId, Guid.NewGuid(), appVersion, appName, driverConfigReporter) + { + } - public StartupOptionsFactory(Guid clusterId, string appVersion, string appName) + internal StartupOptionsFactory( + Guid clusterId, Guid sessionId, string appVersion, string appName, IDriverConfigReporter driverConfigReporter) { _appName = appName; _appVersion = appVersion; _clusterId = clusterId; + _sessionId = sessionId; + _driverConfigReporter = driverConfigReporter; } - public IReadOnlyDictionary CreateStartupOptions(ProtocolOptions options, ISupportedOptionsInitializer supportedOptionsInitializer = null) + public IReadOnlyDictionary CreateStartupOptions( + ProtocolOptions options, ISupportedOptionsInitializer supportedOptionsInitializer = null, bool isControlConnection = false) { var startupOptions = new Dictionary { { StartupOptionsFactory.CqlVersionOption, StartupOptionsFactory.CqlVersion } }; + if (isControlConnection) + { + // The configuration is the same for every connection, only the control connection reports it. + // A null reporter means reporting the driver configuration is disabled. + // This has to run before any other option is added below, so that it can never override them. + _driverConfigReporter?.AddStartupOptions(startupOptions); + } + string compressionName = null; switch (options.Compression) { @@ -104,6 +137,8 @@ public IReadOnlyDictionary CreateStartupOptions(ProtocolOptions } startupOptions[StartupOptionsFactory.ClientIdOption] = _clusterId.ToString(); + startupOptions[StartupOptionsFactory.SessionIdOption] = _sessionId.ToString(); + return startupOptions; } } diff --git a/src/Cassandra/Requests/StartupRequestFactory.cs b/src/Cassandra/Requests/StartupRequestFactory.cs index b2b01ad70..903241583 100644 --- a/src/Cassandra/Requests/StartupRequestFactory.cs +++ b/src/Cassandra/Requests/StartupRequestFactory.cs @@ -21,15 +21,23 @@ namespace Cassandra.Requests internal class StartupRequestFactory : IStartupRequestFactory { private readonly IStartupOptionsFactory _optionsFactory; + private readonly bool _isControlConnection; - public StartupRequestFactory(IStartupOptionsFactory optionsFactory) + /// Builds the options of the STARTUP requests. + /// + /// Whether the connection this factory belongs to is the control connection, which is the only one + /// reporting the driver configuration. + /// + public StartupRequestFactory(IStartupOptionsFactory optionsFactory, bool isControlConnection) { _optionsFactory = optionsFactory; + _isControlConnection = isControlConnection; } public IRequest CreateStartupRequest(ProtocolOptions protocolOptions, ISupportedOptionsInitializer supportedOptionsInitializer) { - return new StartupRequest(_optionsFactory.CreateStartupOptions(protocolOptions, supportedOptionsInitializer)); + return new StartupRequest( + _optionsFactory.CreateStartupOptions(protocolOptions, supportedOptionsInitializer, _isControlConnection)); } } } \ No newline at end of file