Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
126f16a
Add configurable idle connection timeout (ADO #39970)
priyankatiwari08 May 19, 2026
2a94926
Address Copilot review feedback on #4295
priyankatiwari08 May 20, 2026
93ab7ee
Address PR #4295 inline review feedback
priyankatiwari08 May 21, 2026
dd30ce7
Address PR review: default IdleTimeout=300, remove synonym, drop tran…
priyankatiwari08 May 26, 2026
b3a7b12
Address PR #4295 review feedback on idle timeout behavior
priyankatiwari08 May 27, 2026
a6a69c5
Remove Pool Idle Timeout synonym; keep only canonical Connection Idle…
priyankatiwari08 May 27, 2026
a37358d
Address review feedback for idle connection timeout
priyankatiwari08 May 29, 2026
f32d6cd
Address Paul's review feedback: rename and doc cleanup
priyankatiwari08 Jun 4, 2026
0f0d018
Apply review feedback: drop guard, overflow-safe expiry, Yoda swap, 3…
priyankatiwari08 Jun 4, 2026
911b83a
Re-add legacy-switch guard around ReturnedToPool stamp
priyankatiwari08 Jun 4, 2026
228fe6f
Merge branch 'main' into feature/idle-connection-timeout-pr1
priyankatiwari08 Jun 4, 2026
80da656
Fix merge conflict damage in LocalAppContextSwitchesHelper
priyankatiwari08 Jun 4, 2026
7611570
Fix merge conflict damage in LocalAppContextSwitches
priyankatiwari08 Jun 4, 2026
abfa301
Pass idleTimeout to DbConnectionPoolGroupOptions in budget test
priyankatiwari08 Jun 5, 2026
5b1bbe9
Add TimeoutTimer arg to TryGetConnection / idleTimeout to DbConnectio…
priyankatiwari08 Jun 5, 2026
0cf4d4a
Address PR #4295 Copilot review: document UseLegacyIdleTimeoutBehavio…
priyankatiwari08 Jun 5, 2026
a6942f4
Address PR 4295 review feedback: cleanup cadence, idle-eviction gate,…
priyankatiwari08 Jun 10, 2026
182b596
Merge origin/main; fix new ChannelDbConnectionPoolPruningTest to pass…
priyankatiwari08 Jun 10, 2026
f78fac0
Fix ChannelDbConnectionPoolPruningTest: pass idleTimeout: 0 to DbConn…
priyankatiwari08 Jun 10, 2026
e999310
Address PR 4295 review: gate WaitHandleDbConnectionPool destroy loop …
priyankatiwari08 Jun 10, 2026
b52495f
Potential fix for pull request finding
priyankatiwari08 Jun 11, 2026
1e37541
Address PR 4295 open reviews: revert CleanupCallback IsIdleExpired ga…
priyankatiwari08 Jun 18, 2026
72d241d
Clarify IdleTimeout doc: spell out the half-timeout early-eviction case
priyankatiwari08 Jun 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -979,6 +979,31 @@ The following example converts an existing connection string from using SQL Serv
</para>
</remarks>
</LoadBalanceTimeout>
<IdleTimeout>
<summary>
Gets or sets the maximum time, in seconds, that a connection can sit unused (idle) in the connection pool before it is discarded. The default is 300 (5 minutes).
</summary>
<value>
The idle timeout for pooled connections, in seconds.
</value>
<remarks>
<para>
This property corresponds to the "Connection Idle Timeout" key within the connection string.
</para>
<para>
In versions where the AppContext switch <c>Switch.Microsoft.Data.SqlClient.UseLegacyIdleTimeoutBehavior</c> is enabled (the default), the driver preserves historical pooling behavior and does not enforce this setting. Set the switch to <see langword="false" /> to enable idle-timeout enforcement.
</para>
<para>
The driver makes a best effort to discard 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.
</para>
<para>
A value of zero (0) disables idle expiration; connections are kept in the pool indefinitely (subject to other expiry rules such as <see cref="P:Microsoft.Data.SqlClient.SqlConnectionStringBuilder.LoadBalanceTimeout" />).
</para>
<para>
Idle timeout operates independently of <see cref="P:Microsoft.Data.SqlClient.SqlConnectionStringBuilder.LoadBalanceTimeout" />. Whichever threshold is exceeded first causes the connection to be discarded.
</para>
Comment thread
priyankatiwari08 marked this conversation as resolved.
Comment thread
priyankatiwari08 marked this conversation as resolved.
</remarks>
</IdleTimeout>
<MaxPoolSize>
<summary>
Gets or sets the maximum number of connections allowed in the connection pool for this specific connection string.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 { } }
/// <include file='../../../doc/snippets/Microsoft.Data.SqlClient/SqlConnectionStringBuilder.xml' path='docs/members[@name="SqlConnectionStringBuilder"]/IdleTimeout/*'/>
[System.ComponentModel.DisplayNameAttribute("Connection Idle Timeout")]
[System.ComponentModel.RefreshPropertiesAttribute(System.ComponentModel.RefreshProperties.All)]
public int IdleTimeout { get { throw null; } set { } }
/// <include file='../../../doc/snippets/Microsoft.Data.SqlClient/SqlConnectionStringBuilder.xml' path='docs/members[@name="SqlConnectionStringBuilder"]/MaxPoolSize/*'/>
[System.ComponentModel.DisplayNameAttribute("Max Pool Size")]
[System.ComponentModel.RefreshPropertiesAttribute(System.ComponentModel.RefreshProperties.All)]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
priyankatiwari08 marked this conversation as resolved.
internal const int MinPoolSize = 0;
internal const bool MultipleActiveResultSets = false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Comment thread
paulmedynski marked this conversation as resolved.
internal const string MaxPoolSize = "Max Pool Size";
internal const string MinPoolSize = "Min Pool Size";
internal const string MultipleActiveResultSets = "Multiple Active Result Sets";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ internal DbConnectionInternal(ConnectionState state, bool hidePassword, bool all
ShouldHidePassword = hidePassword;
State = state;
CreateTime = DateTime.UtcNow;
// Initialize the returned-to-pool stamp to creation time so that a freshly built connection is treated
// as "just used" by the pool's idle-expiry checks until the pool's return path stamps it again on first return.
// Without this initialization, ReturnedTime would default to DateTime.MinValue, which would cause
// IsLiveConnection to immediately evict every new connection whenever IdleTimeout is configured.
ReturnedTime = CreateTime;
}

