diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs index b83434cac6..4980184c9f 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs @@ -23,10 +23,10 @@ namespace Microsoft.Data.SqlClient.ConnectionPool /// /// A connection pool implementation based on the channel data structure. /// Provides methods to manage the pool of connections, including acquiring and releasing connections. - /// + /// /// This implementation uses for managing idle connections, /// which offers several advantages over the traditional WaitHandleDbConnectionPool: - /// + /// /// /// /// Better async performance: Channels provide native async/await support without blocking @@ -45,7 +45,7 @@ namespace Microsoft.Data.SqlClient.ConnectionPool /// the potential for race conditions in connection lifecycle management. /// /// - /// + /// /// The trade-off is slightly higher memory overhead per pool instance due to the channel infrastructure, /// but this is generally offset by the performance benefits in async-heavy workloads. /// @@ -92,6 +92,62 @@ internal sealed class ChannelDbConnectionPool : IDbConnectionPool /// Must be updated using operations to ensure thread safety. /// private volatile int _isClearing; + + #region Pruning fields + /// + /// Default pruning interval in seconds. Used until a connection string keyword is added. + /// + private const int DefaultPruningIntervalSeconds = 10; + + /// + /// Default lifetime window in seconds used for sample size calculation when + /// LoadBalanceTimeout (Connection Lifetime) is zero. + /// + private const int DefaultLifetimeWindowSeconds = 300; + + /// + /// Maximum allowed sample buffer size to prevent excessive memory allocation + /// from very large LoadBalanceTimeout values. + /// + private const int MaxPruningSampleSize = 300; + + /// + /// One-shot timer that triggers pruning evaluation. Re-armed at the end of each callback. + /// + private readonly Timer? _pruningTimer; + + /// + /// The interval between pruning samples/evaluations. + /// + private readonly TimeSpan _pruningSamplingInterval = TimeSpan.Zero; + + /// + /// Number of idle count samples to collect before computing the median and pruning. + /// Equals ConnectionLifetime / PruningInterval (rounded up), clamped to . + /// + private readonly int _pruningSampleSize = 0; + + /// + /// Buffer of idle count snapshots, one recorded per timer tick. + /// Sorted in-place when full to compute the median, then reset for the next window. + /// + private readonly int[] _pruningSamples = Array.Empty(); + + /// + /// The 0-based index into the sorted array that represents the median. + /// + private readonly int _pruningMedianIndex = 0; + + /// + /// Whether the pruning timer is currently armed and firing. + /// + private volatile bool _pruningTimerEnabled; + + /// + /// Current write position in the buffer. + /// + private int _pruningSampleIndex; + #endregion #endregion /// @@ -115,13 +171,39 @@ internal ChannelDbConnectionPool( _connectionSlots = new(MaxPoolSize); _idleChannel = new(); + // Pruning is only useful when the pool can grow beyond MinPoolSize. + // If min >= max, the pool is fixed-size and pruning would never activate. + if (MinPoolSize < MaxPoolSize) + { + _pruningSamplingInterval = TimeSpan.FromSeconds(DefaultPruningIntervalSeconds); + + var lifetimeSeconds = (int)PoolGroupOptions.LoadBalanceTimeout.TotalSeconds; + if (lifetimeSeconds <= 0) + { + lifetimeSeconds = DefaultLifetimeWindowSeconds; + } + + _pruningSampleSize = Math.Min( + DivideRoundingUp(lifetimeSeconds, DefaultPruningIntervalSeconds), + MaxPruningSampleSize); + _pruningMedianIndex = DivideRoundingUp(_pruningSampleSize, 2) - 1; + _pruningSamples = new int[_pruningSampleSize]; + + // Suppress ExecutionContext flow to avoid capturing AsyncLocals onto the timer, + // which would keep them alive for the lifetime of the pool. + using (ExecutionContext.SuppressFlow()) + { + _pruningTimer = new Timer(PruneIdleConnections, this, Timeout.Infinite, Timeout.Infinite); + } + } + State = Running; } #region Properties /// public ConcurrentDictionary< - DbConnectionPoolAuthenticationContextKey, + DbConnectionPoolAuthenticationContextKey, DbConnectionPoolAuthenticationContext> AuthenticationContexts { get; } /// @@ -168,6 +250,8 @@ public ConcurrentDictionary< public bool UseLoadBalancing => PoolGroupOptions.UseLoadBalancing; private uint MaxPoolSize { get; } + + private int MinPoolSize => PoolGroupOptions.MinPoolSize; #endregion #region Methods @@ -223,7 +307,7 @@ public void PutObjectFromTransactedPool(DbConnectionInternal connection) /// public DbConnectionInternal ReplaceConnection( - DbConnection owningObject, + DbConnection owningObject, DbConnectionInternal oldConnection) { throw new NotImplementedException(); @@ -241,13 +325,13 @@ public void ReturnInternalConnection(DbConnectionInternal connection, DbConnecti } SqlClientEventSource.Log.TryPoolerTraceEvent( - " {0}, Connection {1}, Deactivating.", - Id, + " {0}, Connection {1}, Deactivating.", + Id, connection.ObjectID); connection.DeactivateConnection(); - if (connection.IsConnectionDoomed || - !connection.CanBePooled || + if (connection.IsConnectionDoomed || + !connection.CanBePooled || State == ShuttingDown) { RemoveConnection(connection); @@ -262,7 +346,15 @@ public void ReturnInternalConnection(DbConnectionInternal connection, DbConnecti /// public void Shutdown() { - // No-op for now, warmup will be implemented later. + State = ShuttingDown; + if (_pruningTimer is not null) + { + lock (_pruningTimer) + { + _pruningTimerEnabled = false; + _pruningTimer.Dispose(); + } + } } /// @@ -279,7 +371,7 @@ public void TransactionEnded(Transaction transaction, DbConnectionInternal trans /// public bool TryGetConnection( - DbConnection owningObject, + DbConnection owningObject, TaskCompletionSource? taskCompletionSource, out DbConnectionInternal? connection) { @@ -307,19 +399,19 @@ public bool TryGetConnection( connection = null; return false; } - + // This is ugly, but async anti-patterns above and below us in the stack necessitate a fresh task to be - // created. Ideally we would just return the Task from GetInternalConnection and let the caller await + // created. Ideally we would just return the Task from GetInternalConnection and let the caller await // it as needed, but instead we need to signal to the provided TaskCompletionSource when the connection - // is established. This pattern has implications for connection open retry logic that are intricate - // enough to merit dedicated work. For now, callers that need to open many connections asynchronously - // and in parallel *must* pre-prevision threads in the managed thread pool to avoid exhaustion and + // is established. This pattern has implications for connection open retry logic that are intricate + // enough to merit dedicated work. For now, callers that need to open many connections asynchronously + // and in parallel *must* pre-prevision threads in the managed thread pool to avoid exhaustion and // timeouts. - // - // Also note that we don't have access to the cancellation token passed by the caller to the original + // + // Also note that we don't have access to the cancellation token passed by the caller to the original // OpenAsync call. This means that we cannot cancel the connection open operation if the caller's token - // is cancelled. We can only cancel based on our own timeout, which is set to the owningObject's - // ConnectionTimeout. + // is cancelled. We can only cancel based on our own timeout, which is set to the owningObject's + // ConnectionTimeout. Task.Run(async () => { if (taskCompletionSource.Task.IsCompleted) @@ -377,7 +469,7 @@ public bool TryGetConnection( /// Thrown when the cancellation token is cancelled before the connection operation completes. /// private DbConnectionInternal? OpenNewInternalConnection( - DbConnection? owningConnection, + DbConnection? owningConnection, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -386,16 +478,16 @@ public bool TryGetConnection( // Instead, we reserve a connection slot prior to attempting to open a new connection and release the slot // in case of an exception. - return _connectionSlots.Add( + var result = _connectionSlots.Add( createCallback: () => { // https://github.com/dotnet/SqlClient/issues/3459 // TODO: This blocks the thread for several network calls! - // When running async, the blocked thread is one allocated from the managed thread pool (due to - // use of Task.Run in TryGetConnection). This is why it's critical for async callers to - // pre-provision threads in the managed thread pool. Our options are limited because + // When running async, the blocked thread is one allocated from the managed thread pool (due to + // use of Task.Run in TryGetConnection). This is why it's critical for async callers to + // pre-provision threads in the managed thread pool. Our options are limited because // DbConnectionInternal doesn't support an async open. It's better to block this thread and keep - // throughput high than to queue all of our opens onto a single worker thread. Add an async path + // throughput high than to queue all of our opens onto a single worker thread. Add an async path // when this support is added to DbConnectionInternal. var connection = ConnectionFactory.CreatePooledConnection( owningConnection, @@ -415,6 +507,15 @@ public bool TryGetConnection( _idleChannel?.TryWrite(null); newConnection?.Dispose(); }); + + if (result is not null) + { + // A new connection was added to the pool. If we've grown past MinPoolSize, + // start the pruning timer so idle connections can be reclaimed. + UpdatePruningTimer(); + } + + return result; } /// @@ -452,13 +553,16 @@ private bool IsLiveConnection(DbConnectionInternal connection) private void RemoveConnection(DbConnectionInternal connection) { _connectionSlots.TryRemove(connection); - + // Removing a connection from the pool opens a free slot. // Write a null to the idle connection channel to wake up a waiter, who can now open a new // connection. Statement order is important since we have synchronous completions on the channel. _idleChannel.TryWrite(null); connection.Dispose(); + + // If this removal brought us back to MinPoolSize, disable the pruning timer. + UpdatePruningTimer(); } /// @@ -470,7 +574,7 @@ private void RemoveConnection(DbConnectionInternal connection) // The channel may contain nulls. Read until we find a non-null connection or exhaust the channel. while (_idleChannel.TryRead(out DbConnectionInternal? connection)) { - if (connection is null) + if (connection is null) { continue; } @@ -495,16 +599,16 @@ private void RemoveConnection(DbConnectionInternal connection) /// The timeout for the operation. /// Returns a DbConnectionInternal that is retrieved from the pool. /// - /// Thrown when an OperationCanceledException is caught, indicating that the timeout period + /// Thrown when an OperationCanceledException is caught, indicating that the timeout period /// elapsed prior to obtaining a connection from the pool. /// /// - /// Thrown when a ChannelClosedException is caught, indicating that the connection pool + /// Thrown when a ChannelClosedException is caught, indicating that the connection pool /// has been shut down. /// private async Task GetInternalConnection( - DbConnection owningConnection, - bool async, + DbConnection owningConnection, + bool async, TimeSpan timeout) { DbConnectionInternal? connection = null; @@ -521,7 +625,7 @@ private async Task GetInternalConnection( connection ??= GetIdleConnection(); - // If we didn't find an idle connection, try to open a new one. + // If we didn't find an idle connection, try to open a new one. connection ??= OpenNewInternalConnection( owningConnection, cancellationToken); @@ -594,18 +698,18 @@ private async Task GetInternalConnection( } /// - /// Sets connection state and activates the connection for use. Should always be called after a connection is + /// Sets connection state and activates the connection for use. Should always be called after a connection is /// created or retrieved from the pool. /// /// The owning DbConnection instance. /// The DbConnectionInternal to be activated. /// - /// Thrown when any exception occurs during connection activation. + /// Thrown when any exception occurs during connection activation. /// private void PrepareConnection(DbConnection owningObject, DbConnectionInternal connection) { lock (connection) - { + { // Protect against Clear which calls IsEmancipated, which is affected by PrePush and PostPop connection.PostPop(owningObject); } @@ -643,5 +747,100 @@ private void ValidateOwnershipAndSetPoolingState(DbConnectionInternal connection } } #endregion + + #region Pruning + /// + /// Enables or disables the pruning timer based on the current pool size relative to MinPoolSize. + /// Called after connections are opened or closed. + /// + private void UpdatePruningTimer() + { + if (_pruningTimer is null || !IsRunning) + { + return; + } + + lock (_pruningTimer) + { + // Re-check after acquiring lock — Shutdown() may have disposed the timer. + if (!IsRunning) + { + return; + } + + int numConnections = _connectionSlots.ReservationCount; + + if (numConnections > MinPoolSize && !_pruningTimerEnabled) + { + // Pool grew beyond min — start collecting samples + _pruningTimerEnabled = true; + _pruningTimer.Change(_pruningSamplingInterval, Timeout.InfiniteTimeSpan); + } + else if (numConnections <= MinPoolSize && _pruningTimerEnabled) + { + // Pool shrunk back to min — stop pruning, reset sample buffer + _pruningTimer.Change(Timeout.Infinite, Timeout.Infinite); + _pruningSampleIndex = 0; + _pruningTimerEnabled = false; + } + } + } + + /// + /// Timer callback that samples the idle count and, once enough samples are collected, + /// prunes idle connections based on the median of recent samples. + /// + private static void PruneIdleConnections(object? state) + { + var pool = (ChannelDbConnectionPool)state!; + int[] samples = pool._pruningSamples; + int toPrune; + + lock (pool._pruningTimer!) + { + // Guard against races with Shutdown or UpdatePruningTimer disabling the timer. + if (!pool._pruningTimerEnabled) + { + return; + } + + int sampleIndex = pool._pruningSampleIndex; + + // Record the current idle count as a sample. + samples[sampleIndex] = pool._idleChannel.Count; + + if (sampleIndex != pool._pruningSampleSize - 1) + { + // Buffer not full yet — keep collecting, re-arm timer. + pool._pruningSampleIndex = sampleIndex + 1; + pool._pruningTimer!.Change(pool._pruningSamplingInterval, Timeout.InfiniteTimeSpan); + return; + } + + // Buffer full — compute median, reset, and re-arm. + Array.Sort(samples); + toPrune = samples[pool._pruningMedianIndex]; + pool._pruningSampleIndex = 0; + pool._pruningTimer!.Change(pool._pruningSamplingInterval, Timeout.InfiniteTimeSpan); + } + + // Prune outside the lock to avoid holding it during I/O. + while (toPrune > 0 + && pool.IsRunning + && pool._connectionSlots.ReservationCount > pool.MinPoolSize + && pool._idleChannel.TryRead(out var connection)) + { + if (connection is null) + { + continue; + } + + pool.RemoveConnection(connection); + toPrune--; + } + } + + private static int DivideRoundingUp(int value, int divisor) => 1 + (value - 1) / divisor; + #endregion } } diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolPruningTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolPruningTest.cs new file mode 100644 index 0000000000..1f23caba02 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolPruningTest.cs @@ -0,0 +1,531 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Data.Common; +using System.Reflection; +using System.Threading; +using System.Transactions; +using Microsoft.Data.Common; +using Microsoft.Data.Common.ConnectionString; +using Microsoft.Data.ProviderBase; +using Microsoft.Data.SqlClient.ConnectionPool; +using Xunit; +using static Microsoft.Data.SqlClient.ConnectionPool.DbConnectionPoolState; + +namespace Microsoft.Data.SqlClient.UnitTests.ConnectionPool +{ + /// + /// Unit tests for the pruning feature in . + /// Validates acceptance scenarios from User Story 1 (Automatic Pool Size Reduction During Low Demand). + /// + public class ChannelDbConnectionPoolPruningTest + { + private static readonly SqlConnectionFactory ConnectionFactory = new SuccessfulSqlConnectionFactory(); + + #region Helpers + + private ChannelDbConnectionPool ConstructPool( + int minPoolSize = 0, + int maxPoolSize = 50, + int loadBalanceTimeout = 0) + { + var poolGroupOptions = new DbConnectionPoolGroupOptions( + poolByIdentity: false, + minPoolSize: minPoolSize, + maxPoolSize: maxPoolSize, + creationTimeout: 15, + loadBalanceTimeout: loadBalanceTimeout, + hasTransactionAffinity: true + ); + var dbConnectionPoolGroup = new DbConnectionPoolGroup( + new SqlConnectionOptions("Data Source=localhost;"), + new ConnectionPoolKey("TestDataSource", credential: null, accessToken: null, accessTokenCallback: null, sspiContextProvider: null), + poolGroupOptions + ); + return new ChannelDbConnectionPool( + ConnectionFactory, + dbConnectionPoolGroup, + DbConnectionPoolIdentity.NoIdentity, + new DbConnectionPoolProviderInfo() + ); + } + + /// + /// Opens connections and returns them to the pool so they are idle. + /// + private void FillPoolWithIdleConnections(ChannelDbConnectionPool pool, int count) + { + var connections = new DbConnectionInternal?[count]; + var owners = new SqlConnection[count]; + + for (int i = 0; i < count; i++) + { + owners[i] = new SqlConnection(); + pool.TryGetConnection(owners[i], null, out connections[i]); + } + + for (int i = 0; i < count; i++) + { + pool.ReturnInternalConnection(connections[i]!, owners[i]); + } + } + + private static Timer? GetPruningTimer(ChannelDbConnectionPool pool) + { + return (Timer?)typeof(ChannelDbConnectionPool) + .GetField("_pruningTimer", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(pool); + } + + private static bool GetPruningTimerEnabled(ChannelDbConnectionPool pool) + { + return (bool)typeof(ChannelDbConnectionPool) + .GetField("_pruningTimerEnabled", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(pool)!; + } + + private static int GetPruningSampleIndex(ChannelDbConnectionPool pool) + { + return (int)typeof(ChannelDbConnectionPool) + .GetField("_pruningSampleIndex", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(pool)!; + } + + private static int GetPruningSampleSize(ChannelDbConnectionPool pool) + { + return (int)typeof(ChannelDbConnectionPool) + .GetField("_pruningSampleSize", BindingFlags.NonPublic | BindingFlags.Instance)! + .GetValue(pool)!; + } + + private static void InvokePruneIdleConnections(ChannelDbConnectionPool pool) + { + typeof(ChannelDbConnectionPool) + .GetMethod("PruneIdleConnections", BindingFlags.NonPublic | BindingFlags.Static)! + .Invoke(null, new object[] { pool }); + } + + private static void InvokeUpdatePruningTimer(ChannelDbConnectionPool pool) + { + typeof(ChannelDbConnectionPool) + .GetMethod("UpdatePruningTimer", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(pool, null); + } + + #endregion + + #region Timer Creation / Configuration Tests + + [Fact] + public void Constructor_MinPoolSizeLessThanMax_CreatesPruningTimer() + { + // When min < max, the pool can shrink so a pruning timer should be created. + var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 10); + + Assert.NotNull(GetPruningTimer(pool)); + } + + [Fact] + public void Constructor_MinPoolSizeEqualsMax_DoesNotCreatePruningTimer() + { + // When min == max, the pool is fixed-size — pruning would never activate. + var pool = ConstructPool(minPoolSize: 10, maxPoolSize: 10); + + Assert.Null(GetPruningTimer(pool)); + } + + [Fact] + public void Constructor_PruningTimerStartsDisabled() + { + // Timer should be created but not armed (pool starts empty, below MinPoolSize threshold). + var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 10); + + Assert.False(GetPruningTimerEnabled(pool)); + } + + [Theory] + [InlineData(100, 10)] // 100 / 10 = 10 samples + [InlineData(300, 30)] // 300 / 10 = 30 samples + [InlineData(60, 6)] // 60 / 10 = 6 samples + [InlineData(15, 2)] // 15 / 10 = 2 samples (rounds up) + public void Constructor_CalculatesSampleSizeFromLoadBalanceTimeout( + int loadBalanceTimeout, int expectedSampleSize) + { + var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 10, loadBalanceTimeout: loadBalanceTimeout); + + Assert.Equal(expectedSampleSize, GetPruningSampleSize(pool)); + } + + [Fact] + public void Constructor_ZeroLoadBalanceTimeout_UsesDefaultLifetimeWindow() + { + // When LoadBalanceTimeout is 0, use DefaultLifetimeWindowSeconds (300) / 10 = 30 samples + var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 10, loadBalanceTimeout: 0); + + Assert.Equal(30, GetPruningSampleSize(pool)); + } + + #endregion + + #region UpdatePruningTimer Tests + + [Fact] + public void UpdatePruningTimer_PoolGrowsBeyondMinPoolSize_EnablesTimer() + { + var pool = ConstructPool(minPoolSize: 2, maxPoolSize: 10); + + // Pool starts empty, timer should be disabled + Assert.False(GetPruningTimerEnabled(pool)); + + // Add connections to grow beyond MinPoolSize + FillPoolWithIdleConnections(pool, 3); + + // After growing beyond min, UpdatePruningTimer is called internally + // and should enable the timer. + Assert.True(GetPruningTimerEnabled(pool)); + } + + [Fact] + public void UpdatePruningTimer_PoolAtMinPoolSize_TimerRemainsDisabled() + { + var pool = ConstructPool(minPoolSize: 2, maxPoolSize: 10); + + // Add exactly MinPoolSize connections + FillPoolWithIdleConnections(pool, 2); + + // Timer should stay disabled since we're at (not above) MinPoolSize + Assert.False(GetPruningTimerEnabled(pool)); + } + + [Fact] + public void UpdatePruningTimer_PoolShrinksBackToMin_DisablesTimerAndResetsSamples() + { + var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 10); + + // Grow the pool to enable the timer + FillPoolWithIdleConnections(pool, 3); + Assert.True(GetPruningTimerEnabled(pool)); + + // Now prune all connections back to 0 (MinPoolSize) + // Simulate by calling PruneIdleConnections enough times to fill the sample buffer + // and then let it prune. + int sampleSize = GetPruningSampleSize(pool); + for (int i = 0; i < sampleSize; i++) + { + InvokePruneIdleConnections(pool); + } + + // After pruning removed connections back to MinPoolSize, UpdatePruningTimer + // should have disabled the timer. + Assert.False(GetPruningTimerEnabled(pool)); + Assert.Equal(0, GetPruningSampleIndex(pool)); + } + + [Fact] + public void UpdatePruningTimer_FixedSizePool_NoOp() + { + // min == max means _pruningTimer is null + var pool = ConstructPool(minPoolSize: 5, maxPoolSize: 5); + + // UpdatePruningTimer should return without error (null timer guard) + InvokeUpdatePruningTimer(pool); + + Assert.Null(GetPruningTimer(pool)); + } + + #endregion + + #region PruneIdleConnections Tests + + [Fact] + public void PruneIdleConnections_BufferNotFull_CollectsSampleWithoutPruning() + { + var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 10, loadBalanceTimeout: 30); + // loadBalanceTimeout=30 → sample size = 30/10 = 3 + + // Fill the pool so pruning timer is active + FillPoolWithIdleConnections(pool, 5); + int initialCount = pool.Count; + + // First invocation: records idle count in sample[0], buffer not full + InvokePruneIdleConnections(pool); + + // No connections should be pruned yet + Assert.Equal(initialCount, pool.Count); + Assert.Equal(1, GetPruningSampleIndex(pool)); + } + + [Fact] + public void PruneIdleConnections_RespectsMinPoolSizeFloor() + { + // loadBalanceTimeout=20 → 2 samples + var pool = ConstructPool(minPoolSize: 5, maxPoolSize: 20, loadBalanceTimeout: 20); + + // Fill pool with 10 idle connections + FillPoolWithIdleConnections(pool, 10); + Assert.Equal(10, pool.Count); + + // Fill sample buffer and trigger pruning + InvokePruneIdleConnections(pool); // sample 1 + InvokePruneIdleConnections(pool); // sample 2 → prune + + // Pool should not drop below MinPoolSize (5) + Assert.True(pool.Count >= 5, $"Pool count {pool.Count} dropped below MinPoolSize 5"); + } + + [Fact] + public void PruneIdleConnections_TimerDisabled_ReturnsEarlyWithoutPruning() + { + var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 10, loadBalanceTimeout: 20); + + // Pool starts empty, timer is disabled. Calling prune should be a no-op. + InvokePruneIdleConnections(pool); + + Assert.Equal(0, pool.Count); + Assert.Equal(0, GetPruningSampleIndex(pool)); + } + + [Fact] + public void PruneIdleConnections_SampleBufferResetsAfterPruning() + { + // loadBalanceTimeout=20 → 2 samples + var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 20, loadBalanceTimeout: 20); + + // Fill pool to enable pruning timer + FillPoolWithIdleConnections(pool, 5); + + // Fill sample buffer and trigger pruning + InvokePruneIdleConnections(pool); // sample index → 1 + InvokePruneIdleConnections(pool); // buffer full, prune, reset index → 0 + + // After pruning, sample index should be reset to 0 + Assert.Equal(0, GetPruningSampleIndex(pool)); + } + + [Fact] + public void PruneIdleConnections_DoesNotRemoveInUseConnections() + { + // loadBalanceTimeout=20 → 2 samples + var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 20, loadBalanceTimeout: 20); + + // Get 5 connections and KEEP them checked out (in use) + var owners = new SqlConnection[5]; + var connections = new DbConnectionInternal?[5]; + for (int i = 0; i < 5; i++) + { + owners[i] = new SqlConnection(); + pool.TryGetConnection(owners[i], null, out connections[i]); + } + + // Also add 5 idle connections + FillPoolWithIdleConnections(pool, 5); + + // Total = 10 (5 in-use + 5 idle) + Assert.Equal(10, pool.Count); + + // Fill sample buffer and trigger pruning + InvokePruneIdleConnections(pool); // sample 1 (idle count = 5) + InvokePruneIdleConnections(pool); // sample 2 → prune + + // In-use connections must not be removed. Pool count >= 5 (the in-use ones). + Assert.True(pool.Count >= 5, $"In-use connections were pruned. Count: {pool.Count}"); + } + + #endregion + + #region Shutdown Tests + + [Fact] + public void Shutdown_DisposesPruningTimer() + { + var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 10); + var timer = GetPruningTimer(pool); + Assert.NotNull(timer); + + // Enable the timer by growing the pool beyond MinPoolSize + FillPoolWithIdleConnections(pool, 3); + Assert.True(GetPruningTimerEnabled(pool)); + + pool.Shutdown(); + + Assert.Equal(ShuttingDown, pool.State); + // After shutdown, the timer-enabled flag must be cleared. + Assert.False(GetPruningTimerEnabled(pool)); + } + + [Fact] + public void Shutdown_NullTimer_DoesNotThrow() + { + // Fixed-size pool has no timer + var pool = ConstructPool(minPoolSize: 5, maxPoolSize: 5); + Assert.Null(GetPruningTimer(pool)); + + // Should not throw + pool.Shutdown(); + Assert.Equal(ShuttingDown, pool.State); + } + + #endregion + + #region DivideRoundingUp Tests + + [Theory] + [InlineData(10, 10, 1)] + [InlineData(11, 10, 2)] + [InlineData(300, 10, 30)] + [InlineData(1, 1, 1)] + [InlineData(7, 3, 3)] // ceil(7/3) = 3 + [InlineData(6, 3, 2)] // exact division + [InlineData(15, 10, 2)] // ceil(15/10) = 2 + public void DivideRoundingUp_ReturnsCorrectCeiling(int value, int divisor, int expected) + { + var result = (int)typeof(ChannelDbConnectionPool) + .GetMethod("DivideRoundingUp", BindingFlags.NonPublic | BindingFlags.Static)! + .Invoke(null, new object[] { value, divisor })!; + + Assert.Equal(expected, result); + } + + #endregion + + #region Integration / Acceptance Scenario Tests + + [Fact] + public void AcceptanceScenario1_ExcessIdleConnectionsArePrunedToObservedUsage() + { + // Given: A pool with many idle connections and low recent usage. + // When: The pruning interval elapses (sample buffer fills). + // Then: The pool closes excess idle connections. + + // Use loadBalanceTimeout=20 for 2 samples (fast buffer fill) + var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 50, loadBalanceTimeout: 20); + + // Simulate high load: open 20 connections + FillPoolWithIdleConnections(pool, 20); + Assert.Equal(20, pool.Count); + + // Pruning samples will both record idle=20, so toPrune=20. + // But pruning loop is bounded by pool.Count > MinPoolSize (0). + InvokePruneIdleConnections(pool); // sample 1 + InvokePruneIdleConnections(pool); // sample 2 → prune + + // Pool should be reduced significantly + Assert.True(pool.Count < 20, $"Pool was not pruned. Count: {pool.Count}"); + } + + [Fact] + public void AcceptanceScenario2_PoolAtMinPoolSize_NoPruning() + { + // Given: A pool with connections equal to MinPoolSize. + // When: The pruning interval elapses. + // Then: No connections are pruned. + + var pool = ConstructPool(minPoolSize: 5, maxPoolSize: 20, loadBalanceTimeout: 20); + + // Fill exactly MinPoolSize idle connections + FillPoolWithIdleConnections(pool, 5); + Assert.Equal(5, pool.Count); + + // Timer is not enabled because pool hasn't grown beyond MinPoolSize + Assert.False(GetPruningTimerEnabled(pool)); + + // Even if we call prune directly, the guard (_pruningTimerEnabled=false) prevents action + InvokePruneIdleConnections(pool); + Assert.Equal(5, pool.Count); + } + + [Fact] + public void AcceptanceScenario3_HighRecentUsage_PruningUsesMedianNotJustCurrentIdle() + { + // Given: A pool with high recent usage but currently many idle connections due to a brief lull. + // When: The pruning interval elapses. + // Then: Pruning uses sampled usage data (median) to avoid being too aggressive. + + // Use 3 samples (loadBalanceTimeout=30) + var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 50, loadBalanceTimeout: 30); + + // Simulate varied usage: + // Sample 1: 2 idle (high demand, most connections checked out) + FillPoolWithIdleConnections(pool, 10); + // Check out 8, leaving 2 idle + var busyOwners = new SqlConnection[8]; + var busyConns = new DbConnectionInternal?[8]; + for (int i = 0; i < 8; i++) + { + busyOwners[i] = new SqlConnection(); + pool.TryGetConnection(busyOwners[i], null, out busyConns[i]); + } + InvokePruneIdleConnections(pool); // sample[0] = 2 idle + + // Sample 2: 2 idle (still high demand) + InvokePruneIdleConnections(pool); // sample[1] = 2 idle + + // Now return all busy connections, creating a brief lull (10 idle) + for (int i = 0; i < 8; i++) + { + pool.ReturnInternalConnection(busyConns[i]!, busyOwners[i]); + } + + // Sample 3: 10 idle (lull). Buffer full → sorted=[2,2,10], median at index 1 = 2. + int countBefore = pool.Count; + InvokePruneIdleConnections(pool); + + // Pruning should only remove 2 connections (the median), not 10 (the current idle count). + // This verifies that sampling prevents aggressive pruning during brief lulls. + int pruned = countBefore - pool.Count; + Assert.True(pruned <= 2, $"Pruning was too aggressive: pruned {pruned} connections, " + + $"expected at most 2 (median). Count before={countBefore}, after={pool.Count}"); + } + + #endregion + + #region Test classes + + internal class SuccessfulSqlConnectionFactory : SqlConnectionFactory + { + protected override DbConnectionInternal CreateConnection( + SqlConnectionOptions options, + ConnectionPoolKey poolKey, + DbConnectionPoolGroupProviderInfo poolGroupProviderInfo, + IDbConnectionPool pool, + DbConnection owningConnection) + { + return new StubDbConnectionInternal(); + } + } + + internal class StubDbConnectionInternal : DbConnectionInternal + { + public override string ServerVersion => throw new NotImplementedException(); + + public override DbTransaction BeginTransaction(System.Data.IsolationLevel il) + { + throw new NotImplementedException(); + } + + public override void EnlistTransaction(Transaction transaction) + { + return; + } + + protected override void Activate(Transaction transaction) + { + return; + } + + protected override void Deactivate() + { + return; + } + + internal override void ResetConnection() + { + return; + } + } + + #endregion + } +}