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..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
@@ -254,6 +254,14 @@ 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 (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.");
}
@@ -436,6 +444,15 @@ private bool IsLiveConnection(DbConnectionInternal connection)
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)
+ {
+ 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..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;
}
@@ -1028,7 +1036,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 +1215,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 +1337,14 @@ 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 (PoolGroupOptions.IdleTimeout != TimeSpan.Zero)
+ {
+ obj.MarkPooledIdle();
+ }
_stackNew.Push(obj);
_waitHandles.PoolSemaphore.Release(1);
@@ -1336,6 +1352,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
{
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..1815c551fb
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolIdleTimeoutTest.cs
@@ -0,0 +1,209 @@
+// 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 System.Transactions;
+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);
+ }
+
+ [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);
+ }
+}