From 3301f6c3aee07a240475691427b4d6445051b433 Mon Sep 17 00:00:00 2001 From: Apoorv Deshmukh Date: Thu, 21 May 2026 21:43:17 +0530 Subject: [PATCH 1/5] Add idle connection pruning to ChannelDbConnectionPool --- .../ConnectionPool/ChannelDbConnectionPool.cs | 261 +++++++-- .../ChannelDbConnectionPoolPruningTest.cs | 531 ++++++++++++++++++ 2 files changed, 756 insertions(+), 36 deletions(-) create mode 100644 src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolPruningTest.cs 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..b25ef2da35 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,56 @@ 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; + + /// + /// 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; + + /// + /// Number of idle count samples to collect before computing the median and pruning. + /// Equals ConnectionLifetime / PruningInterval (rounded up). + /// + private readonly int _pruningSampleSize; + + /// + /// 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; + + /// + /// The 0-based index into the sorted array that represents the median. + /// + private readonly int _pruningMedianIndex; + + /// + /// 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 +165,41 @@ 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 = DivideRoundingUp(lifetimeSeconds, DefaultPruningIntervalSeconds); + _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); + } + } + else + { + _pruningSamples = Array.Empty(); + } + State = Running; } #region Properties /// public ConcurrentDictionary< - DbConnectionPoolAuthenticationContextKey, + DbConnectionPoolAuthenticationContextKey, DbConnectionPoolAuthenticationContext> AuthenticationContexts { get; } /// @@ -168,6 +246,8 @@ public ConcurrentDictionary< public bool UseLoadBalancing => PoolGroupOptions.UseLoadBalancing; private uint MaxPoolSize { get; } + + private int MinPoolSize => PoolGroupOptions.MinPoolSize; #endregion #region Methods @@ -223,7 +303,7 @@ public void PutObjectFromTransactedPool(DbConnectionInternal connection) /// public DbConnectionInternal ReplaceConnection( - DbConnection owningObject, + DbConnection owningObject, DbConnectionInternal oldConnection) { throw new NotImplementedException(); @@ -241,13 +321,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 +342,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 +367,7 @@ public void TransactionEnded(Transaction transaction, DbConnectionInternal trans /// public bool TryGetConnection( - DbConnection owningObject, + DbConnection owningObject, TaskCompletionSource? taskCompletionSource, out DbConnectionInternal? connection) { @@ -307,19 +395,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 +465,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 +474,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 +503,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 +549,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 +570,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 +595,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 +621,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 +694,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 +743,94 @@ 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) + { + 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 + } +} From 0652797d24c641d14c8a1e26951b2ad88d6d3030 Mon Sep 17 00:00:00 2001 From: Apoorv Deshmukh Date: Thu, 21 May 2026 22:10:04 +0530 Subject: [PATCH 2/5] Address comments --- .../ConnectionPool/ChannelDbConnectionPool.cs | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) 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 b25ef2da35..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 @@ -105,6 +105,12 @@ internal sealed class ChannelDbConnectionPool : IDbConnectionPool /// 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. /// @@ -113,24 +119,24 @@ internal sealed class ChannelDbConnectionPool : IDbConnectionPool /// /// The interval between pruning samples/evaluations. /// - private readonly TimeSpan _pruningSamplingInterval; + private readonly TimeSpan _pruningSamplingInterval = TimeSpan.Zero; /// /// Number of idle count samples to collect before computing the median and pruning. - /// Equals ConnectionLifetime / PruningInterval (rounded up). + /// Equals ConnectionLifetime / PruningInterval (rounded up), clamped to . /// - private readonly int _pruningSampleSize; + 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; + private readonly int[] _pruningSamples = Array.Empty(); /// /// The 0-based index into the sorted array that represents the median. /// - private readonly int _pruningMedianIndex; + private readonly int _pruningMedianIndex = 0; /// /// Whether the pruning timer is currently armed and firing. @@ -177,7 +183,9 @@ internal ChannelDbConnectionPool( lifetimeSeconds = DefaultLifetimeWindowSeconds; } - _pruningSampleSize = DivideRoundingUp(lifetimeSeconds, DefaultPruningIntervalSeconds); + _pruningSampleSize = Math.Min( + DivideRoundingUp(lifetimeSeconds, DefaultPruningIntervalSeconds), + MaxPruningSampleSize); _pruningMedianIndex = DivideRoundingUp(_pruningSampleSize, 2) - 1; _pruningSamples = new int[_pruningSampleSize]; @@ -188,10 +196,6 @@ internal ChannelDbConnectionPool( _pruningTimer = new Timer(PruneIdleConnections, this, Timeout.Infinite, Timeout.Infinite); } } - else - { - _pruningSamples = Array.Empty(); - } State = Running; } @@ -758,6 +762,12 @@ private void UpdatePruningTimer() lock (_pruningTimer) { + // Re-check after acquiring lock — Shutdown() may have disposed the timer. + if (!IsRunning) + { + return; + } + int numConnections = _connectionSlots.ReservationCount; if (numConnections > MinPoolSize && !_pruningTimerEnabled) From b979f64bd35d88bd452cc8d789e06fe1180adb45 Mon Sep 17 00:00:00 2001 From: Apoorv Deshmukh Date: Fri, 29 May 2026 22:30:44 +0530 Subject: [PATCH 3/5] Remove reflection from tests --- .../ConnectionPool/ChannelDbConnectionPool.cs | 20 ++- .../ChannelDbConnectionPoolPruningTest.cs | 149 ++++++++---------- 2 files changed, 81 insertions(+), 88 deletions(-) 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 4980184c9f..a6e174cace 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 @@ -148,6 +148,20 @@ internal sealed class ChannelDbConnectionPool : IDbConnectionPool /// private int _pruningSampleIndex; #endregion + + #region Internal test surface + /// Whether the pruning timer is currently armed. Exposed for unit tests. + internal bool IsPruningTimerEnabled => _pruningTimerEnabled; + + /// Current write position in the sample buffer. Exposed for unit tests. + internal int PruningSampleIndex => _pruningSampleIndex; + + /// Total number of samples collected per pruning window. Exposed for unit tests. + internal int PruningSampleSize => _pruningSampleSize; + + /// Whether a pruning timer was allocated (false for fixed-size pools). Exposed for unit tests. + internal bool HasPruningTimer => _pruningTimer != null; + #endregion #endregion /// @@ -753,7 +767,7 @@ private void ValidateOwnershipAndSetPoolingState(DbConnectionInternal connection /// 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() + internal void UpdatePruningTimer() { if (_pruningTimer is null || !IsRunning) { @@ -790,7 +804,7 @@ private void UpdatePruningTimer() /// 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) + internal static void PruneIdleConnections(object? state) { var pool = (ChannelDbConnectionPool)state!; int[] samples = pool._pruningSamples; @@ -840,7 +854,7 @@ private static void PruneIdleConnections(object? state) } } - private static int DivideRoundingUp(int value, int divisor) => 1 + (value - 1) / divisor; + internal 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 index 1f23caba02..b064d48852 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolPruningTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolPruningTest.cs @@ -4,8 +4,6 @@ using System; using System.Data.Common; -using System.Reflection; -using System.Threading; using System.Transactions; using Microsoft.Data.Common; using Microsoft.Data.Common.ConnectionString; @@ -72,48 +70,6 @@ private void FillPoolWithIdleConnections(ChannelDbConnectionPool pool, int count } } - 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 @@ -124,7 +80,7 @@ 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)); + Assert.True(pool.HasPruningTimer); } [Fact] @@ -133,7 +89,7 @@ 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)); + Assert.False(pool.HasPruningTimer); } [Fact] @@ -142,7 +98,7 @@ 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)); + Assert.False(pool.IsPruningTimerEnabled); } [Theory] @@ -155,7 +111,7 @@ public void Constructor_CalculatesSampleSizeFromLoadBalanceTimeout( { var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 10, loadBalanceTimeout: loadBalanceTimeout); - Assert.Equal(expectedSampleSize, GetPruningSampleSize(pool)); + Assert.Equal(expectedSampleSize, pool.PruningSampleSize); } [Fact] @@ -164,7 +120,17 @@ 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)); + Assert.Equal(30, pool.PruningSampleSize); + } + + [Fact] + public void Constructor_LargeLoadBalanceTimeout_ClampedToMaxPruningSampleSize() + { + // A very large LoadBalanceTimeout should be clamped to MaxPruningSampleSize (300) + // to prevent excessive memory allocation. 10000 / 10 = 1000, clamped to 300. + var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 10, loadBalanceTimeout: 10000); + + Assert.Equal(300, pool.PruningSampleSize); } #endregion @@ -177,14 +143,16 @@ public void UpdatePruningTimer_PoolGrowsBeyondMinPoolSize_EnablesTimer() var pool = ConstructPool(minPoolSize: 2, maxPoolSize: 10); // Pool starts empty, timer should be disabled - Assert.False(GetPruningTimerEnabled(pool)); + Assert.False(pool.IsPruningTimerEnabled); // 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)); + Assert.True(pool.IsPruningTimerEnabled); + + pool.Shutdown(); } [Fact] @@ -196,7 +164,7 @@ public void UpdatePruningTimer_PoolAtMinPoolSize_TimerRemainsDisabled() FillPoolWithIdleConnections(pool, 2); // Timer should stay disabled since we're at (not above) MinPoolSize - Assert.False(GetPruningTimerEnabled(pool)); + Assert.False(pool.IsPruningTimerEnabled); } [Fact] @@ -206,21 +174,23 @@ public void UpdatePruningTimer_PoolShrinksBackToMin_DisablesTimerAndResetsSample // Grow the pool to enable the timer FillPoolWithIdleConnections(pool, 3); - Assert.True(GetPruningTimerEnabled(pool)); + Assert.True(pool.IsPruningTimerEnabled); // 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); + int sampleSize = pool.PruningSampleSize; for (int i = 0; i < sampleSize; i++) { - InvokePruneIdleConnections(pool); + ChannelDbConnectionPool.PruneIdleConnections(pool); } // After pruning removed connections back to MinPoolSize, UpdatePruningTimer // should have disabled the timer. - Assert.False(GetPruningTimerEnabled(pool)); - Assert.Equal(0, GetPruningSampleIndex(pool)); + Assert.False(pool.IsPruningTimerEnabled); + Assert.Equal(0, pool.PruningSampleIndex); + + pool.Shutdown(); } [Fact] @@ -230,9 +200,9 @@ public void UpdatePruningTimer_FixedSizePool_NoOp() var pool = ConstructPool(minPoolSize: 5, maxPoolSize: 5); // UpdatePruningTimer should return without error (null timer guard) - InvokeUpdatePruningTimer(pool); + pool.UpdatePruningTimer(); - Assert.Null(GetPruningTimer(pool)); + Assert.False(pool.HasPruningTimer); } #endregion @@ -250,11 +220,13 @@ public void PruneIdleConnections_BufferNotFull_CollectsSampleWithoutPruning() int initialCount = pool.Count; // First invocation: records idle count in sample[0], buffer not full - InvokePruneIdleConnections(pool); + ChannelDbConnectionPool.PruneIdleConnections(pool); // No connections should be pruned yet Assert.Equal(initialCount, pool.Count); - Assert.Equal(1, GetPruningSampleIndex(pool)); + Assert.Equal(1, pool.PruningSampleIndex); + + pool.Shutdown(); } [Fact] @@ -268,11 +240,13 @@ public void PruneIdleConnections_RespectsMinPoolSizeFloor() Assert.Equal(10, pool.Count); // Fill sample buffer and trigger pruning - InvokePruneIdleConnections(pool); // sample 1 - InvokePruneIdleConnections(pool); // sample 2 → prune + ChannelDbConnectionPool.PruneIdleConnections(pool); // sample 1 + ChannelDbConnectionPool.PruneIdleConnections(pool); // sample 2 → prune // Pool should not drop below MinPoolSize (5) Assert.True(pool.Count >= 5, $"Pool count {pool.Count} dropped below MinPoolSize 5"); + + pool.Shutdown(); } [Fact] @@ -281,10 +255,10 @@ 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); + ChannelDbConnectionPool.PruneIdleConnections(pool); Assert.Equal(0, pool.Count); - Assert.Equal(0, GetPruningSampleIndex(pool)); + Assert.Equal(0, pool.PruningSampleIndex); } [Fact] @@ -297,11 +271,13 @@ public void PruneIdleConnections_SampleBufferResetsAfterPruning() FillPoolWithIdleConnections(pool, 5); // Fill sample buffer and trigger pruning - InvokePruneIdleConnections(pool); // sample index → 1 - InvokePruneIdleConnections(pool); // buffer full, prune, reset index → 0 + ChannelDbConnectionPool.PruneIdleConnections(pool); // sample index → 1 + ChannelDbConnectionPool.PruneIdleConnections(pool); // buffer full, prune, reset index → 0 // After pruning, sample index should be reset to 0 - Assert.Equal(0, GetPruningSampleIndex(pool)); + Assert.Equal(0, pool.PruningSampleIndex); + + pool.Shutdown(); } [Fact] @@ -326,11 +302,13 @@ public void PruneIdleConnections_DoesNotRemoveInUseConnections() Assert.Equal(10, pool.Count); // Fill sample buffer and trigger pruning - InvokePruneIdleConnections(pool); // sample 1 (idle count = 5) - InvokePruneIdleConnections(pool); // sample 2 → prune + ChannelDbConnectionPool.PruneIdleConnections(pool); // sample 1 (idle count = 5) + ChannelDbConnectionPool.PruneIdleConnections(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}"); + + pool.Shutdown(); } #endregion @@ -341,18 +319,17 @@ public void PruneIdleConnections_DoesNotRemoveInUseConnections() public void Shutdown_DisposesPruningTimer() { var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 10); - var timer = GetPruningTimer(pool); - Assert.NotNull(timer); + Assert.True(pool.HasPruningTimer); // Enable the timer by growing the pool beyond MinPoolSize FillPoolWithIdleConnections(pool, 3); - Assert.True(GetPruningTimerEnabled(pool)); + Assert.True(pool.IsPruningTimerEnabled); pool.Shutdown(); Assert.Equal(ShuttingDown, pool.State); // After shutdown, the timer-enabled flag must be cleared. - Assert.False(GetPruningTimerEnabled(pool)); + Assert.False(pool.IsPruningTimerEnabled); } [Fact] @@ -360,7 +337,7 @@ public void Shutdown_NullTimer_DoesNotThrow() { // Fixed-size pool has no timer var pool = ConstructPool(minPoolSize: 5, maxPoolSize: 5); - Assert.Null(GetPruningTimer(pool)); + Assert.False(pool.HasPruningTimer); // Should not throw pool.Shutdown(); @@ -381,9 +358,7 @@ public void Shutdown_NullTimer_DoesNotThrow() [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 })!; + int result = ChannelDbConnectionPool.DivideRoundingUp(value, divisor); Assert.Equal(expected, result); } @@ -408,11 +383,13 @@ public void AcceptanceScenario1_ExcessIdleConnectionsArePrunedToObservedUsage() // 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 + ChannelDbConnectionPool.PruneIdleConnections(pool); // sample 1 + ChannelDbConnectionPool.PruneIdleConnections(pool); // sample 2 → prune // Pool should be reduced significantly Assert.True(pool.Count < 20, $"Pool was not pruned. Count: {pool.Count}"); + + pool.Shutdown(); } [Fact] @@ -429,10 +406,10 @@ public void AcceptanceScenario2_PoolAtMinPoolSize_NoPruning() Assert.Equal(5, pool.Count); // Timer is not enabled because pool hasn't grown beyond MinPoolSize - Assert.False(GetPruningTimerEnabled(pool)); + Assert.False(pool.IsPruningTimerEnabled); // Even if we call prune directly, the guard (_pruningTimerEnabled=false) prevents action - InvokePruneIdleConnections(pool); + ChannelDbConnectionPool.PruneIdleConnections(pool); Assert.Equal(5, pool.Count); } @@ -457,10 +434,10 @@ public void AcceptanceScenario3_HighRecentUsage_PruningUsesMedianNotJustCurrentI busyOwners[i] = new SqlConnection(); pool.TryGetConnection(busyOwners[i], null, out busyConns[i]); } - InvokePruneIdleConnections(pool); // sample[0] = 2 idle + ChannelDbConnectionPool.PruneIdleConnections(pool); // sample[0] = 2 idle // Sample 2: 2 idle (still high demand) - InvokePruneIdleConnections(pool); // sample[1] = 2 idle + ChannelDbConnectionPool.PruneIdleConnections(pool); // sample[1] = 2 idle // Now return all busy connections, creating a brief lull (10 idle) for (int i = 0; i < 8; i++) @@ -470,13 +447,15 @@ public void AcceptanceScenario3_HighRecentUsage_PruningUsesMedianNotJustCurrentI // Sample 3: 10 idle (lull). Buffer full → sorted=[2,2,10], median at index 1 = 2. int countBefore = pool.Count; - InvokePruneIdleConnections(pool); + ChannelDbConnectionPool.PruneIdleConnections(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}"); + + pool.Shutdown(); } #endregion From 8e60b909373d0f16ac3d1c7b5795a278ea9ae00d Mon Sep 17 00:00:00 2001 From: Apoorv Deshmukh Date: Sat, 30 May 2026 16:20:25 +0530 Subject: [PATCH 4/5] Fix tests --- .../ConnectionPool/ChannelDbConnectionPoolPruningTest.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolPruningTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolPruningTest.cs index b064d48852..aef936ac7b 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolPruningTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolPruningTest.cs @@ -61,7 +61,7 @@ private void FillPoolWithIdleConnections(ChannelDbConnectionPool pool, int count for (int i = 0; i < count; i++) { owners[i] = new SqlConnection(); - pool.TryGetConnection(owners[i], null, out connections[i]); + pool.TryGetConnection(owners[i], null, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out connections[i]); } for (int i = 0; i < count; i++) @@ -292,7 +292,7 @@ public void PruneIdleConnections_DoesNotRemoveInUseConnections() for (int i = 0; i < 5; i++) { owners[i] = new SqlConnection(); - pool.TryGetConnection(owners[i], null, out connections[i]); + pool.TryGetConnection(owners[i], null, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out connections[i]); } // Also add 5 idle connections @@ -432,7 +432,7 @@ public void AcceptanceScenario3_HighRecentUsage_PruningUsesMedianNotJustCurrentI for (int i = 0; i < 8; i++) { busyOwners[i] = new SqlConnection(); - pool.TryGetConnection(busyOwners[i], null, out busyConns[i]); + pool.TryGetConnection(busyOwners[i], null, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out busyConns[i]); } ChannelDbConnectionPool.PruneIdleConnections(pool); // sample[0] = 2 idle From 722d33fc0b419edca478659d56e78f0232edbb40 Mon Sep 17 00:00:00 2001 From: Apoorv Deshmukh Date: Sat, 30 May 2026 17:00:17 +0530 Subject: [PATCH 5/5] Fix test stub --- .../ConnectionPool/ChannelDbConnectionPoolPruningTest.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolPruningTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolPruningTest.cs index aef936ac7b..3c30963572 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolPruningTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolPruningTest.cs @@ -469,7 +469,8 @@ protected override DbConnectionInternal CreateConnection( ConnectionPoolKey poolKey, DbConnectionPoolGroupProviderInfo poolGroupProviderInfo, IDbConnectionPool pool, - DbConnection owningConnection) + DbConnection owningConnection, + TimeoutTimer timeout) { return new StubDbConnectionInternal(); }