From ef9dd8b3194471982dcb8c7b213bf0ba27486ac6 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Mon, 3 Aug 2026 09:26:13 +0200 Subject: [PATCH 1/3] Report SESSION_ID in the STARTUP options Send an identifier that is shared by every connection of a Cluster instance, so that the server can correlate them with each other. It is generated by the driver rather than derived from the CLIENT_ID option, which reports the application supplied Configuration.ClusterId and is therefore not guaranteed to be unique. --- .../Requests/StartupOptionsFactoryTests.cs | 32 +++++++++++++++++-- .../Requests/StartupOptionsFactory.cs | 25 ++++++++++++++- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/Cassandra.Tests/Requests/StartupOptionsFactoryTests.cs b/src/Cassandra.Tests/Requests/StartupOptionsFactoryTests.cs index 5eb7cb65c..252f7d818 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); 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"]; @@ -65,9 +67,33 @@ public void Should_NotReturnOptions_When_OptionsAreNull() 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); + + var firstOptions = factory.CreateStartupOptions(new ProtocolOptions()); + var secondOptions = factory.CreateStartupOptions(new ProtocolOptions()); + + Assert.AreEqual(firstOptions["SESSION_ID"], secondOptions["SESSION_ID"]); + } + + [Test] + public void Should_ReportDistinctSessionIds_When_ThereAreSeveralClusters() + { + var clusterId = Guid.NewGuid(); + var firstFactory = new StartupOptionsFactory(clusterId, null, null); + var secondFactory = new StartupOptionsFactory(clusterId, null, null); + + var firstOptions = firstFactory.CreateStartupOptions(new ProtocolOptions()); + var secondOptions = secondFactory.CreateStartupOptions(new ProtocolOptions()); + + Assert.AreNotEqual(firstOptions["SESSION_ID"], secondOptions["SESSION_ID"]); + } } } \ No newline at end of file diff --git a/src/Cassandra/Requests/StartupOptionsFactory.cs b/src/Cassandra/Requests/StartupOptionsFactory.cs index ea0091747..b82d906e3 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,15 +56,23 @@ internal class StartupOptionsFactory : IStartupOptionsFactory private readonly string _appName; private readonly string _appVersion; private readonly Guid _clusterId; + private readonly Guid _sessionId; public StartupOptionsFactory(Guid clusterId, string appVersion, string appName) + : this(clusterId, Guid.NewGuid(), appVersion, appName) + { + } + + internal StartupOptionsFactory(Guid clusterId, Guid sessionId, string appVersion, string appName) { _appName = appName; _appVersion = appVersion; _clusterId = clusterId; + _sessionId = sessionId; } - public IReadOnlyDictionary CreateStartupOptions(ProtocolOptions options, ISupportedOptionsInitializer supportedOptionsInitializer = null) + public IReadOnlyDictionary CreateStartupOptions( + ProtocolOptions options, ISupportedOptionsInitializer supportedOptionsInitializer = null) { var startupOptions = new Dictionary { @@ -104,6 +126,7 @@ public IReadOnlyDictionary CreateStartupOptions(ProtocolOptions } startupOptions[StartupOptionsFactory.ClientIdOption] = _clusterId.ToString(); + startupOptions[StartupOptionsFactory.SessionIdOption] = _sessionId.ToString(); return startupOptions; } } From a72c5fa9cc4d5a6636119e374487417dae9769cf Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Mon, 3 Aug 2026 09:30:42 +0200 Subject: [PATCH 2/3] Report the driver configuration on the control connection Describe the effective driver configuration to the cluster as the DRIVER_CONFIG startup option, so that operators can inspect the settings of a client while investigating an incident. ScyllaDB exposes it in the client_options column of its clients table. The configuration is the same for every connection, so only the control connection reports it. Connections learn whether they are the control connection from the StartupRequestFactory the ConnectionFactory builds for them, which keeps the flag out of Connection itself. The report only holds the schema version for now, the configuration groups follow. Building it is fail safe and its size is capped, because a diagnostic aid must never keep a connection from being established. Reporting can be turned off with Builder.WithDriverConfigReporting. --- .../Core/ConnectionTests.cs | 6 +- src/Cassandra.Tests/BuilderTests.cs | 19 +++ src/Cassandra.Tests/ConnectionTests.cs | 6 +- .../HostConnectionPoolTests.cs | 2 +- .../Requests/DriverConfigReporterTests.cs | 112 ++++++++++++++++++ .../Requests/StartupOptionsFactoryTests.cs | 37 ++++-- .../TestConfigurationBuilder.cs | 7 +- src/Cassandra/Builder.cs | 19 ++- src/Cassandra/Configuration.cs | 24 +++- .../Connections/ConnectionFactory.cs | 8 +- .../Connections/IConnectionFactory.cs | 3 +- .../Requests/DriverConfigReporter.cs | 108 +++++++++++++++++ .../Requests/IDriverConfigReporter.cs | 38 ++++++ .../Requests/IStartupOptionsFactory.cs | 9 +- .../Requests/StartupOptionsFactory.cs | 20 +++- .../Requests/StartupRequestFactory.cs | 12 +- 16 files changed, 399 insertions(+), 31 deletions(-) create mode 100644 src/Cassandra.Tests/Requests/DriverConfigReporterTests.cs create mode 100644 src/Cassandra/Requests/DriverConfigReporter.cs create mode 100644 src/Cassandra/Requests/IDriverConfigReporter.cs 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.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 252f7d818..80ac9bc5f 100644 --- a/src/Cassandra.Tests/Requests/StartupOptionsFactoryTests.cs +++ b/src/Cassandra.Tests/Requests/StartupOptionsFactoryTests.cs @@ -30,7 +30,7 @@ public class StartupOptionsFactoryTests public void Should_ReturnCorrectProtocolStartupOptions_When_OptionsAreSet() { var sessionId = Guid.NewGuid(); - var factory = new StartupOptionsFactory(Guid.NewGuid(), sessionId, null, null); + var factory = new StartupOptionsFactory(Guid.NewGuid(), sessionId, null, null, new DriverConfigReporter()); var options = factory.CreateStartupOptions(new ProtocolOptions().SetNoCompact(true).SetCompression(CompressionType.Snappy)); @@ -63,7 +63,7 @@ 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)); @@ -75,25 +75,46 @@ public void Should_NotReturnOptions_When_OptionsAreNull() [Test] public void Should_ReportTheSameSessionId_When_OptionsAreBuiltForSeveralConnections() { - var factory = new StartupOptionsFactory(Guid.NewGuid(), null, null); + var factory = new StartupOptionsFactory(Guid.NewGuid(), null, null, new DriverConfigReporter()); - var firstOptions = factory.CreateStartupOptions(new ProtocolOptions()); - var secondOptions = factory.CreateStartupOptions(new ProtocolOptions()); + var controlConnectionOptions = factory.CreateStartupOptions(new ProtocolOptions(), null, true); + var poolOptions = factory.CreateStartupOptions(new ProtocolOptions(), null, false); - Assert.AreEqual(firstOptions["SESSION_ID"], secondOptions["SESSION_ID"]); + 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); - var secondFactory = new StartupOptionsFactory(clusterId, null, null); + 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 b82d906e3..42c202df0 100644 --- a/src/Cassandra/Requests/StartupOptionsFactory.cs +++ b/src/Cassandra/Requests/StartupOptionsFactory.cs @@ -57,28 +57,39 @@ internal class StartupOptionsFactory : IStartupOptionsFactory private readonly string _appVersion; private readonly Guid _clusterId; private readonly Guid _sessionId; + private readonly IDriverConfigReporter _driverConfigReporter; - public StartupOptionsFactory(Guid clusterId, string appVersion, string appName) - : this(clusterId, Guid.NewGuid(), appVersion, appName) + public StartupOptionsFactory(Guid clusterId, string appVersion, string appName, IDriverConfigReporter driverConfigReporter) + : this(clusterId, Guid.NewGuid(), appVersion, appName, driverConfigReporter) { } - internal StartupOptionsFactory(Guid clusterId, Guid sessionId, 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) + 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) { @@ -127,6 +138,7 @@ public IReadOnlyDictionary CreateStartupOptions( 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 From aa8310fe6490dfbeb64b4852f6de91eb37579605 Mon Sep 17 00:00:00 2001 From: sylwiaszunejko Date: Mon, 3 Aug 2026 17:52:31 +0200 Subject: [PATCH 3/3] Add integration tests for the SESSION_ID and DRIVER_CONFIG startup options Verify over an actual connection to Simulacron that: - Only the control connection reports DRIVER_CONFIG, with the expected schema version, and that Builder.WithDriverConfigReporting(false) suppresses it. - Every connection of a Cluster instance reports the same SESSION_ID, and distinct Cluster instances report distinct ones. Adds Frame.GetStartupMessage() and the backing StartupMessage model to the Simulacron log helpers, following the existing GetQueryMessage/ GetBatchMessage pattern, so STARTUP frames can be inspected like other request types. --- .../Core/StartupOptionsTests.cs | 144 ++++++++++++++++++ .../SimulacronAPI/Models/Logs/Frame.cs | 5 + .../Models/Logs/StartupMessage.cs | 27 ++++ 3 files changed, 176 insertions(+) create mode 100644 src/Cassandra.IntegrationTests/Core/StartupOptionsTests.cs create mode 100644 src/Cassandra.IntegrationTests/SimulacronAPI/Models/Logs/StartupMessage.cs 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; } + } +}