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 298c38b9db..4da7c48020 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 @@ -160,11 +160,14 @@ internal ChannelDbConnectionPool( _errorState = new BlockingPeriodErrorState(_instanceId, timeProvider: timeProvider); } - // 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) - { - Pruner = new PoolPruner(this, PoolGroupOptions.LoadBalanceTimeout); + // Pruning is only useful when the pool can grow beyond MinPoolSize and idle + // connections are subject to reclamation. If min >= max the pool is fixed-size so + // pruning would never activate; if Connection Idle Timeout is zero, idle connections + // are never reclaimed and there is nothing to prune. The pruning window (and thus how + // many samples are collected) is derived from IdleTimeout. + if (MinPoolSize < MaxPoolSize && PoolGroupOptions.IdleTimeout != TimeSpan.Zero) + { + Pruner = new PoolPruner(this, PoolGroupOptions.IdleTimeout); } State = Running; diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/PoolPruner.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/PoolPruner.cs index 07899d26e3..9c6621f5f0 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/PoolPruner.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/PoolPruner.cs @@ -4,6 +4,7 @@ using System; using System.Threading; using Microsoft.Data.Common; +using Microsoft.Data.SqlClient.Internal; #nullable enable @@ -14,29 +15,41 @@ namespace Microsoft.Data.SqlClient.ConnectionPool /// Periodically samples the idle connection count and, once enough samples are collected, /// computes the median and prunes that many idle connections from the pool. /// - /// This type is only instantiated when the pool can shrink (MinPoolSize < MaxPoolSize). - /// When the pool is fixed-size, the pool holds no instance. + /// This type is only instantiated when the pool can shrink (MinPoolSize < MaxPoolSize) and + /// idle-timeout based reclamation is enabled (Connection Idle Timeout > 0). When the pool is + /// fixed-size or idle reclamation is disabled, the pool holds no instance. /// /// internal sealed class PoolPruner : IDisposable { /// - /// Maximum allowed sample buffer size to prevent excessive memory allocation - /// from very large lifetime values. With a 10-second interval this supports - /// idle lifetimes up to 3000 seconds (50 minutes). + /// Maximum number of idle-count samples collected per pruning window. Bounds the sample + /// buffer size and the per-window sort cost regardless of how large the configured idle + /// timeout is. Once a window would need more than this many samples at + /// , the sampling interval is stretched instead of growing + /// the buffer, keeping the sample count bounded (the interval itself is separately capped by + /// , so for very large idle timeouts the effective window can + /// be shorter than the configured timeout). + /// At the floor this supports idle timeouts up to + /// MaxSampleSize * MinIntervalSeconds = 3000 seconds (50 minutes) before stretching. /// internal const int MaxSampleSize = 300; /// - /// Default sampling interval. + /// Minimum (default) sampling interval, in seconds. Acts as a floor so the pruning timer + /// never fires more often than this and preserves the Story 1 cadence for short/typical + /// idle timeouts (e.g. the default 300s idle timeout yields a 10s interval and 30 samples). /// - private static readonly TimeSpan DefaultSamplingInterval = TimeSpan.FromSeconds(10); + internal const int MinIntervalSeconds = 10; /// - /// Default lifetime window used for sample size calculation when - /// LoadBalanceTimeout (Connection Lifetime) is zero. + /// Maximum sampling interval, in seconds (1 day). Purely a defensive guard so the derived + /// interval handed to stays well under its + /// ~49.7-day limit for pathological idle timeouts (the Connection Idle Timeout keyword + /// accepts up to seconds). It only engages above ~300-day idle + /// timeouts, so it never affects any realistic configuration. /// - private static readonly TimeSpan DefaultLifetimeWindow = TimeSpan.FromSeconds(300); + internal const int MaxIntervalSeconds = 24 * 60 * 60; /// /// The owning connection pool whose idle connections are pruned. @@ -56,7 +69,8 @@ internal sealed class PoolPruner : IDisposable /// /// Number of idle count samples to collect before computing the median and pruning. - /// Equals lifetime / interval (rounded up), clamped to . + /// Equals ceil(idleTimeout / samplingInterval), bounded by + /// (the interval is stretched rather than exceeding the sample cap for large idle timeouts). /// private readonly int _sampleSize; @@ -88,25 +102,46 @@ internal sealed class PoolPruner : IDisposable /// Creates a new pruner for the given pool. /// /// The owning connection pool. - /// - /// The connection lifetime window used to compute sample count. - /// When zero or negative, is used. + /// + /// The configured Connection Idle Timeout, used as the pruning window. The sampling interval + /// and sample count are derived from it so the window covers the idle timeout for typical + /// values, while the interval stays bounded by and the sample + /// count by (see the constructor body for the exact formula). For + /// very large idle timeouts both are capped, so the effective window can be shorter than the + /// configured timeout. The pool only constructs a pruner when idle-timeout based reclamation is + /// enabled (idleTimeout > 0); a defensive floor keeps the sizing math valid. /// - internal PoolPruner(ChannelDbConnectionPool pool, TimeSpan lifetimeWindow) + internal PoolPruner(ChannelDbConnectionPool pool, TimeSpan idleTimeout) { _pool = pool; - _samplingInterval = DefaultSamplingInterval; - int lifetimeSeconds = (int)lifetimeWindow.TotalSeconds; - if (lifetimeSeconds <= 0) + // The pool only constructs a pruner when IdleTimeout > 0, but floor at one second so + // the sizing math below always sees a positive window even under unexpected inputs. + int idleTimeoutSeconds = Math.Max(1, (int)idleTimeout.TotalSeconds); + + // Derive the interval so the window covers the idle timeout with at most MaxSampleSize + // samples. Short/typical timeouts keep the MinIntervalSeconds cadence (e.g. 300s -> 10s, + // 30 samples); large timeouts stretch the interval and pin the count at MaxSampleSize. The + // MaxIntervalSeconds cap is a defensive Timer.Change overflow guard (engages >~300 days). + // Math.Clamp is unavailable on net462, so clamp explicitly with Min/Max. + int intervalForMaxSamples = DivideRoundingUp(idleTimeoutSeconds, MaxSampleSize); + int flooredInterval = Math.Max(intervalForMaxSamples, MinIntervalSeconds); + int intervalSeconds = Math.Min(flooredInterval, MaxIntervalSeconds); + + int sampleSize = Math.Min(MaxSampleSize, DivideRoundingUp(idleTimeoutSeconds, intervalSeconds)); + + if (intervalSeconds != MinIntervalSeconds) { - lifetimeSeconds = (int)DefaultLifetimeWindow.TotalSeconds; + // The interval was stretched (or clamped) away from the default. Surface the derived + // cadence so operators can tell why a very large Connection Idle Timeout samples less + // frequently than the default 10-second interval. + SqlClientEventSource.Log.TryPoolerTraceEvent( + " Idle timeout {0}s derived a pruning interval of {1}s with {2} samples (max {3}).", + idleTimeoutSeconds, intervalSeconds, sampleSize, MaxSampleSize); } - int intervalSeconds = (int)_samplingInterval.TotalSeconds; - _sampleSize = Math.Min( - DivideRoundingUp(lifetimeSeconds, intervalSeconds), - MaxSampleSize); + _samplingInterval = TimeSpan.FromSeconds(intervalSeconds); + _sampleSize = sampleSize; // Subtract 1 to convert from length to 0-based index. Safe because // DivideRoundingUp always returns >= 1, so _sampleSize >= 1. @@ -127,6 +162,9 @@ internal PoolPruner(ChannelDbConnectionPool pool, TimeSpan lifetimeWindow) /// Total number of samples collected per pruning window. Exposed for unit tests. internal int SampleSize => _sampleSize; + /// The derived interval between pruning samples/evaluations. Exposed for unit tests. + internal TimeSpan SamplingInterval => _samplingInterval; + /// Read-only view of the sample buffer contents. Exposed for unit tests. internal ReadOnlySpan Samples => _samples; @@ -162,7 +200,7 @@ internal void UpdateTimer() else if (numConnections <= _pool.PoolGroupOptions.MinPoolSize && _timerEnabled) { // Pool shrunk back to min — stop pruning, reset sample buffer. - _timer.Change(Timeout.Infinite, Timeout.Infinite); + _timer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); _sampleIndex = 0; _timerEnabled = false; } diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolPruningTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolPruningTest.cs index 3f7f3b0bc5..fad16cf230 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolPruningTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolPruningTest.cs @@ -27,7 +27,8 @@ public class ChannelDbConnectionPoolPruningTest private static ChannelDbConnectionPool ConstructPool( int minPoolSize = 0, int maxPoolSize = 50, - int loadBalanceTimeout = 0) + int loadBalanceTimeout = 0, + int idleTimeout = 300) { var poolGroupOptions = new DbConnectionPoolGroupOptions( poolByIdentity: false, @@ -36,7 +37,7 @@ private static ChannelDbConnectionPool ConstructPool( creationTimeout: 15, loadBalanceTimeout: loadBalanceTimeout, hasTransactionAffinity: true, - idleTimeout: 0 + idleTimeout: idleTimeout ); var dbConnectionPoolGroup = new DbConnectionPoolGroup( new SqlConnectionOptions("Data Source=localhost;"), @@ -160,6 +161,15 @@ public void Constructor_MinPoolSizeEqualsMax_DoesNotCreatePruningTimer() Assert.Null(pool.Pruner); } + [Fact] + public void Constructor_IdleTimeoutZero_DoesNotCreatePruningTimer() + { + // Connection Idle Timeout = 0 disables idle reclamation, so there is nothing to prune. + using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 10, idleTimeout: 0); + + Assert.Null(pool.Pruner); + } + [Fact] public void Constructor_PruningTimerStartsDisabled() { @@ -171,19 +181,28 @@ public void Constructor_PruningTimerStartsDisabled() } [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) - [InlineData(0, 30)] // 0 falls back to DefaultLifetimeWindow (300) / 10 = 30 samples - [InlineData(10000, 300)] // 10000 / 10 = 1000, clamped to MaxPruningSampleSize (300) - public void Constructor_CalculatesSampleSizeFromLoadBalanceTimeout( - int loadBalanceTimeout, int expectedSampleSize) + // Short/typical idle timeouts keep the 10s floor and grow the sample count (Story 1 parity). + [InlineData(100, 10, 10)] // interval 10s, ceil(100/10) = 10 samples + [InlineData(300, 10, 30)] // default idle timeout: 10s, 30 samples + [InlineData(60, 10, 6)] // 10s, 6 samples + [InlineData(15, 10, 2)] // 10s, ceil(15/10) = 2 samples + [InlineData(5, 10, 1)] // sub-interval idle timeout: floored to 10s, 1 sample + [InlineData(3000, 10, 300)] // boundary: MaxSampleSize * 10s, still 10s cadence + // Large idle timeouts pin the sample count at MaxSampleSize and stretch the interval so the + // window still spans the full idle timeout. + [InlineData(6000, 20, 300)] // ceil(6000/300) = 20s interval, 300 samples + [InlineData(10000, 34, 295)] // ceil(10000/300) = 34s interval, ceil(10000/34) = 295 samples + [InlineData(86400, 288, 300)] // 1-day idle timeout: 288s interval, 300 samples + // Pathological idle timeout hits the 1-day interval overflow clamp without throwing. + [InlineData(int.MaxValue, 86400, 300)] + public void Constructor_CalculatesIntervalAndSampleSizeFromIdleTimeout( + int idleTimeout, int expectedIntervalSeconds, int expectedSampleSize) { - using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 10, loadBalanceTimeout: loadBalanceTimeout); + using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 10, idleTimeout: idleTimeout); var pruner = GetPruner(pool); Assert.Equal(expectedSampleSize, pruner.SampleSize); + Assert.Equal(TimeSpan.FromSeconds(expectedIntervalSeconds), pruner.SamplingInterval); } #endregion @@ -262,9 +281,9 @@ public void UpdatePruningTimer_PoolShrinksBackToMin_DisablesTimerAndResetsSample [Fact] public void PruneIdleConnections_BufferNotFull_CollectsSampleWithoutPruning() { - using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 10, loadBalanceTimeout: 30); + using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 10, idleTimeout: 30); var pruner = GetPruner(pool); - // loadBalanceTimeout=30 → sample size = 30/10 = 3 + // idleTimeout=30 → sample size = 30/10 = 3 // Fill the pool so pruning timer is active FillPoolWithIdleConnections(pool, 5); @@ -281,8 +300,8 @@ public void PruneIdleConnections_BufferNotFull_CollectsSampleWithoutPruning() [Fact] public void PruneIdleConnections_RespectsMinPoolSizeFloor() { - // loadBalanceTimeout=20 → 2 samples - using var pool = ConstructPool(minPoolSize: 5, maxPoolSize: 20, loadBalanceTimeout: 20); + // idleTimeout=20 → 2 samples + using var pool = ConstructPool(minPoolSize: 5, maxPoolSize: 20, idleTimeout: 20); var pruner = GetPruner(pool); // Fill pool with 10 idle connections @@ -305,7 +324,7 @@ public void PruneIdleConnections_RespectsMinPoolSizeFloor() [Fact] public void PruneIdleConnections_TimerDisabled_ReturnsEarlyWithoutPruning() { - using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 10, loadBalanceTimeout: 20); + using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 10, idleTimeout: 20); var pruner = GetPruner(pool); // Pool starts empty, timer is disabled. Calling prune should be a no-op. @@ -318,8 +337,8 @@ public void PruneIdleConnections_TimerDisabled_ReturnsEarlyWithoutPruning() [Fact] public void PruneIdleConnections_DoesNotRemoveInUseConnections() { - // loadBalanceTimeout=20 → 2 samples - using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 20, loadBalanceTimeout: 20); + // idleTimeout=20 → 2 samples + using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 20, idleTimeout: 20); var pruner = GetPruner(pool); // Get 5 connections and KEEP them checked out (in use) @@ -428,8 +447,8 @@ public void PruneIdleConnections_AllIdleWithZeroMinPoolSize_PrunesEntirePool() // When: The pruning interval elapses (sample buffer fills). // Then: The pool closes excess idle connections. - // Use loadBalanceTimeout=20 for 2 samples (fast buffer fill) - using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 50, loadBalanceTimeout: 20); + // Use idleTimeout=20 for 2 samples (fast buffer fill) + using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 50, idleTimeout: 20); var pruner = GetPruner(pool); // Simulate high load: open 20 connections @@ -456,8 +475,8 @@ public void PruneIdleConnections_VariedSamples_UsesMedianNotCurrentIdleCount() // When: The pruning interval elapses. // Then: Pruning uses sampled usage data (median) to avoid being too aggressive. - // Use 3 samples (loadBalanceTimeout=30) - using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 50, loadBalanceTimeout: 30); + // Use 3 samples (idleTimeout=30) + using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 50, idleTimeout: 30); var pruner = GetPruner(pool); // Simulate varied usage: @@ -498,8 +517,8 @@ public void PruneIdleConnections_EvenSampleCountWithDistinctValues_UsesLowerMidd // Using distinct sample values proves the lower-middle is chosen rather than the // upper-middle or an average. - // loadBalanceTimeout=20 → 2 samples - using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 20, loadBalanceTimeout: 20); + // idleTimeout=20 → 2 samples + using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 20, idleTimeout: 20); var pruner = GetPruner(pool); // Start with 8 idle connections. @@ -535,8 +554,8 @@ public void PruneIdleConnections_SecondWindowAfterPrune_RecomputesMedianFromFres // the first: it computes its own median from newly recorded samples and prunes the // correct amount, confirming the collect → prune → reset → collect cycle is repeatable. - // loadBalanceTimeout=20 → 2 samples per window - using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 20, loadBalanceTimeout: 20); + // idleTimeout=20 → 2 samples per window + using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 20, idleTimeout: 20); var pruner = GetPruner(pool); // --- Window 1: produces a median of 4 --- @@ -570,6 +589,119 @@ public void PruneIdleConnections_SecondWindowAfterPrune_RecomputesMedianFromFres Assert.Equal(0, pool.Count); } + [Fact] + public void PruneIdleConnections_TransientDemandSpikeWithinWindow_DoesNotUnderPrune() + { + // Complements VariedSamples (which proves a brief lull doesn't over-prune): + // here a brief demand spike (a single low idle sample) must not drag the median down + // and cause the pruner to under-prune. The sustained idle level should still be reclaimed. + + // idleTimeout=30 → 3 samples + using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 50, idleTimeout: 30); + var pruner = GetPruner(pool); + + FillPoolWithIdleConnections(pool, 10); + + // Sample 1: 10 idle (steady state). + pruner.OnPruningCallback(null); + AssertPrunerState(pruner, true, 1, 10); + Assert.Equal(10, pool.Count); + + // Demand spike: check out 8, leaving only 2 idle for the middle sample. + var busyConnections = CheckOutConnections(pool, 8); + + // Sample 2: 2 idle (transient spike). + pruner.OnPruningCallback(null); + AssertPrunerState(pruner, true, 2, 10, 2); + Assert.Equal(10, pool.Count); + + // Spike subsides — all connections idle again. + ReturnConnections(pool, busyConnections); + + // Sample 3: 10 idle. Buffer full → sorted=[2,10,10], median at index 1 = 10. + int countBefore = pool.Count; + pruner.OnPruningCallback(null); + // Median prunes all 10 idle down to MinPoolSize (0), so the timer disables. + AssertPrunerState(pruner, false, 0); + + // The transient spike (sample of 2) is ignored; the sustained level (10) is pruned. + int pruned = countBefore - pool.Count; + Assert.Equal(10, pruned); + Assert.Equal(0, pool.Count); + } + + [Fact] + public void Pruning_PruneToMinThenRegrow_ReArmsAndPrunesAgain() + { + // Exercises the full prune → disable → regrow → re-arm → prune cycle. After a window + // prunes the pool down to MinPoolSize the timer disables; opening new connections must + // re-arm it, and the next window must sample and prune independently. + + // idleTimeout=20 → 2 samples + using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 50, idleTimeout: 20); + var pruner = GetPruner(pool); + + // --- Window 1: prune 6 idle down to min (0), disabling the timer --- + FillPoolWithIdleConnections(pool, 6); + + pruner.OnPruningCallback(null); // sample[0] = 6 + AssertPrunerState(pruner, true, 1, 6); + + pruner.OnPruningCallback(null); // buffer full [6,6], median 6, prune 6 → Count 0 + AssertPrunerState(pruner, false, 0); + Assert.Equal(0, pool.Count); + + // --- Regrow: opening connections must re-arm the disabled timer --- + FillPoolWithIdleConnections(pool, 12); + AssertPrunerState(pruner, true, 0); + Assert.Equal(12, pool.Count); + + // --- Window 2: samples freshly and prunes 12 --- + pruner.OnPruningCallback(null); // sample[0] = 12 + AssertPrunerState(pruner, true, 1, 12); + + int countBefore = pool.Count; + pruner.OnPruningCallback(null); // buffer full [12,12], median 12, prune 12 → Count 0 + AssertPrunerState(pruner, false, 0); + + Assert.Equal(12, countBefore - pool.Count); + Assert.Equal(0, pool.Count); + } + + [Fact] + public void OnPruningCallback_AfterShutdown_IsNoOp() + { + // A pruning timer callback can race with pool shutdown. Once Shutdown() has disposed the + // pruner, a stray callback must be an inert no-op: it must not throw, re-enable the timer, + // mutate the sample index, or resurrect connections. + + // idleTimeout=30 → 3 samples + using var pool = ConstructPool(minPoolSize: 0, maxPoolSize: 50, idleTimeout: 30); + var pruner = GetPruner(pool); + + FillPoolWithIdleConnections(pool, 6); + + // Advance one sample so SampleIndex is non-zero before shutdown. + pruner.OnPruningCallback(null); + AssertPrunerState(pruner, true, 1, 6); + + pool.Shutdown(); + + // Shutdown disposes the pruner (timer disabled) and drains all idle connections. + // Dispose intentionally does NOT reset the sample index. + Assert.False(pruner.IsTimerEnabled); + Assert.Equal(0, pool.Count); + int sampleIndexAfterShutdown = pruner.SampleIndex; + + // A stray timer callback after shutdown must be a no-op. + Exception recorded = Record.Exception(() => pruner.OnPruningCallback(null)); + + Assert.Null(recorded); + Assert.False(pruner.IsTimerEnabled); + Assert.Equal(sampleIndexAfterShutdown, pruner.SampleIndex); + Assert.Equal(0, pool.Count); + } + #endregion #region Test classes