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
+ }
+}