#region Properties
Expand All @@ -91,6 +96,15 @@ internal DbConnectionInternal(ConnectionState state, bool hidePassword, bool all
/// </summary>
internal DateTime CreateTime { get; }

/// <summary>
/// UTC timestamp of when this connection was last returned to the pool.
/// Stamped by <see cref="SetReturnedTime"/>. Initialized to <see cref="CreateTime"/> in the constructor
/// so a freshly built connection is treated as "just used" until its first return.
/// Internal setter exists to support deterministic unit tests without reflection.
/// The pool reads this value to decide whether the connection has sat idle longer than the configured idle timeout.
/// </summary>
internal DateTime ReturnedTime { get; set; }

/// <summary>
/// The pool generation at the time this connection was created or added to the pool.
/// Used by <see cref="ChannelDbConnectionPool"/> to detect stale connections after a pool clear.
Expand Down Expand Up @@ -726,6 +740,17 @@ internal virtual void PrepareForReplaceConnection()
// By default, there is no preparation required
}

/// <summary>
/// Stamps <see cref="ReturnedTime"/> with the current UTC time. The pool calls this from its
/// return-to-pool path only when it intends the idle-timeout machinery to act on the value;
/// the connection owns the mechanism (recording the time) while the pool owns the policy
/// (deciding when a stamp is meaningful).
/// </summary>
internal void SetReturnedTime()
{
ReturnedTime = DateTime.UtcNow;
}

