diff --git a/src/MySqlConnector/Core/ConnectionPool.cs b/src/MySqlConnector/Core/ConnectionPool.cs index ad4f917f9..674a01300 100644 --- a/src/MySqlConnector/Core/ConnectionPool.cs +++ b/src/MySqlConnector/Core/ConnectionPool.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Diagnostics; +using System.Diagnostics.Metrics; using System.Net; using System.Security.Authentication; using Microsoft.Extensions.Logging; @@ -50,12 +51,14 @@ public async ValueTask GetSessionAsync(MySqlConnection connection { if (m_sessions.Count > 0) { + // NOTE: MetricsReporter updated outside lock below session = m_sessions.First!.Value; m_sessions.RemoveFirst(); } } if (session is not null) { + MetricsReporter.RemoveIdle(this); Log.FoundExistingSession(m_logger, Id); bool reuseSession; @@ -96,8 +99,12 @@ public async ValueTask GetSessionAsync(MySqlConnection connection m_leasedSessions.Add(session.Id, session); leasedSessionsCountPooled = m_leasedSessions.Count; } + MetricsReporter.AddUsed(this); ActivitySourceHelper.CopyTags(session.ActivityTags, activity); Log.ReturningPooledSession(m_logger, Id, session.Id, leasedSessionsCountPooled); + + session.LastLeasedTicks = unchecked((uint) Environment.TickCount); + MetricsReporter.RecordWaitTime(this, unchecked(session.LastLeasedTicks - (uint) startTickCount)); return session; } } @@ -112,7 +119,11 @@ public async ValueTask GetSessionAsync(MySqlConnection connection m_leasedSessions.Add(session.Id, session); leasedSessionsCountNew = m_leasedSessions.Count; } + MetricsReporter.AddUsed(this); Log.ReturningNewSession(m_logger, Id, session.Id, leasedSessionsCountNew); + + session.LastLeasedTicks = unchecked((uint) Environment.TickCount); + MetricsReporter.RecordCreateTime(this, unchecked(session.LastLeasedTicks - (uint) startTickCount)); return session; } catch (Exception ex) @@ -164,12 +175,14 @@ public async ValueTask ReturnAsync(IOBehavior ioBehavior, ServerSession session) { lock (m_leasedSessions) m_leasedSessions.Remove(session.Id); + MetricsReporter.RemoveUsed(this); session.OwningConnection = null; var sessionHealth = GetSessionHealth(session); if (sessionHealth == 0) { lock (m_sessions) m_sessions.AddFirst(session); + MetricsReporter.AddIdle(this); } else { @@ -224,6 +237,8 @@ public async Task ReapAsync(IOBehavior ioBehavior, CancellationToken cancellatio public void Dispose() { Log.DisposingConnectionPool(m_logger, Id); + lock (s_allPools) + s_allPools.Remove(this); #if NET6_0_OR_GREATER m_dnsCheckTimer?.Dispose(); m_dnsCheckTimer = null; @@ -326,12 +341,14 @@ private async Task CleanPoolAsync(IOBehavior ioBehavior, Func 0) { + // NOTE: MetricsReporter updated outside lock below session = m_sessions.Last!.Value; m_sessions.RemoveLast(); } } if (session is null) return; + MetricsReporter.RemoveIdle(this); if (shouldCleanFn(session)) { @@ -344,6 +361,7 @@ private async Task CleanPoolAsync(IOBehavior ioBehavior, Func ConnectSessionAsync(MySqlConnection conne else if (pool != newPool) { Log.CreatedPoolWillNotBeUsed(newPool.m_logger, newPool.Id); + newPool.Dispose(); } return pool; @@ -553,10 +573,10 @@ private async ValueTask ConnectSessionAsync(MySqlConnection conne public static async Task ClearPoolsAsync(IOBehavior ioBehavior, CancellationToken cancellationToken) { - foreach (var pool in GetAllPools()) + foreach (var pool in GetCachedPools()) await pool.ClearAsync(ioBehavior, cancellationToken).ConfigureAwait(false); - static List GetAllPools() + static List GetCachedPools() { var pools = new List(s_pools.Count); var uniquePools = new HashSet(); @@ -594,8 +614,19 @@ private ConnectionPool(MySqlConnectorLoggingConfiguration loggingConfiguration, cs.LoadBalance == MySqlLoadBalance.LeastConnections ? new LeastConnectionsLoadBalancer(m_hostSessions!) : (ILoadBalancer) new RoundRobinLoadBalancer(); + // create tag lists for reporting pool metrics + var connectionString = cs.ConnectionStringBuilder.GetConnectionString(includePassword: false); + m_stateTagList = + [ + new("state", "idle"), + new("pool.name", Name ?? connectionString), + new("state", "used"), + ]; + Id = Interlocked.Increment(ref s_poolId); - Log.CreatingNewConnectionPool(m_logger, Id, cs.ConnectionStringBuilder.GetConnectionString(includePassword: false)); + lock (s_allPools) + s_allPools.Add(this); + Log.CreatingNewConnectionPool(m_logger, Id, connectionString); } private void StartReaperTask() @@ -741,6 +772,19 @@ private void AdjustHostConnectionCount(ServerSession session, int delta) } } + // Provides a slice of m_stateTagList that contains either the 'idle' or 'used' state tag along with the pool name. + public ReadOnlySpan> IdleStateTagList => m_stateTagList.AsSpan(0, 2); + public ReadOnlySpan> UsedStateTagList => m_stateTagList.AsSpan(1, 2); + + // A slice of m_stateTagList that contains only the pool name tag. + public ReadOnlySpan> PoolNameTagList => m_stateTagList.AsSpan(1, 1); + + public static List GetAllPools() + { + lock (s_allPools) + return new(s_allPools); + } + private sealed class LeastConnectionsLoadBalancer(Dictionary hostSessions) : ILoadBalancer { public IReadOnlyList LoadBalance(IReadOnlyList hosts) @@ -766,6 +810,7 @@ private static void OnAppDomainShutDown(object? sender, EventArgs e) => ClearPoolsAsync(IOBehavior.Synchronous, CancellationToken.None).GetAwaiter().GetResult(); private static readonly ConcurrentDictionary s_pools = new(); + private static readonly List s_allPools = new(); private static readonly Action s_createdNewSession = LoggerMessage.Define( LogLevel.Debug, new EventId(EventIds.PoolCreatedNewSession, nameof(EventIds.PoolCreatedNewSession)), "Pool {PoolId} has no pooled session available; created new session {SessionId}"); @@ -777,6 +822,7 @@ private static void OnAppDomainShutDown(object? sender, EventArgs e) => private readonly ILogger m_logger; private readonly ILogger m_connectionLogger; + private readonly KeyValuePair[] m_stateTagList; private readonly SemaphoreSlim m_cleanSemaphore; private readonly SemaphoreSlim m_sessionSemaphore; private readonly LinkedList m_sessions; diff --git a/src/MySqlConnector/Core/MetricsReporter.cs b/src/MySqlConnector/Core/MetricsReporter.cs new file mode 100644 index 000000000..0336b0f5c --- /dev/null +++ b/src/MySqlConnector/Core/MetricsReporter.cs @@ -0,0 +1,57 @@ +using System.Diagnostics.Metrics; +using MySqlConnector.Utilities; + +namespace MySqlConnector.Core; + +internal static class MetricsReporter +{ + public static void AddIdle(ConnectionPool pool) => s_connectionsUsageCounter.Add(1, pool.IdleStateTagList); + public static void RemoveIdle(ConnectionPool pool) => s_connectionsUsageCounter.Add(-1, pool.IdleStateTagList); + public static void AddUsed(ConnectionPool pool) => s_connectionsUsageCounter.Add(1, pool.UsedStateTagList); + public static void RemoveUsed(ConnectionPool pool) => s_connectionsUsageCounter.Add(-1, pool.UsedStateTagList); + public static void RecordCreateTime(ConnectionPool pool, uint ticks) => s_createTimeHistory.Record(ticks, pool.PoolNameTagList); + public static void RecordUseTime(ConnectionPool pool, uint ticks) => s_useTimeHistory.Record(ticks, pool.PoolNameTagList); + public static void RecordWaitTime(ConnectionPool pool, uint ticks) => s_waitTimeHistory.Record(ticks, pool.PoolNameTagList); + + public static void AddPendingRequest(ConnectionPool? pool) + { + if (pool is not null) + s_pendingRequestsCounter.Add(1, pool.PoolNameTagList); + } + + public static void RemovePendingRequest(ConnectionPool? pool) + { + if (pool is not null) + s_pendingRequestsCounter.Add(-1, pool.PoolNameTagList); + } + + static MetricsReporter() + { + ActivitySourceHelper.Meter.CreateObservableUpDownCounter("db.client.connections.idle.max", + observeValues: GetMaximumConnections, unit: "{connection}", + description: "The maximum number of idle open connections allowed; this corresponds to MaximumPoolSize in the connection string."); + ActivitySourceHelper.Meter.CreateObservableUpDownCounter("db.client.connections.idle.min", + observeValues: GetMinimumConnections, unit: "{connection}", + description: "The minimum number of idle open connections allowed; this corresponds to MinimumPoolSize in the connection string."); + ActivitySourceHelper.Meter.CreateObservableUpDownCounter("db.client.connections.max", + observeValues: GetMaximumConnections, unit: "{connection}", + description: "The maximum number of open connections allowed; this corresponds to MaximumPoolSize in the connection string."); + + static IEnumerable> GetMaximumConnections() => + ConnectionPool.GetAllPools().Select(x => new Measurement(x.ConnectionSettings.MaximumPoolSize, x.PoolNameTagList)); + + static IEnumerable> GetMinimumConnections() => + ConnectionPool.GetAllPools().Select(x => new Measurement(x.ConnectionSettings.MinimumPoolSize, x.PoolNameTagList)); + } + + private static readonly UpDownCounter s_connectionsUsageCounter = ActivitySourceHelper.Meter.CreateUpDownCounter("db.client.connections.usage", + unit: "{connection}", description: "The number of connections that are currently in the state described by the state tag."); + private static readonly UpDownCounter s_pendingRequestsCounter = ActivitySourceHelper.Meter.CreateUpDownCounter("db.client.connections.pending_requests", + unit: "{request}", description: "The number of pending requests for an open connection, cumulative for the entire pool."); + private static readonly Histogram s_createTimeHistory = ActivitySourceHelper.Meter.CreateHistogram("db.client.connections.create_time", + unit: "ms", description: "The time it took to create a new connection."); + private static readonly Histogram s_useTimeHistory = ActivitySourceHelper.Meter.CreateHistogram("db.client.connections.use_time", + unit: "ms", description: "The time between borrowing a connection and returning it to the pool."); + private static readonly Histogram s_waitTimeHistory = ActivitySourceHelper.Meter.CreateHistogram("db.client.connections.wait_time", + unit: "ms", description: "The time it took to obtain an open connection from the pool."); +} diff --git a/src/MySqlConnector/Core/ServerSession.cs b/src/MySqlConnector/Core/ServerSession.cs index 351eda129..9e08c3003 100644 --- a/src/MySqlConnector/Core/ServerSession.cs +++ b/src/MySqlConnector/Core/ServerSession.cs @@ -1,6 +1,7 @@ using System.Buffers.Text; using System.ComponentModel; using System.Diagnostics; +using System.Diagnostics.Metrics; using System.Globalization; using System.IO.Pipes; using System.Net; @@ -56,6 +57,7 @@ public ServerSession(ILogger logger, ConnectionPool? pool, int poolGeneration, i public uint CreatedTicks { get; } public ConnectionPool? Pool { get; } public int PoolGeneration { get; } + public uint LastLeasedTicks { get; set; } public uint LastReturnedTicks { get; private set; } public string? DatabaseOverride { get; set; } public string HostName { get; private set; } @@ -75,7 +77,11 @@ public ValueTask ReturnToPoolAsync(IOBehavior ioBehavior, MySqlConnection? ownin { Log.ReturningToPool(m_logger, Id, Pool?.Id ?? 0); LastReturnedTicks = unchecked((uint) Environment.TickCount); - return Pool is null ? default : Pool.ReturnAsync(ioBehavior, this); + if (Pool is null) + return default; + MetricsReporter.RecordUseTime(Pool, unchecked(LastReturnedTicks - LastLeasedTicks)); + LastLeasedTicks = 0; + return Pool.ReturnAsync(ioBehavior, this); } public bool IsConnected diff --git a/src/MySqlConnector/MySqlConnection.cs b/src/MySqlConnector/MySqlConnection.cs index ddb259522..dfe3a0c8b 100644 --- a/src/MySqlConnector/MySqlConnection.cs +++ b/src/MySqlConnector/MySqlConnection.cs @@ -384,6 +384,8 @@ private async ValueTask PingAsync(IOBehavior ioBehavior, CancellationToken internal async Task OpenAsync(IOBehavior? ioBehavior, CancellationToken cancellationToken) { + var openStartTickCount = Environment.TickCount; + VerifyNotDisposed(); cancellationToken.ThrowIfCancellationRequested(); if (State != ConnectionState.Closed) @@ -392,8 +394,6 @@ internal async Task OpenAsync(IOBehavior? ioBehavior, CancellationToken cancella using var activity = ActivitySourceHelper.StartActivity(ActivitySourceHelper.OpenActivityName); try { - var openStartTickCount = Environment.TickCount; - SetState(ConnectionState.Connecting); var pool = m_dataSource?.Pool ?? @@ -896,6 +896,7 @@ internal void FinishQuerying(bool hasWarnings) private async ValueTask CreateSessionAsync(ConnectionPool? pool, int startTickCount, Activity? activity, IOBehavior? ioBehavior, CancellationToken cancellationToken) { + MetricsReporter.AddPendingRequest(pool); var connectionSettings = GetInitializedConnectionSettings(); var actualIOBehavior = ioBehavior ?? (connectionSettings.ForceSynchronous ? IOBehavior.Synchronous : IOBehavior.Asynchronous); @@ -949,6 +950,7 @@ private async ValueTask CreateSessionAsync(ConnectionPool? pool, } finally { + MetricsReporter.RemovePendingRequest(pool); linkedSource?.Dispose(); timeoutSource?.Dispose(); } diff --git a/src/MySqlConnector/MySqlConnector.csproj b/src/MySqlConnector/MySqlConnector.csproj index a8e3df037..5ce7293e0 100644 --- a/src/MySqlConnector/MySqlConnector.csproj +++ b/src/MySqlConnector/MySqlConnector.csproj @@ -29,7 +29,7 @@ - + diff --git a/src/MySqlConnector/Utilities/ActivitySourceHelper.cs b/src/MySqlConnector/Utilities/ActivitySourceHelper.cs index a96dbe78e..1f2c15a0d 100644 --- a/src/MySqlConnector/Utilities/ActivitySourceHelper.cs +++ b/src/MySqlConnector/Utilities/ActivitySourceHelper.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Diagnostics.Metrics; using System.Globalization; using System.Reflection; @@ -55,12 +56,10 @@ public static void CopyTags(IEnumerable> tags, Act } } - private static ActivitySource ActivitySource { get; } = CreateActivitySource(); + public static Meter Meter { get; } = new("MySqlConnector", GetVersion()); - private static ActivitySource CreateActivitySource() - { - var assembly = typeof(ActivitySourceHelper).Assembly; - var version = assembly.GetCustomAttribute()!.Version; - return new("MySqlConnector", version); - } + private static ActivitySource ActivitySource { get; } = new("MySqlConnector", GetVersion()); + + private static string GetVersion() => + typeof(ActivitySourceHelper).Assembly.GetCustomAttribute()!.Version; } diff --git a/tests/MySqlConnector.Tests/ConnectionTests.cs b/tests/MySqlConnector.Tests/ConnectionTests.cs index 62b06541f..73f7b0447 100644 --- a/tests/MySqlConnector.Tests/ConnectionTests.cs +++ b/tests/MySqlConnector.Tests/ConnectionTests.cs @@ -179,7 +179,7 @@ public void PingWhenClosed() [Fact] public void ConnectionTimeout() { - m_server.BlockOnConnect = true; + m_server.ConnectDelay = TimeSpan.FromSeconds(10); var csb = new MySqlConnectionStringBuilder(m_csb.ConnectionString); csb.ConnectionTimeout = 4; using var connection = new MySqlConnection(csb.ConnectionString); diff --git a/tests/MySqlConnector.Tests/FakeMySqlServer.cs b/tests/MySqlConnector.Tests/FakeMySqlServer.cs index d1f6d5a9d..d070a085f 100644 --- a/tests/MySqlConnector.Tests/FakeMySqlServer.cs +++ b/tests/MySqlConnector.Tests/FakeMySqlServer.cs @@ -52,7 +52,7 @@ public void Stop() public bool SuppressAuthPluginNameTerminatingNull { get; set; } public bool SendIncompletePostHandshakeResponse { get; set; } - public bool BlockOnConnect { get; set; } + public TimeSpan? ConnectDelay { get; set; } public TimeSpan? ResetDelay { get; set; } internal void CancelQuery(int connectionId) diff --git a/tests/MySqlConnector.Tests/FakeMySqlServerConnection.cs b/tests/MySqlConnector.Tests/FakeMySqlServerConnection.cs index b2fec1411..cf7441dd0 100644 --- a/tests/MySqlConnector.Tests/FakeMySqlServerConnection.cs +++ b/tests/MySqlConnector.Tests/FakeMySqlServerConnection.cs @@ -25,8 +25,8 @@ public async Task RunAsync(TcpClient client, CancellationToken token) using (client) using (var stream = client.GetStream()) { - if (m_server.BlockOnConnect) - Thread.Sleep(TimeSpan.FromSeconds(10)); + if (m_server.ConnectDelay is { } connectDelay) + await Task.Delay(connectDelay); await SendAsync(stream, 0, WriteInitialHandshake); await ReadPayloadAsync(stream, token); // handshake response diff --git a/tests/MySqlConnector.Tests/Metrics/ConnectionTimeTests.cs b/tests/MySqlConnector.Tests/Metrics/ConnectionTimeTests.cs new file mode 100644 index 000000000..98156fe21 --- /dev/null +++ b/tests/MySqlConnector.Tests/Metrics/ConnectionTimeTests.cs @@ -0,0 +1,92 @@ +namespace MySqlConnector.Tests.Metrics; + +public class ConnectionTimeTests : MetricsTestsBase +{ + [Fact(Skip = MetricsSkip)] + public async Task ConnectionTime() + { + var csb = CreateConnectionStringBuilder(); + PoolName = csb.GetConnectionString(includePassword: false); + using var connection = new MySqlConnection(csb.ConnectionString); + await connection.OpenAsync(); + var measurements = GetAndClearMeasurements("db.client.connections.create_time"); + var time = Assert.Single(measurements); + Assert.InRange(time, 0, 300); + } + + [Fact(Skip = MetricsSkip)] + public async Task ConnectionTimeWithDelay() + { + var csb = CreateConnectionStringBuilder(); + PoolName = csb.GetConnectionString(includePassword: false); + Server.ConnectDelay = TimeSpan.FromSeconds(1); + + using var connection = new MySqlConnection(csb.ConnectionString); + await connection.OpenAsync(); + var measurements = GetAndClearMeasurements("db.client.connections.create_time"); + var time = Assert.Single(measurements); + Assert.InRange(time, 1000, 1300); + } + + [Fact(Skip = MetricsSkip)] + public async Task OpenFromPoolTime() + { + var csb = CreateConnectionStringBuilder(); + PoolName = csb.GetConnectionString(includePassword: false); + + using var connection = new MySqlConnection(csb.ConnectionString); + await connection.OpenAsync(); + connection.Close(); + + await connection.OpenAsync(); + var measurements = GetAndClearMeasurements("db.client.connections.wait_time"); + var time = Assert.Single(measurements); + Assert.InRange(time, 0, 200); + } + + [Fact(Skip = MetricsSkip)] + public async Task OpenFromPoolTimeWithDelay() + { + var csb = CreateConnectionStringBuilder(); + PoolName = csb.GetConnectionString(includePassword: false); + Server.ResetDelay = TimeSpan.FromSeconds(1); + + using var connection = new MySqlConnection(csb.ConnectionString); + await connection.OpenAsync(); + connection.Close(); + + await connection.OpenAsync(); + var measurements = GetAndClearMeasurements("db.client.connections.wait_time"); + var time = Assert.Single(measurements); + Assert.InRange(time, 1000, 1200); + } + + [Fact(Skip = MetricsSkip)] + public async Task UseTime() + { + var csb = CreateConnectionStringBuilder(); + PoolName = csb.GetConnectionString(includePassword: false); + + using var connection = new MySqlConnection(csb.ConnectionString); + await connection.OpenAsync(); + connection.Close(); + + var time = Assert.Single(GetAndClearMeasurements("db.client.connections.use_time")); + Assert.InRange(time, 0, 100); + } + + [Fact(Skip = MetricsSkip)] + public async Task UseTimeWithDelay() + { + var csb = CreateConnectionStringBuilder(); + PoolName = csb.GetConnectionString(includePassword: false); + + using var connection = new MySqlConnection(csb.ConnectionString); + await connection.OpenAsync(); + await Task.Delay(500); + connection.Close(); + + var time = Assert.Single(GetAndClearMeasurements("db.client.connections.use_time")); + Assert.InRange(time, 500, 600); + } +} diff --git a/tests/MySqlConnector.Tests/Metrics/ConnectionsUsageTests.cs b/tests/MySqlConnector.Tests/Metrics/ConnectionsUsageTests.cs new file mode 100644 index 000000000..09e9440e8 --- /dev/null +++ b/tests/MySqlConnector.Tests/Metrics/ConnectionsUsageTests.cs @@ -0,0 +1,245 @@ +#nullable enable + +namespace MySqlConnector.Tests.Metrics; + +public class ConnectionsUsageTests : MetricsTestsBase +{ + [Fact(Skip = MetricsSkip)] + public void NamedDataSource() + { + PoolName = "metrics-test"; + using var dataSource = new MySqlDataSourceBuilder(CreateConnectionStringBuilder().ConnectionString) + .UseName(PoolName) + .Build(); + + // no connections at beginning of test + AssertMeasurement("db.client.connections.usage", 0); + AssertMeasurement("db.client.connections.usage|idle", 0); + AssertMeasurement("db.client.connections.usage|used", 0); + Assert.Equal(0, Server.ActiveConnections); + + // opening a connection creates a 'used' connection + using (var connection = dataSource.OpenConnection()) + { + AssertMeasurement("db.client.connections.usage", 1); + AssertMeasurement("db.client.connections.usage|idle", 0); + AssertMeasurement("db.client.connections.usage|used", 1); + Assert.Equal(1, Server.ActiveConnections); + } + + // closing it creates an 'idle' connection + AssertMeasurement("db.client.connections.usage", 1); + AssertMeasurement("db.client.connections.usage|idle", 1); + AssertMeasurement("db.client.connections.usage|used", 0); + Assert.Equal(1, Server.ActiveConnections); + + // reopening the connection transitions it back to 'used' + using (var connection = dataSource.OpenConnection()) + { + AssertMeasurement("db.client.connections.usage", 1); + AssertMeasurement("db.client.connections.usage|idle", 0); + AssertMeasurement("db.client.connections.usage|used", 1); + } + Assert.Equal(1, Server.ActiveConnections); + + // opening a second connection creates a net new 'used' connection + using (var connection = dataSource.OpenConnection()) + using (var connection2 = dataSource.OpenConnection()) + { + AssertMeasurement("db.client.connections.usage", 2); + AssertMeasurement("db.client.connections.usage|idle", 0); + AssertMeasurement("db.client.connections.usage|used", 2); + Assert.Equal(2, Server.ActiveConnections); + } + + AssertMeasurement("db.client.connections.usage", 2); + AssertMeasurement("db.client.connections.usage|idle", 2); + AssertMeasurement("db.client.connections.usage|used", 0); + Assert.Equal(2, Server.ActiveConnections); + } + + [Fact(Skip = MetricsSkip)] + public void NamedDataSourceWithMinPoolSize() + { + var csb = CreateConnectionStringBuilder(); + csb.MinimumPoolSize = 3; + + PoolName = "minimum-pool-size"; + using var dataSource = new MySqlDataSourceBuilder(csb.ConnectionString) + .UseName(PoolName) + .Build(); + + // minimum pool size is created lazily when the first connection is opened + AssertMeasurement("db.client.connections.usage", 0); + AssertMeasurement("db.client.connections.usage|idle", 0); + AssertMeasurement("db.client.connections.usage|used", 0); + Assert.Equal(0, Server.ActiveConnections); + + // opening a connection creates the minimum connections then takes an idle one from the pool + using (var connection = dataSource.OpenConnection()) + { + AssertMeasurement("db.client.connections.usage", 3); + AssertMeasurement("db.client.connections.usage|idle", 2); + AssertMeasurement("db.client.connections.usage|used", 1); + Assert.Equal(3, Server.ActiveConnections); + } + + // closing puts it back to idle + AssertMeasurement("db.client.connections.usage", 3); + AssertMeasurement("db.client.connections.usage|idle", 3); + AssertMeasurement("db.client.connections.usage|used", 0); + Assert.Equal(3, Server.ActiveConnections); + } + + [Fact(Skip = MetricsSkip)] + public void UnnamedDataSource() + { + var csb = CreateConnectionStringBuilder(); + + // NOTE: pool "name" is connection string (without password) + PoolName = csb.GetConnectionString(includePassword: false); + + using var dataSource = new MySqlDataSourceBuilder(csb.ConnectionString) + .Build(); + + // no connections at beginning of test + AssertMeasurement("db.client.connections.usage", 0); + AssertMeasurement("db.client.connections.usage|idle", 0); + AssertMeasurement("db.client.connections.usage|used", 0); + Assert.Equal(0, Server.ActiveConnections); + + // opening a connection creates a 'used' connection + using (var connection = dataSource.OpenConnection()) + { + AssertMeasurement("db.client.connections.usage", 1); + AssertMeasurement("db.client.connections.usage|idle", 0); + AssertMeasurement("db.client.connections.usage|used", 1); + Assert.Equal(1, Server.ActiveConnections); + } + + // closing it creates an 'idle' connection + AssertMeasurement("db.client.connections.usage", 1); + AssertMeasurement("db.client.connections.usage|idle", 1); + AssertMeasurement("db.client.connections.usage|used", 0); + Assert.Equal(1, Server.ActiveConnections); + + // reopening the connection transitions it back to 'used' + using (var connection = dataSource.OpenConnection()) + { + AssertMeasurement("db.client.connections.usage", 1); + AssertMeasurement("db.client.connections.usage|idle", 0); + AssertMeasurement("db.client.connections.usage|used", 1); + } + Assert.Equal(1, Server.ActiveConnections); + + // opening a second connection creates a net new 'used' connection + using (var connection = dataSource.OpenConnection()) + using (var connection2 = dataSource.OpenConnection()) + { + AssertMeasurement("db.client.connections.usage", 2); + AssertMeasurement("db.client.connections.usage|idle", 0); + AssertMeasurement("db.client.connections.usage|used", 2); + Assert.Equal(2, Server.ActiveConnections); + } + + AssertMeasurement("db.client.connections.usage", 2); + AssertMeasurement("db.client.connections.usage|idle", 2); + AssertMeasurement("db.client.connections.usage|used", 0); + Assert.Equal(2, Server.ActiveConnections); + } + + [Fact(Skip = MetricsSkip)] + public void NoDataSource() + { + var csb = CreateConnectionStringBuilder(); + + // NOTE: pool "name" is connection string (without password) + PoolName = csb.GetConnectionString(includePassword: false); + + // no connections at beginning of test + AssertMeasurement("db.client.connections.usage", 0); + AssertMeasurement("db.client.connections.usage|idle", 0); + AssertMeasurement("db.client.connections.usage|used", 0); + Assert.Equal(0, Server.ActiveConnections); + + // opening a connection creates a 'used' connection + using (var connection = new MySqlConnection(csb.ConnectionString)) + { + connection.Open(); + AssertMeasurement("db.client.connections.usage", 1); + AssertMeasurement("db.client.connections.usage|idle", 0); + AssertMeasurement("db.client.connections.usage|used", 1); + Assert.Equal(1, Server.ActiveConnections); + } + + // closing it creates an 'idle' connection + AssertMeasurement("db.client.connections.usage", 1); + AssertMeasurement("db.client.connections.usage|idle", 1); + AssertMeasurement("db.client.connections.usage|used", 0); + Assert.Equal(1, Server.ActiveConnections); + + // reopening the connection transitions it back to 'used' + using (var connection = new MySqlConnection(csb.ConnectionString)) + { + connection.Open(); + AssertMeasurement("db.client.connections.usage", 1); + AssertMeasurement("db.client.connections.usage|idle", 0); + AssertMeasurement("db.client.connections.usage|used", 1); + } + Assert.Equal(1, Server.ActiveConnections); + + // opening a second connection creates a net new 'used' connection + using (var connection = new MySqlConnection(csb.ConnectionString)) + using (var connection2 = new MySqlConnection(csb.ConnectionString)) + { + connection.Open(); + connection2.Open(); + AssertMeasurement("db.client.connections.usage", 2); + AssertMeasurement("db.client.connections.usage|idle", 0); + AssertMeasurement("db.client.connections.usage|used", 2); + Assert.Equal(2, Server.ActiveConnections); + } + + AssertMeasurement("db.client.connections.usage", 2); + AssertMeasurement("db.client.connections.usage|idle", 2); + AssertMeasurement("db.client.connections.usage|used", 0); + Assert.Equal(2, Server.ActiveConnections); + } + + [Fact(Skip = MetricsSkip)] + public async Task PendingRequestForCreation() + { + var csb = CreateConnectionStringBuilder(); + PoolName = csb.GetConnectionString(includePassword: false); + Server.ConnectDelay = TimeSpan.FromSeconds(0.5); + + AssertMeasurement("db.client.connections.pending_requests", 0); + + using var connection = new MySqlConnection(csb.ConnectionString); + var openTask = connection.OpenAsync(); + AssertMeasurement("db.client.connections.pending_requests", 1); + await openTask; + + AssertMeasurement("db.client.connections.pending_requests", 0); + } + + [Fact(Skip = MetricsSkip)] + public async Task PendingRequestForOpenFromPool() + { + var csb = CreateConnectionStringBuilder(); + PoolName = csb.GetConnectionString(includePassword: false); + Server.ResetDelay = TimeSpan.FromSeconds(0.5); + + using var connection = new MySqlConnection(csb.ConnectionString); + await connection.OpenAsync(); + connection.Close(); + + AssertMeasurement("db.client.connections.pending_requests", 0); + + var openTask = connection.OpenAsync(); + AssertMeasurement("db.client.connections.pending_requests", 1); + await openTask; + + AssertMeasurement("db.client.connections.pending_requests", 0); + } +} diff --git a/tests/MySqlConnector.Tests/Metrics/DictionaryExtensions.cs b/tests/MySqlConnector.Tests/Metrics/DictionaryExtensions.cs new file mode 100644 index 000000000..36dc93d74 --- /dev/null +++ b/tests/MySqlConnector.Tests/Metrics/DictionaryExtensions.cs @@ -0,0 +1,12 @@ +#nullable enable + +namespace MySqlConnector.Tests.Metrics; + +#if !NETSTANDARD2_1 && !NETCOREAPP3_1_OR_GREATER +internal static class DictionaryExtensions +{ + public static TValue GetValueOrDefault(this IDictionary dictionary, TKey key) + where TValue : struct => + dictionary.TryGetValue(key, out var value) ? value : default; +} +#endif diff --git a/tests/MySqlConnector.Tests/Metrics/MetricsTestsBase.cs b/tests/MySqlConnector.Tests/Metrics/MetricsTestsBase.cs new file mode 100644 index 000000000..6751b7477 --- /dev/null +++ b/tests/MySqlConnector.Tests/Metrics/MetricsTestsBase.cs @@ -0,0 +1,131 @@ +#nullable enable + +using System.Diagnostics.Metrics; + +namespace MySqlConnector.Tests.Metrics; + +public abstract class MetricsTestsBase : IDisposable +{ + public MetricsTestsBase() + { + m_measurements = new(); + m_timeMeasurements = new(); + + Server = new FakeMySqlServer(); + Server.Start(); + + m_meterListener = new MeterListener + { + InstrumentPublished = (instrument, listener) => + { + if (instrument.Meter.Name == "MySqlConnector") + listener.EnableMeasurementEvents(instrument); + } + }; + m_meterListener.SetMeasurementEventCallback(OnMeasurementRecorded); + m_meterListener.SetMeasurementEventCallback(OnMeasurementRecorded); + m_meterListener.Start(); + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + m_meterListener.Dispose(); + Server.Stop(); + } + } + + protected string? PoolName { get; set; } + protected FakeMySqlServer Server { get; } + + protected MySqlConnectionStringBuilder CreateConnectionStringBuilder() => + new MySqlConnectionStringBuilder + { + Server = "localhost", + Port = (uint) Server.Port, + UserID = "test", + Password = "test", + }; + + protected void AssertMeasurement(string name, int expected) + { + // clear cached measurements from observable counters + lock (m_measurements) + { + m_measurements.Remove("db.client.connections.idle.max"); + m_measurements.Remove("db.client.connections.idle.min"); + m_measurements.Remove("db.client.connections.max"); + } + m_meterListener.RecordObservableInstruments(); + lock (m_measurements) + Assert.Equal(expected, m_measurements.GetValueOrDefault(name)); + } + + protected List GetAndClearMeasurements(string name) + { + if (!m_timeMeasurements.TryGetValue(name, out var list)) + list = new List(); + m_timeMeasurements[name] = new List(); + return list; + } + +#if NO_METRICS_TESTS + protected const string MetricsSkip = "Metrics tests are skipped"; +#else + protected const string MetricsSkip = null; +#endif + + private void OnMeasurementRecorded(Instrument instrument, int measurement, ReadOnlySpan> tags, object? state) + { + var (poolName, stateTag) = GetTags(tags); + if (poolName != PoolName) + return; + + lock (m_measurements) + { + m_measurements[instrument.Name] = m_measurements.GetValueOrDefault(instrument.Name) + measurement; + if (stateTag.Length != 0) + m_measurements[$"{instrument.Name}|{stateTag}"] = m_measurements.GetValueOrDefault($"{instrument.Name}|{stateTag}") + measurement; + } + } + + private void OnMeasurementRecorded(Instrument instrument, float measurement, ReadOnlySpan> tags, object? state) + { + var (poolName, stateTag) = GetTags(tags); + if (poolName != PoolName) + return; + + lock (m_timeMeasurements) + { + if (!m_timeMeasurements.TryGetValue(instrument.Name, out var list)) + list = m_timeMeasurements[instrument.Name] = new List(); + list.Add(measurement); + } + } + + private (string PoolName, string State) GetTags(ReadOnlySpan> tags) + { + var poolName = ""; + var state = ""; + foreach (var tag in tags) + { + if (tag.Key == "pool.name" && tag.Value is string s1) + poolName = s1; + else if (tag.Key == "state" && tag.Value is string s2) + state = s2; + } + return (poolName, state); + } + + + private readonly Dictionary m_measurements; + private readonly Dictionary> m_timeMeasurements; + private readonly MeterListener m_meterListener; +} diff --git a/tests/MySqlConnector.Tests/Metrics/MinMaxConnectionsTests.cs b/tests/MySqlConnector.Tests/Metrics/MinMaxConnectionsTests.cs new file mode 100644 index 000000000..a5a4e658a --- /dev/null +++ b/tests/MySqlConnector.Tests/Metrics/MinMaxConnectionsTests.cs @@ -0,0 +1,105 @@ +#nullable enable + +namespace MySqlConnector.Tests.Metrics; + +public class MinMaxConnectionsTests : MetricsTestsBase +{ + [Fact(Skip = MetricsSkip)] + public void SetsMinimumIdleToDefault() + { + var csb = CreateConnectionStringBuilder(); + + PoolName = "min-idle"; + using var dataSource = new MySqlDataSourceBuilder(csb.ConnectionString) + .UseName(PoolName) + .Build(); + using var connection = dataSource.OpenConnection(); + + AssertMeasurement("db.client.connections.idle.min", 0); + } + + [Fact(Skip = MetricsSkip)] + public void SetsMaximumIdleToDefault() + { + var csb = CreateConnectionStringBuilder(); + + PoolName = "max-idle"; + using var dataSource = new MySqlDataSourceBuilder(csb.ConnectionString) + .UseName(PoolName) + .Build(); + using var connection = dataSource.OpenConnection(); + + AssertMeasurement("db.client.connections.idle.max", 100); + } + + [Fact(Skip = MetricsSkip)] + public void SetsMaximumToDefault() + { + var csb = CreateConnectionStringBuilder(); + + PoolName = "max"; + using var dataSource = new MySqlDataSourceBuilder(csb.ConnectionString) + .UseName(PoolName) + .Build(); + using var connection = dataSource.OpenConnection(); + + AssertMeasurement("db.client.connections.max", 100); + } + + [Fact(Skip = MetricsSkip)] + public void SetsMinimumIdleToCustom() + { + var csb = CreateConnectionStringBuilder(); + csb.MinimumPoolSize = 3; + + PoolName = "min-idle"; + using (var dataSource = new MySqlDataSourceBuilder(csb.ConnectionString) + .UseName(PoolName) + .Build()) + { + using var connection = dataSource.OpenConnection(); + + AssertMeasurement("db.client.connections.idle.min", 3); + } + + AssertMeasurement("db.client.connections.idle.min", 0); + } + + [Fact(Skip = MetricsSkip)] + public void SetsMaximumIdleToCustom() + { + var csb = CreateConnectionStringBuilder(); + csb.MaximumPoolSize = 57; + + PoolName = "max-idle"; + using (var dataSource = new MySqlDataSourceBuilder(csb.ConnectionString) + .UseName(PoolName) + .Build()) + { + using var connection = dataSource.OpenConnection(); + + AssertMeasurement("db.client.connections.idle.max", 57); + } + + AssertMeasurement("db.client.connections.idle.max", 0); + } + + [Fact(Skip = MetricsSkip)] + public void SetsMaximumToCustom() + { + var csb = CreateConnectionStringBuilder(); + csb.MaximumPoolSize = 99; + + PoolName = "max"; + using (var dataSource = new MySqlDataSourceBuilder(csb.ConnectionString) + .UseName(PoolName) + .Build()) + { + using var connection = dataSource.OpenConnection(); + + AssertMeasurement("db.client.connections.max", 99); + } + + AssertMeasurement("db.client.connections.max", 0); + } +} diff --git a/tests/MySqlConnector.Tests/MySqlConnector.Tests.csproj b/tests/MySqlConnector.Tests/MySqlConnector.Tests.csproj index 419961696..473c72caa 100644 --- a/tests/MySqlConnector.Tests/MySqlConnector.Tests.csproj +++ b/tests/MySqlConnector.Tests/MySqlConnector.Tests.csproj @@ -6,7 +6,7 @@ net472 - MYSQL_DATA + $(DefineConstants);MYSQL_DATA $(NoWarn);MSB3246 @@ -18,6 +18,10 @@ 11.0 + + $(DefineConstants);NO_METRICS_TESTS + + @@ -44,6 +48,7 @@ +