From 126f16a8f41a17641ce110157680bef11f87b8df Mon Sep 17 00:00:00 2001 From: Priyanka Tiwari Date: Tue, 19 May 2026 15:40:28 +0530 Subject: [PATCH 1/7] Add configurable idle connection timeout (ADO #39970) Implements spec User Stories 1, 2, 4 + FR-009 of US3 from specs/003-pool-idle-timeout/spec.md. Adds 'Connection Idle Timeout' keyword (synonym: 'Pool Idle Timeout') exposed via SqlConnectionStringBuilder.IdleTimeout. When > 0, connections that have sat idle in the pool longer than the configured number of seconds are discarded on retrieval and a fresh connection is returned. Default 0 (disabled) matches the existing convention used by LoadBalanceTimeout and ConnectionLifetime. Covers both pool designs (ChannelDbConnectionPool, WaitHandleDbConnectionPool). Deferred to follow-up: proactive timer sweep (FR-008, FR-010) which the spec assumes is built on top of the pruning feature (#37338). --- .../SqlConnectionStringBuilder.xml | 22 +++ .../ref/Microsoft.Data.SqlClient.cs | 4 + .../DbConnectionStringDefaults.cs | 1 + .../DbConnectionStringKeywords.cs | 1 + .../DbConnectionStringSynonyms.cs | 1 + .../Data/ProviderBase/DbConnectionInternal.cs | 22 +++ .../ConnectionPool/ChannelDbConnectionPool.cs | 12 ++ .../ConnectionPool/DbConnectionPoolOptions.cs | 17 +- .../WaitHandleDbConnectionPool.cs | 18 +- .../Data/SqlClient/SqlConnectionFactory.cs | 3 +- .../Data/SqlClient/SqlConnectionOptions.cs | 13 ++ .../SqlClient/SqlConnectionStringBuilder.cs | 32 ++++ .../src/Resources/Strings.Designer.cs | 9 + .../src/Resources/Strings.resx | 3 + .../SqlConnectionStringBuilderTest.cs | 46 ++++++ .../ChannelDbConnectionPoolTest.cs | 154 ++++++++++++++++++ 16 files changed, 354 insertions(+), 4 deletions(-) diff --git a/doc/snippets/Microsoft.Data.SqlClient/SqlConnectionStringBuilder.xml b/doc/snippets/Microsoft.Data.SqlClient/SqlConnectionStringBuilder.xml index a68c0a323b..cf7bd4cd34 100644 --- a/doc/snippets/Microsoft.Data.SqlClient/SqlConnectionStringBuilder.xml +++ b/doc/snippets/Microsoft.Data.SqlClient/SqlConnectionStringBuilder.xml @@ -979,6 +979,28 @@ The following example converts an existing connection string from using SQL Serv + + + Gets or sets the maximum time, in seconds, that a connection can sit unused (idle) in the connection pool before it is discarded on its next retrieval. + + + The value of the property, or 0 if none has been supplied. + + + + This property corresponds to the "Connection Idle Timeout" key (synonym: "Pool Idle Timeout") within the connection string. + + + When a caller retrieves a connection from the pool, the driver checks how long the connection has been sitting idle. If the idle duration exceeds the value of Connection Idle Timeout, the connection is discarded and a different valid or newly-created connection is returned instead. This protects callers from receiving connections that may have been silently closed by firewalls, load balancers, or server-side inactivity thresholds. + + + A value of zero (0) disables idle expiration; connections are kept in the pool indefinitely (subject to other expiry rules such as ). + + + Idle timeout operates independently of . Whichever threshold is exceeded first causes the connection to be recycled. + + + Gets or sets the maximum number of connections allowed in the connection pool for this specific connection string. diff --git a/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs b/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs index e52cbdc8d3..96ac073744 100644 --- a/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs +++ b/src/Microsoft.Data.SqlClient/ref/Microsoft.Data.SqlClient.cs @@ -1368,6 +1368,10 @@ public SqlConnectionStringBuilder(string connectionString) { } [System.ComponentModel.DisplayNameAttribute("Load Balance Timeout")] [System.ComponentModel.RefreshPropertiesAttribute(System.ComponentModel.RefreshProperties.All)] public int LoadBalanceTimeout { get { throw null; } set { } } + /// + [System.ComponentModel.DisplayNameAttribute("Connection Idle Timeout")] + [System.ComponentModel.RefreshPropertiesAttribute(System.ComponentModel.RefreshProperties.All)] + public int IdleTimeout { get { throw null; } set { } } /// [System.ComponentModel.DisplayNameAttribute("Max Pool Size")] [System.ComponentModel.RefreshPropertiesAttribute(System.ComponentModel.RefreshProperties.All)] diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringDefaults.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringDefaults.cs index 460fa0c2cc..4a3b9771cf 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringDefaults.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringDefaults.cs @@ -36,6 +36,7 @@ internal static class DbConnectionStringDefaults internal const bool IntegratedSecurity = false; internal const SqlConnectionIPAddressPreference IpAddressPreference = SqlConnectionIPAddressPreference.IPv4First; internal const int LoadBalanceTimeout = 0; // default of 0 means don't use + internal const int IdleTimeout = 0; // default of 0 means don't expire idle connections internal const int MaxPoolSize = 100; internal const int MinPoolSize = 0; internal const bool MultipleActiveResultSets = false; diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringKeywords.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringKeywords.cs index 5c902de2a8..b163ec2536 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringKeywords.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringKeywords.cs @@ -31,6 +31,7 @@ internal static class DbConnectionStringKeywords internal const string IntegratedSecurity = "Integrated Security"; internal const string IpAddressPreference = "IP Address Preference"; internal const string LoadBalanceTimeout = "Load Balance Timeout"; + internal const string IdleTimeout = "Connection Idle Timeout"; internal const string MaxPoolSize = "Max Pool Size"; internal const string MinPoolSize = "Min Pool Size"; internal const string MultipleActiveResultSets = "Multiple Active Result Sets"; diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringSynonyms.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringSynonyms.cs index 15c32abe75..922d08ce73 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringSynonyms.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringSynonyms.cs @@ -32,6 +32,7 @@ internal static class DbConnectionStringSynonyms internal const string PacketSize = "packetsize"; internal const string PersistSecurityInfo = "persistsecurityinfo"; internal const string PoolBlockingPeriod = "poolblockingperiod"; + internal const string PoolIdleTimeout = "pool idle timeout"; internal const string Pwd = "pwd"; internal const string Server = "server"; internal const string ServerCertificate = "servercertificate"; diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/ProviderBase/DbConnectionInternal.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/ProviderBase/DbConnectionInternal.cs index ac3949c726..ba07a5f652 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/ProviderBase/DbConnectionInternal.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/ProviderBase/DbConnectionInternal.cs @@ -82,6 +82,11 @@ internal DbConnectionInternal(ConnectionState state, bool hidePassword, bool all ShouldHidePassword = hidePassword; State = state; CreateTime = DateTime.UtcNow; + // Initialize the idle-since stamp to creation time so that a freshly built connection is treated + // as "just used" by idle-expiry checks until the pool's return path stamps it again on first return. + // Without this initialization, IdleSinceUtc would default to DateTime.MinValue, which would cause + // IsLiveConnection to immediately evict every new connection whenever IdleTimeout is configured. + IdleSinceUtc = CreateTime; } #region Properties @@ -91,6 +96,13 @@ internal DbConnectionInternal(ConnectionState state, bool hidePassword, bool all /// internal DateTime CreateTime { get; } + /// + /// UTC timestamp of when this connection was last placed into the pool's idle state. + /// Stamped by from the pool's return-to-pool path. + /// Used by the pool to discard connections that have sat unused longer than the configured idle timeout. + /// + internal DateTime IdleSinceUtc { get; private set; } + /// /// The pool generation at the time this connection was created or added to the pool. /// Used by to detect stale connections after a pool clear. @@ -734,6 +746,16 @@ internal virtual void PrepareForReplaceConnection() // By default, there is no preparation required } + /// + /// Stamps with the current UTC time. Called by the pool's return-to-pool path + /// (after the connection has been deactivated and is about to enter the idle pool) so that the pool can later + /// decide whether the connection has sat idle for too long and should be discarded. + /// + internal void MarkPooledIdle() + { + IdleSinceUtc = DateTime.UtcNow; + } + internal void PrePush(DbConnection expectedOwner) { // Called by IDbConnectionPool when we're about to be put into it's pool, we take this 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..03dccadee7 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 @@ -254,6 +254,9 @@ public void ReturnInternalConnection(DbConnectionInternal connection, DbConnecti } else { + // Stamp the idle-since timestamp immediately before putting the connection back in the + // pool so that IsLiveConnection can later evict it if it sits idle past the configured limit. + connection.MarkPooledIdle(); var written = _idleChannel.TryWrite(connection); Debug.Assert(written, "Failed to write returning connection to the idle channel."); } @@ -436,6 +439,15 @@ private bool IsLiveConnection(DbConnectionInternal connection) return false; } + // Connection has been sitting idle longer than the configured idle timeout. + // IdleSinceUtc is stamped by ReturnInternalConnection on each return; if it is the default + // (DateTime.MinValue), the connection has never been pooled yet and the check is a no-op. + TimeSpan idleTimeout = PoolGroupOptions.IdleTimeout; + if (idleTimeout != TimeSpan.Zero && DateTime.UtcNow > connection.IdleSinceUtc + idleTimeout) + { + return false; + } + // Connection was created before the last Clear, so it's stale. if (connection.ClearGeneration != _clearGeneration) { diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolOptions.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolOptions.cs index 5ac6f4d565..bb6721c4c8 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolOptions.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolOptions.cs @@ -13,6 +13,7 @@ internal sealed class DbConnectionPoolGroupOptions private readonly int _maxPoolSize; private readonly int _creationTimeout; private readonly TimeSpan _loadBalanceTimeout; + private readonly TimeSpan _idleTimeout; private readonly bool _hasTransactionAffinity; private readonly bool _useLoadBalancing; @@ -22,7 +23,8 @@ public DbConnectionPoolGroupOptions( int maxPoolSize, int creationTimeout, int loadBalanceTimeout, - bool hasTransactionAffinity + bool hasTransactionAffinity, + int idleTimeout = 0 ) { _poolByIdentity = poolByIdentity; @@ -36,6 +38,11 @@ bool hasTransactionAffinity _useLoadBalancing = true; } + if (0 != idleTimeout) + { + _idleTimeout = new TimeSpan(0, 0, idleTimeout); + } + _hasTransactionAffinity = hasTransactionAffinity; } @@ -54,6 +61,14 @@ public TimeSpan LoadBalanceTimeout { get { return _loadBalanceTimeout; } } + /// + /// The maximum time a pooled connection can sit unused (idle) in the pool before it is discarded + /// on the next retrieval attempt. disables idle expiration. + /// + public TimeSpan IdleTimeout + { + get { return _idleTimeout; } + } public int MaxPoolSize { get { return _maxPoolSize; } diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs index 4243e777ba..55adcc0559 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs @@ -1028,7 +1028,7 @@ private bool TryGetConnection(DbConnection owningObject, uint waitForMultipleObj Interlocked.Decrement(ref _waitCount); obj = GetFromGeneralPool(); - if ((obj != null) && (!obj.IsConnectionAlive())) + if ((obj != null) && (!obj.IsConnectionAlive() || IsIdleExpired(obj))) { SqlClientEventSource.Log.TryPoolerTraceEvent(" {0}, Connection {1}, found dead and removed.", Id, obj.ObjectID); DestroyObject(obj); @@ -1207,7 +1207,7 @@ private DbConnectionInternal GetFromTransactedPool(out Transaction transaction) throw; } } - else if (!obj.IsConnectionAlive()) + else if (!obj.IsConnectionAlive() || IsIdleExpired(obj)) { SqlClientEventSource.Log.TryPoolerTraceEvent(" {0}, Connection {1}, found dead and removed.", Id, obj.ObjectID); DestroyObject(obj); @@ -1329,6 +1329,9 @@ private void PutNewObject(DbConnectionInternal obj) SqlClientEventSource.Log.TryPoolerTraceEvent(" {0}, Connection {1}, Pushing to general pool.", Id, obj.ObjectID); + // Stamp the idle-since timestamp immediately before placing the connection on the idle stack + // so that idle-expiry checks on later retrieval can decide whether it has sat unused too long. + obj.MarkPooledIdle(); _stackNew.Push(obj); _waitHandles.PoolSemaphore.Release(1); @@ -1336,6 +1339,17 @@ private void PutNewObject(DbConnectionInternal obj) } + /// + /// Returns true when the supplied connection has been sitting idle in the pool longer than the + /// configured . Returns false when idle timeout + /// is disabled (zero). + /// + private bool IsIdleExpired(DbConnectionInternal obj) + { + TimeSpan idleTimeout = PoolGroupOptions.IdleTimeout; + return idleTimeout != TimeSpan.Zero && DateTime.UtcNow > obj.IdleSinceUtc + idleTimeout; + } + public void ReturnInternalConnection(DbConnectionInternal obj, DbConnection owningObject) { Debug.Assert(obj != null, "null obj?"); diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs index 30010d0d4b..4cee0a295a 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs @@ -731,7 +731,8 @@ private static DbConnectionPoolGroupOptions CreateConnectionPoolGroupOptions(Sql opt.MaxPoolSize, connectionTimeout, opt.LoadBalanceTimeout, - opt.Enlist); + opt.Enlist, + opt.IdleTimeout); } return poolingOptions; } diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionOptions.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionOptions.cs index 6eafb195c8..75ff79ae34 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionOptions.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionOptions.cs @@ -105,6 +105,7 @@ internal static class TRANSACTIONBINDING private readonly int _commandTimeout; private readonly int _connectTimeout; private readonly int _loadBalanceTimeout; + private readonly int _idleTimeout; private readonly int _maxPoolSize; private readonly int _minPoolSize; private readonly int _packetSize; @@ -195,6 +196,8 @@ static SqlConnectionOptions() DbConnectionStringSynonyms.IpAddressPreference); AddKeywordToMap(DbConnectionStringKeywords.LoadBalanceTimeout, DbConnectionStringSynonyms.ConnectionLifetime); + AddKeywordToMap(DbConnectionStringKeywords.IdleTimeout, + DbConnectionStringSynonyms.PoolIdleTimeout); AddKeywordToMap(DbConnectionStringKeywords.MultipleActiveResultSets, DbConnectionStringSynonyms.MultipleActiveResultSets); AddKeywordToMap(DbConnectionStringKeywords.MaxPoolSize); @@ -274,6 +277,7 @@ internal SqlConnectionOptions(string connectionString) _commandTimeout = ConvertValueToInt32(DbConnectionStringKeywords.CommandTimeout, DbConnectionStringDefaults.CommandTimeout); _connectTimeout = ConvertValueToInt32(DbConnectionStringKeywords.ConnectTimeout, DbConnectionStringDefaults.ConnectTimeout); _loadBalanceTimeout = ConvertValueToInt32(DbConnectionStringKeywords.LoadBalanceTimeout, DbConnectionStringDefaults.LoadBalanceTimeout); + _idleTimeout = ConvertValueToInt32(DbConnectionStringKeywords.IdleTimeout, DbConnectionStringDefaults.IdleTimeout); _maxPoolSize = ConvertValueToInt32(DbConnectionStringKeywords.MaxPoolSize, DbConnectionStringDefaults.MaxPoolSize); _minPoolSize = ConvertValueToInt32(DbConnectionStringKeywords.MinPoolSize, DbConnectionStringDefaults.MinPoolSize); _packetSize = ConvertValueToInt32(DbConnectionStringKeywords.PacketSize, DbConnectionStringDefaults.PacketSize); @@ -318,6 +322,11 @@ internal SqlConnectionOptions(string connectionString) throw ADP.InvalidConnectionOptionValue(DbConnectionStringKeywords.LoadBalanceTimeout); } + if (_idleTimeout < 0) + { + throw ADP.InvalidConnectionOptionValue(DbConnectionStringKeywords.IdleTimeout); + } + if (_connectTimeout < 0) { throw ADP.InvalidConnectionOptionValue(DbConnectionStringKeywords.ConnectTimeout); @@ -579,6 +588,7 @@ internal SqlConnectionOptions(SqlConnectionOptions connectionOptions, string dat _commandTimeout = connectionOptions._commandTimeout; _connectTimeout = connectionOptions._connectTimeout; _loadBalanceTimeout = connectionOptions._loadBalanceTimeout; + _idleTimeout = connectionOptions._idleTimeout; _poolBlockingPeriod = connectionOptions._poolBlockingPeriod; _maxPoolSize = connectionOptions._maxPoolSize; _minPoolSize = connectionOptions._minPoolSize; @@ -650,6 +660,9 @@ internal SqlConnectionOptions(SqlConnectionOptions connectionOptions, string dat internal int CommandTimeout => _commandTimeout; internal int ConnectTimeout => _connectTimeout; internal int LoadBalanceTimeout => _loadBalanceTimeout; + // Maximum time (in seconds) a connection can sit idle in the pool before it is discarded + // on the next retrieval attempt. 0 disables idle expiration. + internal int IdleTimeout => _idleTimeout; internal int MaxPoolSize => _maxPoolSize; internal int MinPoolSize => _minPoolSize; internal int PacketSize => _packetSize; diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionStringBuilder.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionStringBuilder.cs index 4e54a32c75..c2d1b0c485 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionStringBuilder.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionStringBuilder.cs @@ -50,6 +50,7 @@ private enum Keywords ServerCertificate, TrustServerCertificate, LoadBalanceTimeout, + IdleTimeout, PacketSize, TypeSystemVersion, Authentication, @@ -101,6 +102,7 @@ private enum Keywords private int _commandTimeout = DbConnectionStringDefaults.CommandTimeout; private int _connectTimeout = DbConnectionStringDefaults.ConnectTimeout; private int _loadBalanceTimeout = DbConnectionStringDefaults.LoadBalanceTimeout; + private int _idleTimeout = DbConnectionStringDefaults.IdleTimeout; private int _maxPoolSize = DbConnectionStringDefaults.MaxPoolSize; private int _minPoolSize = DbConnectionStringDefaults.MinPoolSize; private int _packetSize = DbConnectionStringDefaults.PacketSize; @@ -155,6 +157,7 @@ private static string[] CreateValidKeywords() validKeywords[(int)Keywords.InitialCatalog] = DbConnectionStringKeywords.InitialCatalog; validKeywords[(int)Keywords.IntegratedSecurity] = DbConnectionStringKeywords.IntegratedSecurity; validKeywords[(int)Keywords.LoadBalanceTimeout] = DbConnectionStringKeywords.LoadBalanceTimeout; + validKeywords[(int)Keywords.IdleTimeout] = DbConnectionStringKeywords.IdleTimeout; validKeywords[(int)Keywords.MaxPoolSize] = DbConnectionStringKeywords.MaxPoolSize; validKeywords[(int)Keywords.MinPoolSize] = DbConnectionStringKeywords.MinPoolSize; validKeywords[(int)Keywords.MultipleActiveResultSets] = DbConnectionStringKeywords.MultipleActiveResultSets; @@ -212,6 +215,7 @@ private static Dictionary CreateKeywordsDictionary() { DbConnectionStringKeywords.InitialCatalog, Keywords.InitialCatalog }, { DbConnectionStringKeywords.IntegratedSecurity, Keywords.IntegratedSecurity }, { DbConnectionStringKeywords.LoadBalanceTimeout, Keywords.LoadBalanceTimeout }, + { DbConnectionStringKeywords.IdleTimeout, Keywords.IdleTimeout }, { DbConnectionStringKeywords.MultipleActiveResultSets, Keywords.MultipleActiveResultSets }, { DbConnectionStringKeywords.MaxPoolSize, Keywords.MaxPoolSize }, { DbConnectionStringKeywords.MinPoolSize, Keywords.MinPoolSize }, @@ -268,6 +272,7 @@ private static Dictionary CreateKeywordsDictionary() { DbConnectionStringSynonyms.TrustedConnection, Keywords.IntegratedSecurity }, { DbConnectionStringSynonyms.TrustServerCertificate, Keywords.TrustServerCertificate }, { DbConnectionStringSynonyms.ConnectionLifetime, Keywords.LoadBalanceTimeout }, + { DbConnectionStringSynonyms.PoolIdleTimeout, Keywords.IdleTimeout }, { DbConnectionStringSynonyms.Pwd, Keywords.Password }, { DbConnectionStringSynonyms.PersistSecurityInfo, Keywords.PersistSecurityInfo }, { DbConnectionStringSynonyms.Uid, Keywords.UserID }, @@ -349,6 +354,8 @@ private object GetAt(Keywords index) return IntegratedSecurity; case Keywords.LoadBalanceTimeout: return LoadBalanceTimeout; + case Keywords.IdleTimeout: + return IdleTimeout; case Keywords.MultipleActiveResultSets: return MultipleActiveResultSets; case Keywords.MaxPoolSize: @@ -481,6 +488,9 @@ private void Reset(Keywords index) case Keywords.LoadBalanceTimeout: _loadBalanceTimeout = DbConnectionStringDefaults.LoadBalanceTimeout; break; + case Keywords.IdleTimeout: + _idleTimeout = DbConnectionStringDefaults.IdleTimeout; + break; case Keywords.MultipleActiveResultSets: _multipleActiveResultSets = DbConnectionStringDefaults.MultipleActiveResultSets; break; @@ -979,6 +989,9 @@ public override object this[string keyword] case Keywords.LoadBalanceTimeout: LoadBalanceTimeout = ConvertToInt32(value); break; + case Keywords.IdleTimeout: + IdleTimeout = ConvertToInt32(value); + break; case Keywords.MaxPoolSize: MaxPoolSize = ConvertToInt32(value); break; @@ -1473,6 +1486,25 @@ public int LoadBalanceTimeout } } + /// + [DisplayName(DbConnectionStringKeywords.IdleTimeout)] + [ResCategory(nameof(Strings.DataCategory_Pooling))] + [ResDescription(nameof(Strings.DbConnectionString_IdleTimeout))] + [RefreshProperties(RefreshProperties.All)] + public int IdleTimeout + { + get => _idleTimeout; + set + { + if (value < 0) + { + throw ADP.InvalidConnectionOptionValue(DbConnectionStringKeywords.IdleTimeout); + } + SetValue(DbConnectionStringKeywords.IdleTimeout, value); + _idleTimeout = value; + } + } + /// [DisplayName(DbConnectionStringKeywords.MaxPoolSize)] [ResCategory(nameof(Strings.DataCategory_Pooling))] diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs b/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs index 041b9aa8aa..7979d14346 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs @@ -1428,6 +1428,15 @@ internal static string DbConnectionString_LoadBalanceTimeout { } } + /// + /// Looks up a localized string similar to The maximum amount of time (in seconds) a connection can sit unused (idle) in the pool before it is discarded on its next retrieval. A value of 0 disables idle expiration.. + /// + internal static string DbConnectionString_IdleTimeout { + get { + return ResourceManager.GetString("DbConnectionString_IdleTimeout", resourceCulture); + } + } + /// /// Looks up a localized string similar to The maximum number of connections allowed in the pool.. /// diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx index ca9dcf0a93..32a8c60335 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx @@ -960,6 +960,9 @@ The minimum amount of time (in seconds) for this connection to live in the pool before being destroyed. + + The maximum amount of time (in seconds) a connection can sit unused (idle) in the pool before it is discarded on its next retrieval. A value of 0 disables idle expiration. + The maximum number of connections allowed in the pool. diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionStringBuilderTest.cs b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionStringBuilderTest.cs index a24c330181..eba409bade 100644 --- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionStringBuilderTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionStringBuilderTest.cs @@ -250,6 +250,52 @@ public void SetInvalidLoadBalanceTimeout_Throws() Assert.Contains("load balance timeout", ex.Message, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void IdleTimeout_DefaultIsZero() + { + // Default-constructed builder should have IdleTimeout == 0 (disabled). + SqlConnectionStringBuilder builder = new SqlConnectionStringBuilder(); + Assert.Equal(0, builder.IdleTimeout); + } + + [Fact] + public void IdleTimeout_RoundTripsThroughConnectionString() + { + // Set via property, observe in ConnectionString; parse back and observe via property. + SqlConnectionStringBuilder builder = new SqlConnectionStringBuilder + { + IdleTimeout = 45 + }; + Assert.Contains("Connection Idle Timeout=45", builder.ConnectionString, StringComparison.OrdinalIgnoreCase); + + SqlConnectionStringBuilder parsed = new SqlConnectionStringBuilder(builder.ConnectionString); + Assert.Equal(45, parsed.IdleTimeout); + } + + [Fact] + public void IdleTimeout_CanonicalKeyword_Parses() + { + SqlConnectionStringBuilder builder = new SqlConnectionStringBuilder("Connection Idle Timeout=120"); + Assert.Equal(120, builder.IdleTimeout); + } + + [Fact] + public void IdleTimeout_SynonymPoolIdleTimeout_Parses() + { + // "Pool Idle Timeout" is a registered synonym -> same canonical property. + SqlConnectionStringBuilder builder = new SqlConnectionStringBuilder("Pool Idle Timeout=75"); + Assert.Equal(75, builder.IdleTimeout); + } + + [Fact] + public void SetInvalidIdleTimeout_Throws() + { + SqlConnectionStringBuilder builder = new SqlConnectionStringBuilder(); + + ArgumentException ex = Assert.Throws(() => builder.IdleTimeout = -1); + Assert.Contains("idle", ex.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] public void SetInvalidMaxPoolSize_Throws() { diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs index 7c1593af14..aef526c830 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs @@ -895,6 +895,160 @@ out DbConnectionInternal? newConnection #endregion + #region Idle Timeout Tests + + // Helper: build a pool whose IdleTimeout is the given number of seconds. + private ChannelDbConnectionPool ConstructPoolWithIdleTimeout(int idleTimeoutSeconds) + { + var poolGroupOptions = new DbConnectionPoolGroupOptions( + poolByIdentity: false, + minPoolSize: 0, + maxPoolSize: 50, + creationTimeout: 15, + loadBalanceTimeout: 0, + hasTransactionAffinity: true, + idleTimeout: idleTimeoutSeconds); + return ConstructPool(SuccessfulConnectionFactory, poolGroupOptions: poolGroupOptions); + } + + [Fact] + public void IdleTimeout_PoolGroupOptions_ConvertsSecondsToTimeSpan() + { + // 30 seconds in -> TimeSpan(0, 0, 30) out. + var poolGroupOptions = new DbConnectionPoolGroupOptions( + poolByIdentity: false, + minPoolSize: 0, + maxPoolSize: 50, + creationTimeout: 15, + loadBalanceTimeout: 0, + hasTransactionAffinity: true, + idleTimeout: 30); + + Assert.Equal(TimeSpan.FromSeconds(30), poolGroupOptions.IdleTimeout); + } + + [Fact] + public void IdleTimeout_DefaultIsZero_DisablesExpiry() + { + // Default ctor argument keeps idle expiry off. + var poolGroupOptions = new DbConnectionPoolGroupOptions( + poolByIdentity: false, + minPoolSize: 0, + maxPoolSize: 50, + creationTimeout: 15, + loadBalanceTimeout: 0, + hasTransactionAffinity: true); + + Assert.Equal(TimeSpan.Zero, poolGroupOptions.IdleTimeout); + } + + [Fact] + public void IdleTimeout_StampedOnReturn() + { + // Arrange - long idle timeout so the return path stamps (not evicts). + var pool = ConstructPoolWithIdleTimeout(idleTimeoutSeconds: 3600); + SqlConnection owningConnection = new(); + pool.TryGetConnection(owningConnection, taskCompletionSource: null, + out DbConnectionInternal? connection); + Assert.NotNull(connection); + + // Backdate by a small amount that's still well inside the idle window so the return path + // doesn't decide to evict instead of stamp. + BackdateIdleSince(connection, TimeSpan.FromSeconds(5)); + DateTime stampedBack = connection.IdleSinceUtc; + + // Act + DateTime before = DateTime.UtcNow; + pool.ReturnInternalConnection(connection, owningConnection); + DateTime after = DateTime.UtcNow; + + // Assert: stamp falls within the return window and is strictly newer than the backdated value. + Assert.InRange(connection.IdleSinceUtc, before, after); + Assert.True(connection.IdleSinceUtc > stampedBack); + } + + [Fact] + public void IdleTimeout_Zero_DoesNotExpire() + { + // Arrange - pool with idle expiry disabled + var pool = ConstructPoolWithIdleTimeout(idleTimeoutSeconds: 0); + SqlConnection owner = new(); + pool.TryGetConnection(owner, taskCompletionSource: null, + out DbConnectionInternal? first); + Assert.NotNull(first); + + // Return + back-date IdleSinceUtc to simulate a long sit. + pool.ReturnInternalConnection(first, owner); + BackdateIdleSince(first, TimeSpan.FromHours(1)); + + // Act + SqlConnection owner2 = new(); + pool.TryGetConnection(owner2, taskCompletionSource: null, + out DbConnectionInternal? second); + + // Assert - same instance, idle expiry disabled + Assert.Same(first, second); + Assert.Equal(1, pool.Count); + } + + [Fact] + public void IdleTimeout_Set_ExpiresOldConnection() + { + // Arrange - pool with 1-second idle timeout + var pool = ConstructPoolWithIdleTimeout(idleTimeoutSeconds: 1); + SqlConnection owner = new(); + pool.TryGetConnection(owner, taskCompletionSource: null, + out DbConnectionInternal? first); + Assert.NotNull(first); + + // Return + back-date IdleSinceUtc beyond the timeout. + pool.ReturnInternalConnection(first, owner); + BackdateIdleSince(first, TimeSpan.FromSeconds(5)); + + // Act - request another connection + SqlConnection owner2 = new(); + pool.TryGetConnection(owner2, taskCompletionSource: null, + out DbConnectionInternal? second); + + // Assert - the expired one is discarded; a new one is minted. + Assert.NotNull(second); + Assert.NotSame(first, second); + Assert.Equal(1, pool.Count); + } + + [Fact] + public void IdleTimeout_Set_KeepsFreshConnection() + { + // Arrange - 60-second idle timeout, connection just returned + var pool = ConstructPoolWithIdleTimeout(idleTimeoutSeconds: 60); + SqlConnection owner = new(); + pool.TryGetConnection(owner, taskCompletionSource: null, + out DbConnectionInternal? first); + Assert.NotNull(first); + pool.ReturnInternalConnection(first, owner); + + // Act - immediately request another connection + SqlConnection owner2 = new(); + pool.TryGetConnection(owner2, taskCompletionSource: null, + out DbConnectionInternal? second); + + // Assert - same instance reused, well within idle window + Assert.Same(first, second); + } + + // Forcibly rewinds a connection's IdleSinceUtc by the given amount so tests don't have to sleep. + // Uses reflection because the setter is private by design (only the pool's return path stamps it). + private static void BackdateIdleSince(DbConnectionInternal connection, TimeSpan delta) + { + var prop = typeof(DbConnectionInternal).GetProperty( + nameof(DbConnectionInternal.IdleSinceUtc), + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public); + Assert.NotNull(prop); + prop!.SetValue(connection, DateTime.UtcNow - delta); + } + + #endregion + #region Test classes internal class SuccessfulSqlConnectionFactory : SqlConnectionFactory { From 2a94926d057af32d060689256d5dedfacdc6ffa4 Mon Sep 17 00:00:00 2001 From: Priyanka Tiwari Date: Wed, 20 May 2026 15:40:30 +0530 Subject: [PATCH 2/7] Address Copilot review feedback on #4295 - Fix stale comment in ChannelDbConnectionPool.IsLiveConnection: IdleSinceUtc is initialized to CreateTime, not DateTime.MinValue. - Add WaitHandleDbConnectionPoolIdleTimeoutTest mirroring the existing channel-pool idle-timeout coverage (stamp on return, zero disables expiry, expired connection is replaced, fresh connection is reused). --- .../ConnectionPool/ChannelDbConnectionPool.cs | 4 +- ...itHandleDbConnectionPoolIdleTimeoutTest.cs | 160 ++++++++++++++++++ 2 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolIdleTimeoutTest.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 03dccadee7..4cdc05f04c 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 @@ -440,8 +440,8 @@ private bool IsLiveConnection(DbConnectionInternal connection) } // Connection has been sitting idle longer than the configured idle timeout. - // IdleSinceUtc is stamped by ReturnInternalConnection on each return; if it is the default - // (DateTime.MinValue), the connection has never been pooled yet and the check is a no-op. + // IdleSinceUtc is initialized to CreateTime so a freshly minted connection never trips this + // check on first retrieval, and is then stamped by ReturnInternalConnection on every return. TimeSpan idleTimeout = PoolGroupOptions.IdleTimeout; if (idleTimeout != TimeSpan.Zero && DateTime.UtcNow > connection.IdleSinceUtc + idleTimeout) { diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolIdleTimeoutTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolIdleTimeoutTest.cs new file mode 100644 index 0000000000..57d72f439c --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolIdleTimeoutTest.cs @@ -0,0 +1,160 @@ +// 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.Reflection; +using Microsoft.Data.Common.ConnectionString; +using Microsoft.Data.ProviderBase; +using Microsoft.Data.SqlClient.ConnectionPool; +using Xunit; + +namespace Microsoft.Data.SqlClient.UnitTests.ConnectionPool; + +/// +/// Deterministic tests for WaitHandleDbConnectionPool idle-timeout enforcement. +/// Mirrors the corresponding tests in so that the +/// retrieval-side idle-expiry behavior is covered for both pool implementations. +/// +public class WaitHandleDbConnectionPoolIdleTimeoutTest : IDisposable +{ + private const int DefaultMaxPoolSize = 50; + private const int DefaultMinPoolSize = 0; + private const int DefaultCreationTimeoutInMilliseconds = 15000; + + private WaitHandleDbConnectionPool _pool = null!; + + public void Dispose() + { + _pool?.Shutdown(); + _pool?.Clear(); + } + + private WaitHandleDbConnectionPool CreatePool(int idleTimeoutSeconds) + { + var poolGroupOptions = new DbConnectionPoolGroupOptions( + poolByIdentity: false, + minPoolSize: DefaultMinPoolSize, + maxPoolSize: DefaultMaxPoolSize, + creationTimeout: DefaultCreationTimeoutInMilliseconds, + loadBalanceTimeout: 0, + hasTransactionAffinity: true, + idleTimeout: idleTimeoutSeconds); + + var dbConnectionPoolGroup = new DbConnectionPoolGroup( + new SqlConnectionOptions("Data Source=localhost;"), + new ConnectionPoolKey("TestDataSource", credential: null, accessToken: null, accessTokenCallback: null, sspiContextProvider: null), + poolGroupOptions); + + var pool = new WaitHandleDbConnectionPool( + new WaitHandleDbConnectionPoolTransactionTest.MockSqlConnectionFactory(), + dbConnectionPoolGroup, + DbConnectionPoolIdentity.NoIdentity, + new DbConnectionPoolProviderInfo()); + + pool.Startup(); + return pool; + } + + private DbConnectionInternal GetConnection(SqlConnection owner) + { + _pool.TryGetConnection( + owner, + taskCompletionSource: null, + out DbConnectionInternal? connection); + Assert.NotNull(connection); + return connection!; + } + + // Forcibly rewinds a connection's IdleSinceUtc by the given amount so tests don't have to sleep. + // Uses reflection because the setter is private by design (only the pool's return path stamps it). + private static void BackdateIdleSince(DbConnectionInternal connection, TimeSpan delta) + { + var prop = typeof(DbConnectionInternal).GetProperty( + nameof(DbConnectionInternal.IdleSinceUtc), + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + Assert.NotNull(prop); + prop!.SetValue(connection, DateTime.UtcNow - delta); + } + + [Fact] + public void IdleTimeout_StampedOnReturn() + { + // Arrange - long idle timeout so the return path stamps (not evicts). + _pool = CreatePool(idleTimeoutSeconds: 3600); + SqlConnection owner = new(); + DbConnectionInternal connection = GetConnection(owner); + + // Backdate by a small amount that's still well inside the idle window so the return path + // doesn't decide to evict instead of stamp. + BackdateIdleSince(connection, TimeSpan.FromSeconds(5)); + DateTime stampedBack = connection.IdleSinceUtc; + + // Act + DateTime before = DateTime.UtcNow; + _pool.ReturnInternalConnection(connection, owner); + DateTime after = DateTime.UtcNow; + + // Assert: stamp falls within the return window and is strictly newer than the backdated value. + Assert.InRange(connection.IdleSinceUtc, before, after); + Assert.True(connection.IdleSinceUtc > stampedBack); + } + + [Fact] + public void IdleTimeout_Zero_DoesNotExpire() + { + // Arrange - pool with idle expiry disabled + _pool = CreatePool(idleTimeoutSeconds: 0); + SqlConnection owner = new(); + DbConnectionInternal first = GetConnection(owner); + + // Return + back-date IdleSinceUtc to simulate a long sit. + _pool.ReturnInternalConnection(first, owner); + BackdateIdleSince(first, TimeSpan.FromHours(1)); + + // Act + SqlConnection owner2 = new(); + DbConnectionInternal second = GetConnection(owner2); + + // Assert - same instance, idle expiry disabled + Assert.Same(first, second); + Assert.Equal(1, _pool.Count); + } + + [Fact] + public void IdleTimeout_Set_ExpiresOldConnection() + { + // Arrange - pool with 1-second idle timeout + _pool = CreatePool(idleTimeoutSeconds: 1); + SqlConnection owner = new(); + DbConnectionInternal first = GetConnection(owner); + + // Return + back-date IdleSinceUtc beyond the timeout. + _pool.ReturnInternalConnection(first, owner); + BackdateIdleSince(first, TimeSpan.FromSeconds(5)); + + // Act - request another connection + SqlConnection owner2 = new(); + DbConnectionInternal second = GetConnection(owner2); + + // Assert - the expired one is discarded; a new one is minted. + Assert.NotSame(first, second); + } + + [Fact] + public void IdleTimeout_Set_KeepsFreshConnection() + { + // Arrange - 60-second idle timeout, connection just returned + _pool = CreatePool(idleTimeoutSeconds: 60); + SqlConnection owner = new(); + DbConnectionInternal first = GetConnection(owner); + _pool.ReturnInternalConnection(first, owner); + + // Act - immediately request another connection + SqlConnection owner2 = new(); + DbConnectionInternal second = GetConnection(owner2); + + // Assert - same instance reused, well within idle window + Assert.Same(first, second); + } +} From 93ab7eeef0981d5b45b64ff105f109dc87e66ce7 Mon Sep 17 00:00:00 2001 From: Priyanka Tiwari Date: Thu, 21 May 2026 17:05:00 +0530 Subject: [PATCH 3/7] Address PR #4295 inline review feedback - Skip MarkPooledIdle on return when IdleTimeout == TimeSpan.Zero so the default config has no per-return DateTime.UtcNow on the hot path. Applies to ChannelDbConnectionPool.ReturnInternalConnection and WaitHandleDbConnectionPool.PutNewObject. - Stamp IdleSinceUtc when returning a connection into the transacted pool (WaitHandleDbConnectionPool.DeactivateObject before TransactedConnectionPool.PutTransactedObject) so idle expiry on the next retrieval measures time spent parked in the transacted pool, not time since create-time / last general-pool return. - Add 2 WaitHandle pool tests covering the new behavior: IdleTimeout_TransactedPool_StampsOnReturn and IdleTimeout_Zero_DoesNotStampOnReturn. --- .../ConnectionPool/ChannelDbConnectionPool.cs | 7 ++- .../WaitHandleDbConnectionPool.cs | 15 +++++- ...itHandleDbConnectionPoolIdleTimeoutTest.cs | 49 +++++++++++++++++++ 3 files changed, 69 insertions(+), 2 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 4cdc05f04c..77ffef27fc 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 @@ -256,7 +256,12 @@ public void ReturnInternalConnection(DbConnectionInternal connection, DbConnecti { // Stamp the idle-since timestamp immediately before putting the connection back in the // pool so that IsLiveConnection can later evict it if it sits idle past the configured limit. - connection.MarkPooledIdle(); + // Skip the stamp when idle expiry is disabled (the default) to avoid the per-return + // DateTime.UtcNow on the hot return path. + if (PoolGroupOptions.IdleTimeout != TimeSpan.Zero) + { + connection.MarkPooledIdle(); + } var written = _idleChannel.TryWrite(connection); Debug.Assert(written, "Failed to write returning connection to the idle channel."); } diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs index 55adcc0559..0590a38fb2 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs @@ -670,6 +670,14 @@ private void DeactivateObject(DbConnectionInternal obj) // DelegatedTransactionEnded event will clean up the // connection appropriately regardless of the pool state. Debug.Assert(_transactedConnectionPool != null, "Transacted connection pool was not expected to be null."); + // Stamp the idle-since timestamp before parking the connection in the transacted + // pool so the next retrieval measures idle time from when it left the active set, + // not from create-time or the previous general-pool return. Skip when idle expiry + // is disabled to avoid an unnecessary DateTime.UtcNow on the hot return path. + if (PoolGroupOptions.IdleTimeout != TimeSpan.Zero) + { + obj.MarkPooledIdle(); + } _transactedConnectionPool.PutTransactedObject(transaction, obj); rootTxn = true; } @@ -1331,7 +1339,12 @@ private void PutNewObject(DbConnectionInternal obj) // Stamp the idle-since timestamp immediately before placing the connection on the idle stack // so that idle-expiry checks on later retrieval can decide whether it has sat unused too long. - obj.MarkPooledIdle(); + // Skip the stamp when idle expiry is disabled (the default) to avoid the per-return + // DateTime.UtcNow on the hot return path. + if (PoolGroupOptions.IdleTimeout != TimeSpan.Zero) + { + obj.MarkPooledIdle(); + } _stackNew.Push(obj); _waitHandles.PoolSemaphore.Release(1); diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolIdleTimeoutTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolIdleTimeoutTest.cs index 57d72f439c..1815c551fb 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolIdleTimeoutTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolIdleTimeoutTest.cs @@ -4,6 +4,7 @@ using System; using System.Reflection; +using System.Transactions; using Microsoft.Data.Common.ConnectionString; using Microsoft.Data.ProviderBase; using Microsoft.Data.SqlClient.ConnectionPool; @@ -157,4 +158,52 @@ public void IdleTimeout_Set_KeepsFreshConnection() // Assert - same instance reused, well within idle window Assert.Same(first, second); } + + [Fact] + public void IdleTimeout_TransactedPool_StampsOnReturn() + { + // Regression test: returning a connection into the transacted pool must stamp IdleSinceUtc + // so that idle-expiry on the next retrieval measures time spent parked there, not time since + // create-time / last general-pool return. + _pool = CreatePool(idleTimeoutSeconds: 3600); + + using var scope = new TransactionScope(); + Assert.NotNull(Transaction.Current); + + SqlConnection owner = new(); + DbConnectionInternal connection = GetConnection(owner); + + // Backdate the stamp to a clearly-old value before returning so we can assert the return + // path actually re-stamped it (and didn't just leave the create-time value). + BackdateIdleSince(connection, TimeSpan.FromMinutes(30)); + DateTime stampedBack = connection.IdleSinceUtc; + + DateTime before = DateTime.UtcNow; + _pool.ReturnInternalConnection(connection, owner); + DateTime after = DateTime.UtcNow; + + // Assert - stamp was refreshed during the return-to-transacted-pool path. + Assert.InRange(connection.IdleSinceUtc, before, after); + Assert.True(connection.IdleSinceUtc > stampedBack); + + scope.Complete(); + } + + [Fact] + public void IdleTimeout_Zero_DoesNotStampOnReturn() + { + // When idle-timeout is disabled, the return path must skip the stamp so the default config + // does not pay a per-return DateTime.UtcNow on the hot path. A connection's IdleSinceUtc is + // initialized to CreateTime and should remain at that value when expiry is off. + _pool = CreatePool(idleTimeoutSeconds: 0); + + SqlConnection owner = new(); + DbConnectionInternal connection = GetConnection(owner); + DateTime stampAtAcquire = connection.IdleSinceUtc; + + _pool.ReturnInternalConnection(connection, owner); + + // Assert - stamp was NOT refreshed (return path is a no-op when feature disabled). + Assert.Equal(stampAtAcquire, connection.IdleSinceUtc); + } } From dd30ce7d4f9f8479714824be353900ab04211b3a Mon Sep 17 00:00:00 2001 From: Priyanka Tiwari Date: Tue, 26 May 2026 20:35:44 +0530 Subject: [PATCH 4/7] Address PR review: default IdleTimeout=300, remove synonym, drop transacted idle handling - Default Connection Idle Timeout 0 -> 300 (5 min, matches Npgsql); 0 disables. - Remove 'Pool Idle Timeout' synonym; the canonical keyword is the only accepted form. - Make idleTimeout a required parameter on DbConnectionPoolGroupOptions; defaults now live in DbConnectionStringDefaults. - Use TimeSpan.FromSeconds for the ctor body conversion. - WaitHandle pool: drop MarkPooledIdle() on transacted-pool return and remove the idle-expiry check on transacted-pool retrieval (transacting connections must never be proactively closed). - WaitHandle pool: reorder general-pool retrieval to check idle expiry before the liveness probe; derive _cleanupWait from IdleTimeout when set. - Channel pool: reorder IsLiveConnection so the idle check runs before IsConnectionAlive(). - Tests, doc snippet, and release notes updated accordingly. --- .../SqlConnectionStringBuilder.xml | 4 +-- release-notes/7.1/7.1.0-preview1.md | 17 ++++++++++ .../DbConnectionStringDefaults.cs | 2 +- .../DbConnectionStringSynonyms.cs | 1 - .../ConnectionPool/ChannelDbConnectionPool.cs | 20 ++++++------ .../ConnectionPool/DbConnectionPoolOptions.cs | 4 +-- .../WaitHandleDbConnectionPool.cs | 27 +++++++++------- .../Data/SqlClient/SqlConnectionOptions.cs | 3 +- .../SqlClient/SqlConnectionStringBuilder.cs | 1 - .../SqlConnectionStringBuilderTest.cs | 14 ++------ .../ChannelDbConnectionPoolTest.cs | 32 ++++++++++++------- ...itHandleDbConnectionPoolIdleTimeoutTest.cs | 31 ------------------ ...itHandleDbConnectionPoolTransactionTest.cs | 3 +- 13 files changed, 75 insertions(+), 84 deletions(-) diff --git a/doc/snippets/Microsoft.Data.SqlClient/SqlConnectionStringBuilder.xml b/doc/snippets/Microsoft.Data.SqlClient/SqlConnectionStringBuilder.xml index cf7bd4cd34..adfa5a13c7 100644 --- a/doc/snippets/Microsoft.Data.SqlClient/SqlConnectionStringBuilder.xml +++ b/doc/snippets/Microsoft.Data.SqlClient/SqlConnectionStringBuilder.xml @@ -984,11 +984,11 @@ The following example converts an existing connection string from using SQL Serv Gets or sets the maximum time, in seconds, that a connection can sit unused (idle) in the connection pool before it is discarded on its next retrieval. - The value of the property, or 0 if none has been supplied. + The value of the property, or 300 (5 minutes) if none has been supplied. - This property corresponds to the "Connection Idle Timeout" key (synonym: "Pool Idle Timeout") within the connection string. + This property corresponds to the "Connection Idle Timeout" key within the connection string. When a caller retrieves a connection from the pool, the driver checks how long the connection has been sitting idle. If the idle duration exceeds the value of Connection Idle Timeout, the connection is discarded and a different valid or newly-created connection is returned instead. This protects callers from receiving connections that may have been silently closed by firewalls, load balancers, or server-side inactivity thresholds. diff --git a/release-notes/7.1/7.1.0-preview1.md b/release-notes/7.1/7.1.0-preview1.md index beb95c6a7b..e7c8067bac 100644 --- a/release-notes/7.1/7.1.0-preview1.md +++ b/release-notes/7.1/7.1.0-preview1.md @@ -37,6 +37,23 @@ This update brings the following changes since the [7.0.0](../7.0/7.0.0.md) rele - Existing canonical keywords continue to work unchanged; this preview simply accepts more equivalent aliases during parsing. +#### `Connection Idle Timeout` Connection-String Keyword + +*What Changed:* + +- Added a new `Connection Idle Timeout` connection-string keyword (integer, seconds) that bounds how long an idle pooled connection may sit in the pool before it is discarded and replaced on its next retrieval. + ([#4295](https://github.com/dotnet/SqlClient/pull/4295)) + +*Who Benefits:* + +- Applications behind firewalls, load balancers, or proxies with idle-connection limits avoid receiving a connection that has been silently closed by an intermediary. +- Long-lived pools that span credential rotation or server-side inactivity policies recycle stale connections proactively. + +*Impact:* + +- The default value is `300` seconds (5 minutes), matching Npgsql. A value of `0` disables idle expiration. Negative values are rejected. +- Existing connection strings that omit the keyword will now expire idle connections after 5 minutes; previously connections were retained indefinitely (subject to other expiry rules such as `Load Balance Timeout`). + ### Changed #### Connection Pool Clearing Now Works With Pool v2 (Experimental) diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringDefaults.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringDefaults.cs index 4a3b9771cf..bca2af6b28 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringDefaults.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringDefaults.cs @@ -36,7 +36,7 @@ internal static class DbConnectionStringDefaults internal const bool IntegratedSecurity = false; internal const SqlConnectionIPAddressPreference IpAddressPreference = SqlConnectionIPAddressPreference.IPv4First; internal const int LoadBalanceTimeout = 0; // default of 0 means don't use - internal const int IdleTimeout = 0; // default of 0 means don't expire idle connections + internal const int IdleTimeout = 300; // 5 minutes, matches Npgsql. 0 disables idle expiration. internal const int MaxPoolSize = 100; internal const int MinPoolSize = 0; internal const bool MultipleActiveResultSets = false; diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringSynonyms.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringSynonyms.cs index 922d08ce73..15c32abe75 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringSynonyms.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringSynonyms.cs @@ -32,7 +32,6 @@ internal static class DbConnectionStringSynonyms internal const string PacketSize = "packetsize"; internal const string PersistSecurityInfo = "persistsecurityinfo"; internal const string PoolBlockingPeriod = "poolblockingperiod"; - internal const string PoolIdleTimeout = "pool idle timeout"; internal const string Pwd = "pwd"; internal const string Server = "server"; internal const string ServerCertificate = "servercertificate"; 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 77ffef27fc..10629043c0 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 @@ -432,23 +432,25 @@ public bool TryGetConnection( /// Returns true if the connection is live and unexpired, otherwise returns false. private bool IsLiveConnection(DbConnectionInternal connection) { - // Broken physical connection - if (!connection.IsConnectionAlive()) + // Connection has been sitting idle longer than the configured idle timeout. + // Checked before the (potentially expensive) liveness probe so an idle-expired + // connection is discarded without an SNI round-trip. + // IdleSinceUtc is initialized to CreateTime so a freshly minted connection never trips this + // check on first retrieval, and is then stamped by ReturnInternalConnection on every return. + TimeSpan idleTimeout = PoolGroupOptions.IdleTimeout; + if (idleTimeout != TimeSpan.Zero && DateTime.UtcNow > connection.IdleSinceUtc + idleTimeout) { return false; } - // Connection has been alive longer than the load balance timeout - if (LoadBalanceTimeout != TimeSpan.Zero && DateTime.UtcNow > connection.CreateTime + LoadBalanceTimeout) + // Broken physical connection + if (!connection.IsConnectionAlive()) { return false; } - // Connection has been sitting idle longer than the configured idle timeout. - // IdleSinceUtc is initialized to CreateTime so a freshly minted connection never trips this - // check on first retrieval, and is then stamped by ReturnInternalConnection on every return. - TimeSpan idleTimeout = PoolGroupOptions.IdleTimeout; - if (idleTimeout != TimeSpan.Zero && DateTime.UtcNow > connection.IdleSinceUtc + idleTimeout) + // Connection has been alive longer than the load balance timeout + if (LoadBalanceTimeout != TimeSpan.Zero && DateTime.UtcNow > connection.CreateTime + LoadBalanceTimeout) { return false; } diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolOptions.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolOptions.cs index bb6721c4c8..40815e04e5 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolOptions.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolOptions.cs @@ -24,7 +24,7 @@ public DbConnectionPoolGroupOptions( int creationTimeout, int loadBalanceTimeout, bool hasTransactionAffinity, - int idleTimeout = 0 + int idleTimeout ) { _poolByIdentity = poolByIdentity; @@ -40,7 +40,7 @@ public DbConnectionPoolGroupOptions( if (0 != idleTimeout) { - _idleTimeout = new TimeSpan(0, 0, idleTimeout); + _idleTimeout = TimeSpan.FromSeconds(idleTimeout); } _hasTransactionAffinity = hasTransactionAffinity; diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs index 0590a38fb2..1200191d0e 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs @@ -221,8 +221,13 @@ internal WaitHandleDbConnectionPool( lock (s_random) { - // Random.Next is not thread-safe - _cleanupWait = s_random.Next(12, 24) * 10 * 1000; // 2-4 minutes in 10 sec intervals, WebData 103603 + // Drive the cleanup cadence from the configured idle timeout so a configured pool + // prunes at roughly the idle-expiration interval. When idle expiration is disabled + // (IdleTimeout == 0) fall back to the historical 2-4 minute random window. + TimeSpan idleTimeout = connectionPoolGroup.PoolGroupOptions.IdleTimeout; + _cleanupWait = idleTimeout != TimeSpan.Zero + ? (int)idleTimeout.TotalMilliseconds + : s_random.Next(12, 24) * 10 * 1000; // 2-4 minutes in 10 sec intervals, WebData 103603 } _connectionFactory = connectionFactory; @@ -670,14 +675,10 @@ private void DeactivateObject(DbConnectionInternal obj) // DelegatedTransactionEnded event will clean up the // connection appropriately regardless of the pool state. Debug.Assert(_transactedConnectionPool != null, "Transacted connection pool was not expected to be null."); - // Stamp the idle-since timestamp before parking the connection in the transacted - // pool so the next retrieval measures idle time from when it left the active set, - // not from create-time or the previous general-pool return. Skip when idle expiry - // is disabled to avoid an unnecessary DateTime.UtcNow on the hot return path. - if (PoolGroupOptions.IdleTimeout != TimeSpan.Zero) - { - obj.MarkPooledIdle(); - } + // Transacting connections are held in their own store and are never + // proactively closed (doing so would abort the transaction, which can be + // distributed). Idle-timeout enforcement does not apply here, so we do + // not stamp IdleSinceUtc when parking the connection in the transacted pool. _transactedConnectionPool.PutTransactedObject(transaction, obj); rootTxn = true; } @@ -1036,7 +1037,7 @@ private bool TryGetConnection(DbConnection owningObject, uint waitForMultipleObj Interlocked.Decrement(ref _waitCount); obj = GetFromGeneralPool(); - if ((obj != null) && (!obj.IsConnectionAlive() || IsIdleExpired(obj))) + if ((obj != null) && (IsIdleExpired(obj) || !obj.IsConnectionAlive())) { SqlClientEventSource.Log.TryPoolerTraceEvent(" {0}, Connection {1}, found dead and removed.", Id, obj.ObjectID); DestroyObject(obj); @@ -1215,8 +1216,10 @@ private DbConnectionInternal GetFromTransactedPool(out Transaction transaction) throw; } } - else if (!obj.IsConnectionAlive() || IsIdleExpired(obj)) + else if (!obj.IsConnectionAlive()) { + // Transacting connections are exempt from idle-timeout eviction (closing them + // would abort the transaction, possibly distributed). Only liveness is checked here. SqlClientEventSource.Log.TryPoolerTraceEvent(" {0}, Connection {1}, found dead and removed.", Id, obj.ObjectID); DestroyObject(obj); obj = null; diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionOptions.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionOptions.cs index 75ff79ae34..7f43925e31 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionOptions.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionOptions.cs @@ -196,8 +196,7 @@ static SqlConnectionOptions() DbConnectionStringSynonyms.IpAddressPreference); AddKeywordToMap(DbConnectionStringKeywords.LoadBalanceTimeout, DbConnectionStringSynonyms.ConnectionLifetime); - AddKeywordToMap(DbConnectionStringKeywords.IdleTimeout, - DbConnectionStringSynonyms.PoolIdleTimeout); + AddKeywordToMap(DbConnectionStringKeywords.IdleTimeout); AddKeywordToMap(DbConnectionStringKeywords.MultipleActiveResultSets, DbConnectionStringSynonyms.MultipleActiveResultSets); AddKeywordToMap(DbConnectionStringKeywords.MaxPoolSize); diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionStringBuilder.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionStringBuilder.cs index c2d1b0c485..4038dbb295 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionStringBuilder.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionStringBuilder.cs @@ -272,7 +272,6 @@ private static Dictionary CreateKeywordsDictionary() { DbConnectionStringSynonyms.TrustedConnection, Keywords.IntegratedSecurity }, { DbConnectionStringSynonyms.TrustServerCertificate, Keywords.TrustServerCertificate }, { DbConnectionStringSynonyms.ConnectionLifetime, Keywords.LoadBalanceTimeout }, - { DbConnectionStringSynonyms.PoolIdleTimeout, Keywords.IdleTimeout }, { DbConnectionStringSynonyms.Pwd, Keywords.Password }, { DbConnectionStringSynonyms.PersistSecurityInfo, Keywords.PersistSecurityInfo }, { DbConnectionStringSynonyms.Uid, Keywords.UserID }, diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionStringBuilderTest.cs b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionStringBuilderTest.cs index eba409bade..180fe07209 100644 --- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionStringBuilderTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionStringBuilderTest.cs @@ -251,11 +251,11 @@ public void SetInvalidLoadBalanceTimeout_Throws() } [Fact] - public void IdleTimeout_DefaultIsZero() + public void IdleTimeout_DefaultIs300() { - // Default-constructed builder should have IdleTimeout == 0 (disabled). + // Default-constructed builder should have IdleTimeout == 300 (5 minutes), matching Npgsql. SqlConnectionStringBuilder builder = new SqlConnectionStringBuilder(); - Assert.Equal(0, builder.IdleTimeout); + Assert.Equal(300, builder.IdleTimeout); } [Fact] @@ -279,14 +279,6 @@ public void IdleTimeout_CanonicalKeyword_Parses() Assert.Equal(120, builder.IdleTimeout); } - [Fact] - public void IdleTimeout_SynonymPoolIdleTimeout_Parses() - { - // "Pool Idle Timeout" is a registered synonym -> same canonical property. - SqlConnectionStringBuilder builder = new SqlConnectionStringBuilder("Pool Idle Timeout=75"); - Assert.Equal(75, builder.IdleTimeout); - } - [Fact] public void SetInvalidIdleTimeout_Throws() { diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs index aef526c830..5f65792c66 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs @@ -33,7 +33,8 @@ private ChannelDbConnectionPool ConstructPool(SqlConnectionFactory connectionFac maxPoolSize: 50, creationTimeout: 15, loadBalanceTimeout: 0, - hasTransactionAffinity: true + hasTransactionAffinity: true, + idleTimeout: 0 ); dbConnectionPoolGroup ??= new DbConnectionPoolGroup( new SqlConnectionOptions("Data Source=localhost;"), @@ -578,7 +579,8 @@ public void TestLoadBalanceTimeout() maxPoolSize: 50, creationTimeout: 15, loadBalanceTimeout: 500, - hasTransactionAffinity: true + hasTransactionAffinity: true, + idleTimeout: 0 ); var pool = ConstructPool(SuccessfulConnectionFactory, poolGroupOptions: poolGroupOptions); Assert.Equal(poolGroupOptions.LoadBalanceTimeout, pool.LoadBalanceTimeout); @@ -596,7 +598,8 @@ public void TestPoolGroup() maxPoolSize: 50, creationTimeout: 15, loadBalanceTimeout: 500, - hasTransactionAffinity: true)); + hasTransactionAffinity: true, + idleTimeout: 0)); var pool = ConstructPool(SuccessfulConnectionFactory, dbConnectionPoolGroup: dbConnectionPoolGroup); Assert.Equal(dbConnectionPoolGroup, pool.PoolGroup); } @@ -610,7 +613,8 @@ public void TestPoolGroupOptions() maxPoolSize: 50, creationTimeout: 15, loadBalanceTimeout: 500, - hasTransactionAffinity: true); + hasTransactionAffinity: true, + idleTimeout: 0); var pool = ConstructPool(SuccessfulConnectionFactory, poolGroupOptions: poolGroupOptions); Assert.Equal(poolGroupOptions, pool.PoolGroupOptions); } @@ -646,7 +650,8 @@ public void TestUseLoadBalancing() maxPoolSize: 50, creationTimeout: 15, loadBalanceTimeout: 500, - hasTransactionAffinity: true); + hasTransactionAffinity: true, + idleTimeout: 0); var pool = ConstructPool(SuccessfulConnectionFactory, poolGroupOptions: poolGroupOptions); Assert.Equal(poolGroupOptions.UseLoadBalancing, pool.UseLoadBalancing); } @@ -930,14 +935,15 @@ public void IdleTimeout_PoolGroupOptions_ConvertsSecondsToTimeSpan() [Fact] public void IdleTimeout_DefaultIsZero_DisablesExpiry() { - // Default ctor argument keeps idle expiry off. + // Explicitly passing zero keeps idle expiry off. var poolGroupOptions = new DbConnectionPoolGroupOptions( poolByIdentity: false, minPoolSize: 0, maxPoolSize: 50, creationTimeout: 15, loadBalanceTimeout: 0, - hasTransactionAffinity: true); + hasTransactionAffinity: true, + idleTimeout: 0); Assert.Equal(TimeSpan.Zero, poolGroupOptions.IdleTimeout); } @@ -1119,7 +1125,8 @@ public void Constructor_WithZeroMaxPoolSize_ThrowsArgumentOutOfRangeException() maxPoolSize: 0, // This should cause an exception creationTimeout: 15, loadBalanceTimeout: 0, - hasTransactionAffinity: true + hasTransactionAffinity: true, + idleTimeout: 0 ); var dbConnectionPoolGroup = new DbConnectionPoolGroup( new SqlConnectionOptions("Data Source=localhost;"), @@ -1150,7 +1157,8 @@ public void Constructor_WithLargeMaxPoolSize() maxPoolSize: 10000, creationTimeout: 15, loadBalanceTimeout: 0, - hasTransactionAffinity: true + hasTransactionAffinity: true, + idleTimeout: 0 ); var dbConnectionPoolGroup = new DbConnectionPoolGroup( new SqlConnectionOptions("Data Source=localhost;"), @@ -1191,7 +1199,8 @@ public void Constructor_WithValidSmallPoolSizes_WorksCorrectly() maxPoolSize: 1, creationTimeout: 15, loadBalanceTimeout: 0, - hasTransactionAffinity: true + hasTransactionAffinity: true, + idleTimeout: 0 ); var dbConnectionPoolGroup1 = new DbConnectionPoolGroup( new SqlConnectionOptions("Data Source=localhost;"), @@ -1217,7 +1226,8 @@ public void Constructor_WithValidSmallPoolSizes_WorksCorrectly() maxPoolSize: 2, creationTimeout: 15, loadBalanceTimeout: 0, - hasTransactionAffinity: true + hasTransactionAffinity: true, + idleTimeout: 0 ); var dbConnectionPoolGroup2 = new DbConnectionPoolGroup( new SqlConnectionOptions("Data Source=localhost;"), diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolIdleTimeoutTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolIdleTimeoutTest.cs index 1815c551fb..28ae51c3db 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolIdleTimeoutTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolIdleTimeoutTest.cs @@ -4,7 +4,6 @@ using System; using System.Reflection; -using System.Transactions; using Microsoft.Data.Common.ConnectionString; using Microsoft.Data.ProviderBase; using Microsoft.Data.SqlClient.ConnectionPool; @@ -159,36 +158,6 @@ public void IdleTimeout_Set_KeepsFreshConnection() Assert.Same(first, second); } - [Fact] - public void IdleTimeout_TransactedPool_StampsOnReturn() - { - // Regression test: returning a connection into the transacted pool must stamp IdleSinceUtc - // so that idle-expiry on the next retrieval measures time spent parked there, not time since - // create-time / last general-pool return. - _pool = CreatePool(idleTimeoutSeconds: 3600); - - using var scope = new TransactionScope(); - Assert.NotNull(Transaction.Current); - - SqlConnection owner = new(); - DbConnectionInternal connection = GetConnection(owner); - - // Backdate the stamp to a clearly-old value before returning so we can assert the return - // path actually re-stamped it (and didn't just leave the create-time value). - BackdateIdleSince(connection, TimeSpan.FromMinutes(30)); - DateTime stampedBack = connection.IdleSinceUtc; - - DateTime before = DateTime.UtcNow; - _pool.ReturnInternalConnection(connection, owner); - DateTime after = DateTime.UtcNow; - - // Assert - stamp was refreshed during the return-to-transacted-pool path. - Assert.InRange(connection.IdleSinceUtc, before, after); - Assert.True(connection.IdleSinceUtc > stampedBack); - - scope.Complete(); - } - [Fact] public void IdleTimeout_Zero_DoesNotStampOnReturn() { diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolTransactionTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolTransactionTest.cs index a5e5d0339f..4b705280aa 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolTransactionTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolTransactionTest.cs @@ -55,7 +55,8 @@ private WaitHandleDbConnectionPool CreatePool( maxPoolSize: maxPoolSize, creationTimeout: DefaultCreationTimeoutInMilliseconds, loadBalanceTimeout: 0, - hasTransactionAffinity: hasTransactionAffinity + hasTransactionAffinity: hasTransactionAffinity, + idleTimeout: 0 ); var dbConnectionPoolGroup = new DbConnectionPoolGroup( From b3a7b12f873b0fec6f3fb66f7700396f9f5733d7 Mon Sep 17 00:00:00 2001 From: Priyanka Tiwari Date: Wed, 27 May 2026 13:51:31 +0530 Subject: [PATCH 5/7] Address PR #4295 review feedback on idle timeout behavior --- .../SqlConnectionStringBuilder.xml | 2 +- release-notes/7.1/7.1.0-preview1.md | 17 -------------- .../DbConnectionStringDefaults.cs | 4 +++- .../DbConnectionStringSynonyms.cs | 1 + .../Data/ProviderBase/DbConnectionInternal.cs | 3 ++- .../ConnectionPool/ChannelDbConnectionPool.cs | 11 ++++++---- .../ConnectionPool/DbConnectionPoolOptions.cs | 5 +++++ .../WaitHandleDbConnectionPool.cs | 17 ++++++++------ .../Data/SqlClient/LocalAppContextSwitches.cs | 22 +++++++++++++++++++ .../Data/SqlClient/SqlConnectionOptions.cs | 3 ++- .../SqlClient/SqlConnectionStringBuilder.cs | 1 + .../Common/LocalAppContextSwitchesHelper.cs | 15 +++++++++++++ .../SqlConnectionStringBuilderTest.cs | 7 ++++++ .../ChannelDbConnectionPoolTest.cs | 17 +++++++++----- .../SqlClient/LocalAppContextSwitchesTest.cs | 1 + 15 files changed, 88 insertions(+), 38 deletions(-) diff --git a/doc/snippets/Microsoft.Data.SqlClient/SqlConnectionStringBuilder.xml b/doc/snippets/Microsoft.Data.SqlClient/SqlConnectionStringBuilder.xml index adfa5a13c7..7d6d536083 100644 --- a/doc/snippets/Microsoft.Data.SqlClient/SqlConnectionStringBuilder.xml +++ b/doc/snippets/Microsoft.Data.SqlClient/SqlConnectionStringBuilder.xml @@ -981,7 +981,7 @@ The following example converts an existing connection string from using SQL Serv - Gets or sets the maximum time, in seconds, that a connection can sit unused (idle) in the connection pool before it is discarded on its next retrieval. + Gets or sets the maximum time, in seconds, that a connection can sit unused (idle) in the connection pool before it is discarded. The value of the property, or 300 (5 minutes) if none has been supplied. diff --git a/release-notes/7.1/7.1.0-preview1.md b/release-notes/7.1/7.1.0-preview1.md index e7c8067bac..beb95c6a7b 100644 --- a/release-notes/7.1/7.1.0-preview1.md +++ b/release-notes/7.1/7.1.0-preview1.md @@ -37,23 +37,6 @@ This update brings the following changes since the [7.0.0](../7.0/7.0.0.md) rele - Existing canonical keywords continue to work unchanged; this preview simply accepts more equivalent aliases during parsing. -#### `Connection Idle Timeout` Connection-String Keyword - -*What Changed:* - -- Added a new `Connection Idle Timeout` connection-string keyword (integer, seconds) that bounds how long an idle pooled connection may sit in the pool before it is discarded and replaced on its next retrieval. - ([#4295](https://github.com/dotnet/SqlClient/pull/4295)) - -*Who Benefits:* - -- Applications behind firewalls, load balancers, or proxies with idle-connection limits avoid receiving a connection that has been silently closed by an intermediary. -- Long-lived pools that span credential rotation or server-side inactivity policies recycle stale connections proactively. - -*Impact:* - -- The default value is `300` seconds (5 minutes), matching Npgsql. A value of `0` disables idle expiration. Negative values are rejected. -- Existing connection strings that omit the keyword will now expire idle connections after 5 minutes; previously connections were retained indefinitely (subject to other expiry rules such as `Load Balance Timeout`). - ### Changed #### Connection Pool Clearing Now Works With Pool v2 (Experimental) diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringDefaults.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringDefaults.cs index bca2af6b28..bf700c0b55 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringDefaults.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringDefaults.cs @@ -36,7 +36,9 @@ internal static class DbConnectionStringDefaults internal const bool IntegratedSecurity = false; internal const SqlConnectionIPAddressPreference IpAddressPreference = SqlConnectionIPAddressPreference.IPv4First; internal const int LoadBalanceTimeout = 0; // default of 0 means don't use - internal const int IdleTimeout = 300; // 5 minutes, matches Npgsql. 0 disables idle expiration. + // Default configured idle timeout is 5 minutes. Connection pool behavior is gated by + // LocalAppContextSwitches.UseLegacyIdleTimeoutBehavior for compatibility. + internal const int IdleTimeout = 300; internal const int MaxPoolSize = 100; internal const int MinPoolSize = 0; internal const bool MultipleActiveResultSets = false; diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringSynonyms.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringSynonyms.cs index 15c32abe75..922d08ce73 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringSynonyms.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringSynonyms.cs @@ -32,6 +32,7 @@ internal static class DbConnectionStringSynonyms internal const string PacketSize = "packetsize"; internal const string PersistSecurityInfo = "persistsecurityinfo"; internal const string PoolBlockingPeriod = "poolblockingperiod"; + internal const string PoolIdleTimeout = "pool idle timeout"; internal const string Pwd = "pwd"; internal const string Server = "server"; internal const string ServerCertificate = "servercertificate"; diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/ProviderBase/DbConnectionInternal.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/ProviderBase/DbConnectionInternal.cs index ba07a5f652..b39fa12989 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/ProviderBase/DbConnectionInternal.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/ProviderBase/DbConnectionInternal.cs @@ -99,9 +99,10 @@ internal DbConnectionInternal(ConnectionState state, bool hidePassword, bool all /// /// UTC timestamp of when this connection was last placed into the pool's idle state. /// Stamped by from the pool's return-to-pool path. + /// Internal setter exists to support deterministic unit tests without reflection. /// Used by the pool to discard connections that have sat unused longer than the configured idle timeout. /// - internal DateTime IdleSinceUtc { get; private set; } + internal DateTime IdleSinceUtc { get; set; } /// /// The pool generation at the time this connection was created or added to the pool. 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 10629043c0..e730c542a1 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 @@ -256,9 +256,10 @@ public void ReturnInternalConnection(DbConnectionInternal connection, DbConnecti { // Stamp the idle-since timestamp immediately before putting the connection back in the // pool so that IsLiveConnection can later evict it if it sits idle past the configured limit. - // Skip the stamp when idle expiry is disabled (the default) to avoid the per-return - // DateTime.UtcNow on the hot return path. - if (PoolGroupOptions.IdleTimeout != TimeSpan.Zero) + // Skip the stamp when idle expiry is disabled or legacy idle-timeout behavior is in effect + // to avoid the per-return DateTime.UtcNow on the hot return path. + if (!LocalAppContextSwitches.UseLegacyIdleTimeoutBehavior && + PoolGroupOptions.IdleTimeout != TimeSpan.Zero) { connection.MarkPooledIdle(); } @@ -438,7 +439,9 @@ private bool IsLiveConnection(DbConnectionInternal connection) // IdleSinceUtc is initialized to CreateTime so a freshly minted connection never trips this // check on first retrieval, and is then stamped by ReturnInternalConnection on every return. TimeSpan idleTimeout = PoolGroupOptions.IdleTimeout; - if (idleTimeout != TimeSpan.Zero && DateTime.UtcNow > connection.IdleSinceUtc + idleTimeout) + if (!LocalAppContextSwitches.UseLegacyIdleTimeoutBehavior && + idleTimeout != TimeSpan.Zero && + DateTime.UtcNow > connection.IdleSinceUtc + idleTimeout) { return false; } diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolOptions.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolOptions.cs index 40815e04e5..d1f9b3e176 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolOptions.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolOptions.cs @@ -32,6 +32,11 @@ int idleTimeout _maxPoolSize = maxPoolSize; _creationTimeout = creationTimeout; + if (idleTimeout < 0) + { + throw new ArgumentOutOfRangeException(nameof(idleTimeout), idleTimeout, "Idle timeout cannot be negative."); + } + if (0 != loadBalanceTimeout) { _loadBalanceTimeout = new TimeSpan(0, 0, loadBalanceTimeout); diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs index 1200191d0e..de79627fb7 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs @@ -221,12 +221,12 @@ internal WaitHandleDbConnectionPool( lock (s_random) { - // Drive the cleanup cadence from the configured idle timeout so a configured pool - // prunes at roughly the idle-expiration interval. When idle expiration is disabled - // (IdleTimeout == 0) fall back to the historical 2-4 minute random window. + // Drive the cleanup cadence from the configured idle timeout only when legacy behavior + // is disabled. Otherwise preserve the historical 2-4 minute random cleanup window. TimeSpan idleTimeout = connectionPoolGroup.PoolGroupOptions.IdleTimeout; - _cleanupWait = idleTimeout != TimeSpan.Zero - ? (int)idleTimeout.TotalMilliseconds + long cleanupWaitMilliseconds = (long)idleTimeout.TotalMilliseconds; + _cleanupWait = !LocalAppContextSwitches.UseLegacyIdleTimeoutBehavior && idleTimeout != TimeSpan.Zero + ? (cleanupWaitMilliseconds >= int.MaxValue ? int.MaxValue : (int)cleanupWaitMilliseconds) : s_random.Next(12, 24) * 10 * 1000; // 2-4 minutes in 10 sec intervals, WebData 103603 } @@ -1344,7 +1344,8 @@ private void PutNewObject(DbConnectionInternal obj) // so that idle-expiry checks on later retrieval can decide whether it has sat unused too long. // Skip the stamp when idle expiry is disabled (the default) to avoid the per-return // DateTime.UtcNow on the hot return path. - if (PoolGroupOptions.IdleTimeout != TimeSpan.Zero) + if (!LocalAppContextSwitches.UseLegacyIdleTimeoutBehavior && + PoolGroupOptions.IdleTimeout != TimeSpan.Zero) { obj.MarkPooledIdle(); } @@ -1363,7 +1364,9 @@ private void PutNewObject(DbConnectionInternal obj) private bool IsIdleExpired(DbConnectionInternal obj) { TimeSpan idleTimeout = PoolGroupOptions.IdleTimeout; - return idleTimeout != TimeSpan.Zero && DateTime.UtcNow > obj.IdleSinceUtc + idleTimeout; + return !LocalAppContextSwitches.UseLegacyIdleTimeoutBehavior && + idleTimeout != TimeSpan.Zero && + DateTime.UtcNow > obj.IdleSinceUtc + idleTimeout; } public void ReturnInternalConnection(DbConnectionInternal obj, DbConnection owningObject) diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs index 16e0abb7ec..f5cd5551c9 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/LocalAppContextSwitches.cs @@ -118,6 +118,13 @@ internal static class LocalAppContextSwitches private const string UseConnectionPoolV2String = "Switch.Microsoft.Data.SqlClient.UseConnectionPoolV2"; + /// + /// The name of the app context switch that controls whether to preserve + /// legacy idle-timeout behavior in connection pooling. + /// + private const string UseLegacyIdleTimeoutBehaviorString = + "Switch.Microsoft.Data.SqlClient.UseLegacyIdleTimeoutBehavior"; + #if NET && _WINDOWS /// /// The name of the app context switch that controls whether to use the @@ -222,6 +229,11 @@ private enum SwitchValue : byte /// private static SwitchValue s_useConnectionPoolV2 = SwitchValue.None; + /// + /// The cached value of the UseLegacyIdleTimeoutBehavior switch. + /// + private static SwitchValue s_useLegacyIdleTimeoutBehavior = SwitchValue.None; + #if NET && _WINDOWS /// /// The cached value of the UseManagedNetworking switch. @@ -539,6 +551,16 @@ public static bool UseCompatibilityAsyncBehaviour defaultValue: false, ref s_useConnectionPoolV2); + /// + /// When set to true (the default), pooling preserves historical idle-timeout behavior. + /// When set to false, configured Connection Idle Timeout is enforced by the pool. + /// + public static bool UseLegacyIdleTimeoutBehavior => + AcquireAndReturn( + UseLegacyIdleTimeoutBehaviorString, + defaultValue: true, + ref s_useLegacyIdleTimeoutBehavior); + #if NET && _WINDOWS /// /// When set to true, .NET on Windows will use the managed SNI diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionOptions.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionOptions.cs index 7f43925e31..75ff79ae34 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionOptions.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionOptions.cs @@ -196,7 +196,8 @@ static SqlConnectionOptions() DbConnectionStringSynonyms.IpAddressPreference); AddKeywordToMap(DbConnectionStringKeywords.LoadBalanceTimeout, DbConnectionStringSynonyms.ConnectionLifetime); - AddKeywordToMap(DbConnectionStringKeywords.IdleTimeout); + AddKeywordToMap(DbConnectionStringKeywords.IdleTimeout, + DbConnectionStringSynonyms.PoolIdleTimeout); AddKeywordToMap(DbConnectionStringKeywords.MultipleActiveResultSets, DbConnectionStringSynonyms.MultipleActiveResultSets); AddKeywordToMap(DbConnectionStringKeywords.MaxPoolSize); diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionStringBuilder.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionStringBuilder.cs index 4038dbb295..c2d1b0c485 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionStringBuilder.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionStringBuilder.cs @@ -272,6 +272,7 @@ private static Dictionary CreateKeywordsDictionary() { DbConnectionStringSynonyms.TrustedConnection, Keywords.IntegratedSecurity }, { DbConnectionStringSynonyms.TrustServerCertificate, Keywords.TrustServerCertificate }, { DbConnectionStringSynonyms.ConnectionLifetime, Keywords.LoadBalanceTimeout }, + { DbConnectionStringSynonyms.PoolIdleTimeout, Keywords.IdleTimeout }, { DbConnectionStringSynonyms.Pwd, Keywords.Password }, { DbConnectionStringSynonyms.PersistSecurityInfo, Keywords.PersistSecurityInfo }, { DbConnectionStringSynonyms.Uid, Keywords.UserID }, diff --git a/src/Microsoft.Data.SqlClient/tests/Common/LocalAppContextSwitchesHelper.cs b/src/Microsoft.Data.SqlClient/tests/Common/LocalAppContextSwitchesHelper.cs index 8102f47d51..4109833776 100644 --- a/src/Microsoft.Data.SqlClient/tests/Common/LocalAppContextSwitchesHelper.cs +++ b/src/Microsoft.Data.SqlClient/tests/Common/LocalAppContextSwitchesHelper.cs @@ -55,6 +55,7 @@ public sealed class LocalAppContextSwitchesHelper : IDisposable private readonly bool? _useCompatibilityAsyncBehaviourOriginal; private readonly bool? _useCompatibilityProcessSniOriginal; private readonly bool? _useConnectionPoolV2Original; + private readonly bool? _useLegacyIdleTimeoutBehaviorOriginal; #if NET && _WINDOWS private readonly bool? _useManagedNetworkingOriginal; #endif @@ -114,6 +115,8 @@ public LocalAppContextSwitchesHelper() GetSwitchValue("s_useCompatibilityProcessSni"); _useConnectionPoolV2Original = GetSwitchValue("s_useConnectionPoolV2"); + _useLegacyIdleTimeoutBehaviorOriginal = + GetSwitchValue("s_useLegacyIdleTimeoutBehavior"); #if NET && _WINDOWS _useManagedNetworkingOriginal = GetSwitchValue("s_useManagedNetworking"); @@ -178,6 +181,9 @@ public void Dispose() SetSwitchValue( "s_useConnectionPoolV2", _useConnectionPoolV2Original); + SetSwitchValue( + "s_useLegacyIdleTimeoutBehavior", + _useLegacyIdleTimeoutBehaviorOriginal); #if NET && _WINDOWS SetSwitchValue( "s_useManagedNetworking", @@ -314,6 +320,15 @@ public bool? UseConnectionPoolV2 set => SetSwitchValue("s_useConnectionPoolV2", value); } + /// + /// Get or set the UseLegacyIdleTimeoutBehavior switch value. + /// + public bool? UseLegacyIdleTimeoutBehavior + { + get => GetSwitchValue("s_useLegacyIdleTimeoutBehavior"); + set => SetSwitchValue("s_useLegacyIdleTimeoutBehavior", value); + } + #if NET && _WINDOWS /// /// Get or set the UseManagedNetworking switch value. diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionStringBuilderTest.cs b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionStringBuilderTest.cs index 180fe07209..60a1e608e3 100644 --- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionStringBuilderTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionStringBuilderTest.cs @@ -279,6 +279,13 @@ public void IdleTimeout_CanonicalKeyword_Parses() Assert.Equal(120, builder.IdleTimeout); } + [Fact] + public void IdleTimeout_SynonymKeyword_Parses() + { + SqlConnectionStringBuilder builder = new SqlConnectionStringBuilder("Pool Idle Timeout=120"); + Assert.Equal(120, builder.IdleTimeout); + } + [Fact] public void SetInvalidIdleTimeout_Throws() { diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs index 5f65792c66..ac740da9d4 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs @@ -12,6 +12,7 @@ using Microsoft.Data.Common.ConnectionString; using Microsoft.Data.ProviderBase; using Microsoft.Data.SqlClient.ConnectionPool; +using Microsoft.Data.SqlClient.Tests.Common; using Xunit; namespace Microsoft.Data.SqlClient.UnitTests.ConnectionPool @@ -951,6 +952,9 @@ public void IdleTimeout_DefaultIsZero_DisablesExpiry() [Fact] public void IdleTimeout_StampedOnReturn() { + using LocalAppContextSwitchesHelper switchesHelper = new(); + switchesHelper.UseLegacyIdleTimeoutBehavior = false; + // Arrange - long idle timeout so the return path stamps (not evicts). var pool = ConstructPoolWithIdleTimeout(idleTimeoutSeconds: 3600); SqlConnection owningConnection = new(); @@ -1000,6 +1004,9 @@ public void IdleTimeout_Zero_DoesNotExpire() [Fact] public void IdleTimeout_Set_ExpiresOldConnection() { + using LocalAppContextSwitchesHelper switchesHelper = new(); + switchesHelper.UseLegacyIdleTimeoutBehavior = false; + // Arrange - pool with 1-second idle timeout var pool = ConstructPoolWithIdleTimeout(idleTimeoutSeconds: 1); SqlConnection owner = new(); @@ -1025,6 +1032,9 @@ public void IdleTimeout_Set_ExpiresOldConnection() [Fact] public void IdleTimeout_Set_KeepsFreshConnection() { + using LocalAppContextSwitchesHelper switchesHelper = new(); + switchesHelper.UseLegacyIdleTimeoutBehavior = false; + // Arrange - 60-second idle timeout, connection just returned var pool = ConstructPoolWithIdleTimeout(idleTimeoutSeconds: 60); SqlConnection owner = new(); @@ -1043,14 +1053,9 @@ public void IdleTimeout_Set_KeepsFreshConnection() } // Forcibly rewinds a connection's IdleSinceUtc by the given amount so tests don't have to sleep. - // Uses reflection because the setter is private by design (only the pool's return path stamps it). private static void BackdateIdleSince(DbConnectionInternal connection, TimeSpan delta) { - var prop = typeof(DbConnectionInternal).GetProperty( - nameof(DbConnectionInternal.IdleSinceUtc), - System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public); - Assert.NotNull(prop); - prop!.SetValue(connection, DateTime.UtcNow - delta); + connection.IdleSinceUtc = DateTime.UtcNow - delta; } #endregion diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs index a5db02faa0..c643ba994c 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft/Data/SqlClient/LocalAppContextSwitchesTest.cs @@ -25,6 +25,7 @@ public void TestDefaultAppContextSwitchValues() Assert.True(LocalAppContextSwitches.LegacyVarTimeZeroScaleBehaviour); Assert.True(LocalAppContextSwitches.UseCompatibilityProcessSni); Assert.True(LocalAppContextSwitches.UseCompatibilityAsyncBehaviour); + Assert.True(LocalAppContextSwitches.UseLegacyIdleTimeoutBehavior); Assert.False(LocalAppContextSwitches.UseConnectionPoolV2); Assert.False(LocalAppContextSwitches.TruncateScaledDecimal); Assert.False(LocalAppContextSwitches.IgnoreServerProvidedFailoverPartner); From a6a69c5bdffc42c68e6bb93ccd76b71d4499cd85 Mon Sep 17 00:00:00 2001 From: Priyanka Tiwari Date: Wed, 27 May 2026 19:18:28 +0530 Subject: [PATCH 6/7] Remove Pool Idle Timeout synonym; keep only canonical Connection Idle Timeout keyword --- .../Common/ConnectionString/DbConnectionStringSynonyms.cs | 1 - .../src/Microsoft/Data/SqlClient/SqlConnectionOptions.cs | 3 +-- .../Microsoft/Data/SqlClient/SqlConnectionStringBuilder.cs | 1 - .../FunctionalTests/SqlConnectionStringBuilderTest.cs | 7 ------- 4 files changed, 1 insertion(+), 11 deletions(-) diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringSynonyms.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringSynonyms.cs index 922d08ce73..15c32abe75 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringSynonyms.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/ConnectionString/DbConnectionStringSynonyms.cs @@ -32,7 +32,6 @@ internal static class DbConnectionStringSynonyms internal const string PacketSize = "packetsize"; internal const string PersistSecurityInfo = "persistsecurityinfo"; internal const string PoolBlockingPeriod = "poolblockingperiod"; - internal const string PoolIdleTimeout = "pool idle timeout"; internal const string Pwd = "pwd"; internal const string Server = "server"; internal const string ServerCertificate = "servercertificate"; diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionOptions.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionOptions.cs index 75ff79ae34..7f43925e31 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionOptions.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionOptions.cs @@ -196,8 +196,7 @@ static SqlConnectionOptions() DbConnectionStringSynonyms.IpAddressPreference); AddKeywordToMap(DbConnectionStringKeywords.LoadBalanceTimeout, DbConnectionStringSynonyms.ConnectionLifetime); - AddKeywordToMap(DbConnectionStringKeywords.IdleTimeout, - DbConnectionStringSynonyms.PoolIdleTimeout); + AddKeywordToMap(DbConnectionStringKeywords.IdleTimeout); AddKeywordToMap(DbConnectionStringKeywords.MultipleActiveResultSets, DbConnectionStringSynonyms.MultipleActiveResultSets); AddKeywordToMap(DbConnectionStringKeywords.MaxPoolSize); diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionStringBuilder.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionStringBuilder.cs index c2d1b0c485..4038dbb295 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionStringBuilder.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionStringBuilder.cs @@ -272,7 +272,6 @@ private static Dictionary CreateKeywordsDictionary() { DbConnectionStringSynonyms.TrustedConnection, Keywords.IntegratedSecurity }, { DbConnectionStringSynonyms.TrustServerCertificate, Keywords.TrustServerCertificate }, { DbConnectionStringSynonyms.ConnectionLifetime, Keywords.LoadBalanceTimeout }, - { DbConnectionStringSynonyms.PoolIdleTimeout, Keywords.IdleTimeout }, { DbConnectionStringSynonyms.Pwd, Keywords.Password }, { DbConnectionStringSynonyms.PersistSecurityInfo, Keywords.PersistSecurityInfo }, { DbConnectionStringSynonyms.Uid, Keywords.UserID }, diff --git a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionStringBuilderTest.cs b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionStringBuilderTest.cs index 60a1e608e3..180fe07209 100644 --- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionStringBuilderTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionStringBuilderTest.cs @@ -279,13 +279,6 @@ public void IdleTimeout_CanonicalKeyword_Parses() Assert.Equal(120, builder.IdleTimeout); } - [Fact] - public void IdleTimeout_SynonymKeyword_Parses() - { - SqlConnectionStringBuilder builder = new SqlConnectionStringBuilder("Pool Idle Timeout=120"); - Assert.Equal(120, builder.IdleTimeout); - } - [Fact] public void SetInvalidIdleTimeout_Throws() { From a37358dc6fba0c5c6e660d2c9dc7db2b94fe536d Mon Sep 17 00:00:00 2001 From: Priyanka Tiwari Date: Fri, 29 May 2026 14:24:15 +0530 Subject: [PATCH 7/7] Address review feedback for idle connection timeout - Colocate idleTimeout < 0 validation with the _idleTimeout assignment in DbConnectionPoolOptions - Clean up WaitHandle cleanup-wait conditional and halve the interval (two pruning cycles per eviction) - Vague-up IdleTimeout doc/resx wording per reviewer (drop 'on the next retrieval attempt' / lifecycle commitments) - Toggle UseLegacyIdleTimeoutBehavior in WaitHandle idle-timeout tests and replace reflection-based BackdateIdleSince with direct internal setter --- .../SqlConnectionStringBuilder.xml | 2 +- .../ConnectionPool/DbConnectionPoolOptions.cs | 10 +++++----- .../WaitHandleDbConnectionPool.cs | 19 +++++++++++++------ .../Data/SqlClient/SqlConnectionOptions.cs | 4 ++-- .../src/Resources/Strings.Designer.cs | 2 +- .../src/Resources/Strings.resx | 2 +- ...itHandleDbConnectionPoolIdleTimeoutTest.cs | 18 +++++++++++------- 7 files changed, 34 insertions(+), 23 deletions(-) diff --git a/doc/snippets/Microsoft.Data.SqlClient/SqlConnectionStringBuilder.xml b/doc/snippets/Microsoft.Data.SqlClient/SqlConnectionStringBuilder.xml index 7d6d536083..fa198eefc2 100644 --- a/doc/snippets/Microsoft.Data.SqlClient/SqlConnectionStringBuilder.xml +++ b/doc/snippets/Microsoft.Data.SqlClient/SqlConnectionStringBuilder.xml @@ -991,7 +991,7 @@ The following example converts an existing connection string from using SQL Serv This property corresponds to the "Connection Idle Timeout" key within the connection string. - When a caller retrieves a connection from the pool, the driver checks how long the connection has been sitting idle. If the idle duration exceeds the value of Connection Idle Timeout, the connection is discarded and a different valid or newly-created connection is returned instead. This protects callers from receiving connections that may have been silently closed by firewalls, load balancers, or server-side inactivity thresholds. + The driver makes a best effort to close connections that have remained idle in the pool for longer than this value. The exact point in the connection lifecycle at which the check occurs is an implementation detail and may change over time. This protects callers from receiving connections that may have been silently closed by firewalls, load balancers, or server-side inactivity thresholds. A value of zero (0) disables idle expiration; connections are kept in the pool indefinitely (subject to other expiry rules such as ). diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolOptions.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolOptions.cs index d1f9b3e176..25398115f6 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolOptions.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolOptions.cs @@ -32,17 +32,17 @@ int idleTimeout _maxPoolSize = maxPoolSize; _creationTimeout = creationTimeout; - if (idleTimeout < 0) - { - throw new ArgumentOutOfRangeException(nameof(idleTimeout), idleTimeout, "Idle timeout cannot be negative."); - } - if (0 != loadBalanceTimeout) { _loadBalanceTimeout = new TimeSpan(0, 0, loadBalanceTimeout); _useLoadBalancing = true; } + if (idleTimeout < 0) + { + throw new ArgumentOutOfRangeException(nameof(idleTimeout), idleTimeout, "Idle timeout cannot be negative."); + } + if (0 != idleTimeout) { _idleTimeout = TimeSpan.FromSeconds(idleTimeout); diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs index de79627fb7..dc5759e4a6 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs @@ -221,13 +221,20 @@ internal WaitHandleDbConnectionPool( lock (s_random) { - // Drive the cleanup cadence from the configured idle timeout only when legacy behavior - // is disabled. Otherwise preserve the historical 2-4 minute random cleanup window. TimeSpan idleTimeout = connectionPoolGroup.PoolGroupOptions.IdleTimeout; - long cleanupWaitMilliseconds = (long)idleTimeout.TotalMilliseconds; - _cleanupWait = !LocalAppContextSwitches.UseLegacyIdleTimeoutBehavior && idleTimeout != TimeSpan.Zero - ? (cleanupWaitMilliseconds >= int.MaxValue ? int.MaxValue : (int)cleanupWaitMilliseconds) - : s_random.Next(12, 24) * 10 * 1000; // 2-4 minutes in 10 sec intervals, WebData 103603 + if (!LocalAppContextSwitches.UseLegacyIdleTimeoutBehavior && idleTimeout != TimeSpan.Zero) + { + // The WaitHandle pool takes two pruning cycles to remove an idle connection + // (new->old generation, then old->closed), so halve the configured timeout + // to approximate the requested idle lifetime. + long cleanupWaitMilliseconds = (long)idleTimeout.TotalMilliseconds / 2; + _cleanupWait = cleanupWaitMilliseconds >= int.MaxValue ? int.MaxValue : (int)cleanupWaitMilliseconds; + } + else + { + // Preserve the historical 2-4 minute random cleanup window. + _cleanupWait = s_random.Next(12, 24) * 10 * 1000; // 2-4 minutes in 10 sec intervals, WebData 103603 + } } _connectionFactory = connectionFactory; diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionOptions.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionOptions.cs index 7f43925e31..734ecc125f 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionOptions.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionOptions.cs @@ -659,8 +659,8 @@ internal SqlConnectionOptions(SqlConnectionOptions connectionOptions, string dat internal int CommandTimeout => _commandTimeout; internal int ConnectTimeout => _connectTimeout; internal int LoadBalanceTimeout => _loadBalanceTimeout; - // Maximum time (in seconds) a connection can sit idle in the pool before it is discarded - // on the next retrieval attempt. 0 disables idle expiration. + // Maximum time (in seconds) a connection can sit idle in the pool before it is discarded. + // 0 disables idle expiration. internal int IdleTimeout => _idleTimeout; internal int MaxPoolSize => _maxPoolSize; internal int MinPoolSize => _minPoolSize; diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs b/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs index 7979d14346..cab809a120 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.Designer.cs @@ -1429,7 +1429,7 @@ internal static string DbConnectionString_LoadBalanceTimeout { } /// - /// Looks up a localized string similar to The maximum amount of time (in seconds) a connection can sit unused (idle) in the pool before it is discarded on its next retrieval. A value of 0 disables idle expiration.. + /// Looks up a localized string similar to The maximum amount of time (in seconds) a connection can sit unused (idle) in the pool before it is discarded. A value of 0 disables idle expiration.. /// internal static string DbConnectionString_IdleTimeout { get { diff --git a/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx b/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx index 32a8c60335..a8a807aef1 100644 --- a/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx +++ b/src/Microsoft.Data.SqlClient/src/Resources/Strings.resx @@ -961,7 +961,7 @@ The minimum amount of time (in seconds) for this connection to live in the pool before being destroyed. - The maximum amount of time (in seconds) a connection can sit unused (idle) in the pool before it is discarded on its next retrieval. A value of 0 disables idle expiration. + The maximum amount of time (in seconds) a connection can sit unused (idle) in the pool before it is discarded. A value of 0 disables idle expiration. The maximum number of connections allowed in the pool. diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolIdleTimeoutTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolIdleTimeoutTest.cs index 28ae51c3db..b4de314def 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolIdleTimeoutTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolIdleTimeoutTest.cs @@ -3,10 +3,10 @@ // See the LICENSE file in the project root for more information. using System; -using System.Reflection; using Microsoft.Data.Common.ConnectionString; using Microsoft.Data.ProviderBase; using Microsoft.Data.SqlClient.ConnectionPool; +using Microsoft.Data.SqlClient.Tests.Common; using Xunit; namespace Microsoft.Data.SqlClient.UnitTests.ConnectionPool; @@ -67,19 +67,17 @@ private DbConnectionInternal GetConnection(SqlConnection owner) } // Forcibly rewinds a connection's IdleSinceUtc by the given amount so tests don't have to sleep. - // Uses reflection because the setter is private by design (only the pool's return path stamps it). private static void BackdateIdleSince(DbConnectionInternal connection, TimeSpan delta) { - var prop = typeof(DbConnectionInternal).GetProperty( - nameof(DbConnectionInternal.IdleSinceUtc), - BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); - Assert.NotNull(prop); - prop!.SetValue(connection, DateTime.UtcNow - delta); + connection.IdleSinceUtc = DateTime.UtcNow - delta; } [Fact] public void IdleTimeout_StampedOnReturn() { + using LocalAppContextSwitchesHelper switchesHelper = new(); + switchesHelper.UseLegacyIdleTimeoutBehavior = false; + // Arrange - long idle timeout so the return path stamps (not evicts). _pool = CreatePool(idleTimeoutSeconds: 3600); SqlConnection owner = new(); @@ -124,6 +122,9 @@ public void IdleTimeout_Zero_DoesNotExpire() [Fact] public void IdleTimeout_Set_ExpiresOldConnection() { + using LocalAppContextSwitchesHelper switchesHelper = new(); + switchesHelper.UseLegacyIdleTimeoutBehavior = false; + // Arrange - pool with 1-second idle timeout _pool = CreatePool(idleTimeoutSeconds: 1); SqlConnection owner = new(); @@ -144,6 +145,9 @@ public void IdleTimeout_Set_ExpiresOldConnection() [Fact] public void IdleTimeout_Set_KeepsFreshConnection() { + using LocalAppContextSwitchesHelper switchesHelper = new(); + switchesHelper.UseLegacyIdleTimeoutBehavior = false; + // Arrange - 60-second idle timeout, connection just returned _pool = CreatePool(idleTimeoutSeconds: 60); SqlConnection owner = new();