internal void PrePush(DbConnection expectedOwner)
{
// Called by IDbConnectionPool when we're about to be put into it's pool, we take this
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,16 @@ public void ReturnInternalConnection(DbConnectionInternal connection, DbConnecti
}
else
{
// Stamp the return time so IsLiveConnection can later evict the connection if it sits
// idle past the configured limit. Skip the stamp when idle expiry is disabled or the
// legacy idle-timeout behavior is in effect to avoid the per-return DateTime.UtcNow on
// the hot return path; IsLiveConnection short-circuits on the same conditions so the
// value would be unread in those cases.
if (!LocalAppContextSwitches.UseLegacyIdleTimeoutBehavior &&
PoolGroupOptions.IdleTimeout != TimeSpan.Zero)
{
connection.SetReturnedTime();
}
var written = _idleChannel.TryWrite(connection);
Debug.Assert(written, "Failed to write returning connection to the idle channel.");
}
Expand Down Expand Up @@ -453,6 +463,22 @@ public bool TryGetConnection(
/// <returns>Returns true if the connection is live and unexpired, otherwise returns false.</returns>
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.
// ReturnedTime 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.
// Use subtraction rather than addition so the comparison cannot throw if ReturnedTime is
// ever close to DateTime.MaxValue. A clock skew that leaves ReturnedTime in the future
// produces a negative TimeSpan, which falls through as not-expired (fail safe).
TimeSpan idleTimeout = PoolGroupOptions.IdleTimeout;
Comment thread
mdaigle marked this conversation as resolved.
if (!LocalAppContextSwitches.UseLegacyIdleTimeoutBehavior &&
idleTimeout != TimeSpan.Zero &&
DateTime.UtcNow - connection.ReturnedTime > idleTimeout)
{
return false;
}

// Broken physical connection
if (!connection.IsConnectionAlive())
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -22,7 +23,8 @@ public DbConnectionPoolGroupOptions(
int maxPoolSize,
int creationTimeout,
int loadBalanceTimeout,
bool hasTransactionAffinity
bool hasTransactionAffinity,
int idleTimeout
)
{
_poolByIdentity = poolByIdentity;
Expand All @@ -36,6 +38,16 @@ bool hasTransactionAffinity
_useLoadBalancing = true;
}

if (idleTimeout < 0)
Comment thread
priyankatiwari08 marked this conversation as resolved.
{
throw new ArgumentOutOfRangeException(nameof(idleTimeout), idleTimeout, "Idle timeout cannot be negative.");
}

if (idleTimeout != 0)
{
_idleTimeout = TimeSpan.FromSeconds(idleTimeout);
Comment thread
paulmedynski marked this conversation as resolved.
}

_hasTransactionAffinity = hasTransactionAffinity;
}

Expand All @@ -54,6 +66,20 @@ public TimeSpan LoadBalanceTimeout
{
get { return _loadBalanceTimeout; }
}
/// <summary>
/// The maximum time a pooled connection can sit unused (idle) in the pool before it becomes
/// eligible for eviction. Eviction is best-effort: a connection that has been idle longer
/// than this value is discarded either on the next retrieval attempt or during a periodic
/// pool maintenance pass, whichever happens first. Because maintenance runs on a fixed
Comment thread
priyankatiwari08 marked this conversation as resolved.
/// cadence shorter than the timeout, a connection that becomes idle just before a
/// maintenance pass may be discarded after roughly half the configured timeout; callers
/// that need a strict floor should configure a correspondingly larger value.
/// <see cref="TimeSpan.Zero"/> disables idle expiration.
/// </summary>
public TimeSpan IdleTimeout
{
get { return _idleTimeout; }
}
public int MaxPoolSize
{
get { return _maxPoolSize; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,8 +223,25 @@ 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)
{
// Historical 2-4 minute random cleanup window. Used for the legacy switch and for
// the "idle eviction disabled" state (IdleTimeout=0) where the timer still runs for
// non-idle maintenance but CleanupCallback skips the generational sweep.
_cleanupWait = s_random.Next(12, 24) * 10 * 1000; // 2-4 minutes in 10 sec intervals, WebData 103603
}
else
{
// New idle-timeout behavior with a configured value: the WaitHandle pool takes two
// pruning cycles to evict an idle connection (new->old generation, then old->closed),
// so halve the configured timeout to approximate the requested idle lifetime. Floor
// the period at 1 second so small IdleTimeout values (e.g. 1s) don't schedule a
// sub-second timer that wakes the threadpool just to walk empty stacks.
long cleanupWaitMilliseconds = (long)idleTimeout.TotalMilliseconds / 2;
cleanupWaitMilliseconds = Math.Max(cleanupWaitMilliseconds, 1000);
_cleanupWait = cleanupWaitMilliseconds >= int.MaxValue ? int.MaxValue : (int)cleanupWaitMilliseconds;
Comment thread
priyankatiwari08 marked this conversation as resolved.
Comment thread
priyankatiwari08 marked this conversation as resolved.
}
}

_connectionFactory = connectionFactory;
Expand Down Expand Up @@ -351,8 +368,16 @@ private void CleanupCallback(object state)
// at least one period but not more than two periods.
SqlClientEventSource.Log.TryPoolerTraceEvent("<prov.DbConnectionPool.CleanupCallback|RES|INFO|CPOOL> {0}", Id);

// Idle eviction (the generational destroy/age-into-old-stack sweep below) is enabled under
// the legacy switch, or when the new behaviour is on with a non-zero IdleTimeout. When the
// new behaviour is on and IdleTimeout is 0, eviction is disabled entirely: skip the sweep
// and only run the MinPoolSize floor maintenance.
Comment thread
priyankatiwari08 marked this conversation as resolved.
bool idleEvictionEnabled =
LocalAppContextSwitches.UseLegacyIdleTimeoutBehavior ||
PoolGroupOptions.IdleTimeout != TimeSpan.Zero;

// Destroy free objects that put us above MinPoolSize from old stack.
while (Count > MinPoolSize)
while (idleEvictionEnabled && Count > MinPoolSize)
{
// While above MinPoolSize...
if (_waitHandles.PoolSemaphore.WaitOne(0, false))
Expand All @@ -363,6 +388,7 @@ private void CleanupCallback(object state)
if (_stackOld.TryPop(out obj))
{
Debug.Assert(obj != null, "null connection is not expected");

// If we obtained one from the old stack, destroy it.

SqlClientDiagnostics.Metrics.ExitFreeConnection();
Expand Down Expand Up @@ -414,7 +440,7 @@ private void CleanupCallback(object state)

// Push to the old-stack. For each free object, move object from
// new stack to old stack.
if (_waitHandles.PoolSemaphore.WaitOne(0, false))
if (idleEvictionEnabled && _waitHandles.PoolSemaphore.WaitOne(0, false))
{
for (; ; )
{
Expand Down Expand Up @@ -667,6 +693,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 call SetReturnedTime when parking the connection in the transacted pool.
_transactedConnectionPool.PutTransactedObject(transaction, obj);
rootTxn = true;
}
Expand Down Expand Up @@ -1061,7 +1091,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("<prov.DbConnectionPool.GetConnection|RES|CPOOL> {0}, Connection {1}, found dead and removed.", Id, obj.ObjectID);
DestroyObject(obj);
Comment on lines +1094 to 1097
Expand Down Expand Up @@ -1243,6 +1273,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("<prov.DbConnectionPool.GetFromTransactedPool|RES|CPOOL> {0}, Connection {1}, found dead and removed.", Id, obj.ObjectID);
DestroyObject(obj);
obj = null;
Expand Down Expand Up @@ -1363,13 +1395,39 @@ private void PutNewObject(DbConnectionInternal obj)

SqlClientEventSource.Log.TryPoolerTraceEvent("<prov.DbConnectionPool.PutNewObject|RES|CPOOL> {0}, Connection {1}, Pushing to general pool.", Id, obj.ObjectID);

// Stamp the return time so IsIdleExpired can later decide whether the connection has sat
// unused too long. Skip the stamp when idle expiry is disabled or the legacy idle-timeout
// behavior is in effect to avoid the per-return DateTime.UtcNow on the hot return path;
// IsIdleExpired short-circuits on the same conditions so the value would be unread in
// those cases.
if (!LocalAppContextSwitches.UseLegacyIdleTimeoutBehavior &&
PoolGroupOptions.IdleTimeout != TimeSpan.Zero)
{
obj.SetReturnedTime();
}
_stackNew.Push(obj);
_waitHandles.PoolSemaphore.Release(1);

SqlClientDiagnostics.Metrics.EnterFreeConnection();

}

/// <summary>
/// Returns true when the supplied connection has been sitting idle in the pool longer than the
/// configured <see cref="DbConnectionPoolGroupOptions.IdleTimeout"/>. Returns false when idle timeout
/// is disabled (zero).
/// </summary>
Comment on lines +1415 to +1419
private bool IsIdleExpired(DbConnectionInternal obj)
{
// Use subtraction rather than addition so the comparison cannot throw if ReturnedTime is
// ever close to DateTime.MaxValue. A clock skew that leaves ReturnedTime in the future
// produces a negative TimeSpan, which falls through as not-expired (fail safe).
TimeSpan idleTimeout = PoolGroupOptions.IdleTimeout;
Comment thread
mdaigle marked this conversation as resolved.
return !LocalAppContextSwitches.UseLegacyIdleTimeoutBehavior &&
idleTimeout != TimeSpan.Zero &&
DateTime.UtcNow - obj.ReturnedTime > idleTimeout;
}

public void ReturnInternalConnection(DbConnectionInternal obj, DbConnection owningObject)
{
Debug.Assert(obj != null, "null obj?");
Expand Down
Loading
Loading