diff --git a/doc/snippets/Microsoft.Data.SqlClient/SqlConnectionStringBuilder.xml b/doc/snippets/Microsoft.Data.SqlClient/SqlConnectionStringBuilder.xml index a68c0a323b..fa198eefc2 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. + + + The value of the property, or 300 (5 minutes) if none has been supplied. + + + + This property corresponds to the "Connection Idle Timeout" key within the connection string. + + + 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 ). + + + 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..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,6 +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 + // 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/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/ProviderBase/DbConnectionInternal.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/ProviderBase/DbConnectionInternal.cs index ac3949c726..b39fa12989 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,14 @@ 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. + /// 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; 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 +747,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..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 @@ -254,6 +254,15 @@ 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. + // 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(); + } var written = _idleChannel.TryWrite(connection); Debug.Assert(written, "Failed to write returning connection to the idle channel."); } @@ -424,6 +433,19 @@ public bool TryGetConnection( /// Returns true if the connection is live and unexpired, otherwise returns false. private bool IsLiveConnection(DbConnectionInternal connection) { + // 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 (!LocalAppContextSwitches.UseLegacyIdleTimeoutBehavior && + idleTimeout != TimeSpan.Zero && + DateTime.UtcNow > connection.IdleSinceUtc + idleTimeout) + { + return false; + } + // Broken physical connection if (!connection.IsConnectionAlive()) { 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..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 @@ -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 ) { _poolByIdentity = poolByIdentity; @@ -36,6 +38,16 @@ bool hasTransactionAffinity _useLoadBalancing = true; } + if (idleTimeout < 0) + { + throw new ArgumentOutOfRangeException(nameof(idleTimeout), idleTimeout, "Idle timeout cannot be negative."); + } + + if (0 != idleTimeout) + { + _idleTimeout = TimeSpan.FromSeconds(idleTimeout); + } + _hasTransactionAffinity = hasTransactionAffinity; } @@ -54,6 +66,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..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,8 +221,20 @@ 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 + TimeSpan idleTimeout = connectionPoolGroup.PoolGroupOptions.IdleTimeout; + 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; @@ -670,6 +682,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."); + // 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; } @@ -1028,7 +1044,7 @@ private bool TryGetConnection(DbConnection owningObject, uint waitForMultipleObj Interlocked.Decrement(ref _waitCount); obj = GetFromGeneralPool(); - if ((obj != null) && (!obj.IsConnectionAlive())) + if ((obj != null) && (IsIdleExpired(obj) || !obj.IsConnectionAlive())) { SqlClientEventSource.Log.TryPoolerTraceEvent(" {0}, Connection {1}, found dead and removed.", Id, obj.ObjectID); DestroyObject(obj); @@ -1209,6 +1225,8 @@ private DbConnectionInternal GetFromTransactedPool(out Transaction transaction) } 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; @@ -1329,6 +1347,15 @@ 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. + // Skip the stamp when idle expiry is disabled (the default) to avoid the per-return + // DateTime.UtcNow on the hot return path. + if (!LocalAppContextSwitches.UseLegacyIdleTimeoutBehavior && + PoolGroupOptions.IdleTimeout != TimeSpan.Zero) + { + obj.MarkPooledIdle(); + } _stackNew.Push(obj); _waitHandles.PoolSemaphore.Release(1); @@ -1336,6 +1363,19 @@ 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 !LocalAppContextSwitches.UseLegacyIdleTimeoutBehavior && + 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/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/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..734ecc125f 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,7 @@ static SqlConnectionOptions() DbConnectionStringSynonyms.IpAddressPreference); AddKeywordToMap(DbConnectionStringKeywords.LoadBalanceTimeout, DbConnectionStringSynonyms.ConnectionLifetime); + AddKeywordToMap(DbConnectionStringKeywords.IdleTimeout); AddKeywordToMap(DbConnectionStringKeywords.MultipleActiveResultSets, DbConnectionStringSynonyms.MultipleActiveResultSets); AddKeywordToMap(DbConnectionStringKeywords.MaxPoolSize); @@ -274,6 +276,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 +321,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 +587,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 +659,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. + // 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..4038dbb295 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 }, @@ -349,6 +353,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 +487,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 +988,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 +1485,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..cab809a120 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. 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..a8a807aef1 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. A value of 0 disables idle expiration. + The maximum number of connections allowed in the pool. 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 a24c330181..180fe07209 100644 --- a/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionStringBuilderTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/FunctionalTests/SqlConnectionStringBuilderTest.cs @@ -250,6 +250,44 @@ public void SetInvalidLoadBalanceTimeout_Throws() Assert.Contains("load balance timeout", ex.Message, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void IdleTimeout_DefaultIs300() + { + // Default-constructed builder should have IdleTimeout == 300 (5 minutes), matching Npgsql. + SqlConnectionStringBuilder builder = new SqlConnectionStringBuilder(); + Assert.Equal(300, 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 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..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 @@ -33,7 +34,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 +580,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 +599,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 +614,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 +651,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); } @@ -895,6 +901,165 @@ 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() + { + // Explicitly passing zero keeps idle expiry off. + var poolGroupOptions = new DbConnectionPoolGroupOptions( + poolByIdentity: false, + minPoolSize: 0, + maxPoolSize: 50, + creationTimeout: 15, + loadBalanceTimeout: 0, + hasTransactionAffinity: true, + idleTimeout: 0); + + Assert.Equal(TimeSpan.Zero, poolGroupOptions.IdleTimeout); + } + + [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(); + 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() + { + using LocalAppContextSwitchesHelper switchesHelper = new(); + switchesHelper.UseLegacyIdleTimeoutBehavior = false; + + // 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() + { + using LocalAppContextSwitchesHelper switchesHelper = new(); + switchesHelper.UseLegacyIdleTimeoutBehavior = false; + + // 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. + private static void BackdateIdleSince(DbConnectionInternal connection, TimeSpan delta) + { + connection.IdleSinceUtc = DateTime.UtcNow - delta; + } + + #endregion + #region Test classes internal class SuccessfulSqlConnectionFactory : SqlConnectionFactory { @@ -965,7 +1130,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;"), @@ -996,7 +1162,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;"), @@ -1037,7 +1204,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;"), @@ -1063,7 +1231,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 new file mode 100644 index 0000000000..b4de314def --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolIdleTimeoutTest.cs @@ -0,0 +1,182 @@ +// 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 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; + +/// +/// 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. + private static void BackdateIdleSince(DbConnectionInternal connection, TimeSpan 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(); + 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() + { + using LocalAppContextSwitchesHelper switchesHelper = new(); + switchesHelper.UseLegacyIdleTimeoutBehavior = false; + + // 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() + { + using LocalAppContextSwitchesHelper switchesHelper = new(); + switchesHelper.UseLegacyIdleTimeoutBehavior = false; + + // 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); + } + + [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); + } +} 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( 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);