Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 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
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 on lines +989 to +1004
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rightly pointed. Added in document

</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="ReturnedToPool"/>. 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>
/// Records that this connection was just returned to the pool by stamping <see cref="ReturnedTime"/>
/// with the current UTC time. Called from the pool's return-to-pool path after the connection has
/// been deactivated and is about to enter the idle pool. The pool alone decides what to do with the
/// resulting timestamp (e.g. discarding the connection once it has sat unused for too long).
/// </summary>
internal void ReturnedToPool()
{
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 @@ -255,6 +255,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.ReturnedToPool();
}
var written = _idleChannel.TryWrite(connection);
Debug.Assert(written, "Failed to write returning connection to the idle channel.");
}
Expand Down Expand Up @@ -429,6 +439,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,14 @@ public TimeSpan LoadBalanceTimeout
{
get { return _loadBalanceTimeout; }
}
/// <summary>
/// The maximum time a pooled connection can sit unused (idle) in the pool before it is discarded
/// on the next retrieval attempt. <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,26 @@ 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)
{
// Legacy: 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
}
else if (idleTimeout != TimeSpan.Zero)
{
// New + idle-expiry enabled: 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.
long cleanupWaitMilliseconds = (long)idleTimeout.TotalMilliseconds / 2;
_cleanupWait = cleanupWaitMilliseconds >= int.MaxValue ? int.MaxValue : (int)cleanupWaitMilliseconds;
Comment on lines +237 to +238
}
else
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's avoid duplicating the calculation and move this condition up to the first block:

if (LocalAppContextSwitches.UseLegacyIdleTimeoutBehavior || idleTimeout == TimeSpan.Zero)
{
  // Historical calculation.
}
else
{
  // Use new Idle Timeout value.
}

Note that your changes here eliminate the "None" state I discussed earlier - this old pool will always perform idle connection cleanup, despite the connection string option claiming that a value of 0 means idle cleanup is disabled.

{
// New pool, idle-expiry disabled (IdleTimeout=0). The cleanup timer still runs for
// non-idle maintenance, so reuse the historical 2-4 minute window.
_cleanupWait = s_random.Next(12, 24) * 10 * 1000;
}
}

_connectionFactory = connectionFactory;
Expand Down Expand Up @@ -667,6 +685,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 ReturnedToPool when parking the connection in the transacted pool.
_transactedConnectionPool.PutTransactedObject(transaction, obj);
rootTxn = true;
}
Expand Down Expand Up @@ -1061,7 +1083,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);
Expand Down Expand Up @@ -1243,6 +1265,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 +1387,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.ReturnedToPool();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... so now we're in a situation where the pool is 100% returning this connection to the idle pool, but we're not calling ReturnedToPool(), which seems wrong based on the context here and method name.

Maybe a less-general method name like SetReturnedTime() would be better, since that's exactly what we want the connection to do. The connection is responsible for knowing what sort of time to use, and the pool is responsible for telling the connection to set it. I think that keeps our mechanism/policy separation, and makes the calling code clearer. Thoughts?

}
_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>
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
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,13 @@ internal static class LocalAppContextSwitches
private const string UseConnectionPoolV2String =
"Switch.Microsoft.Data.SqlClient.UseConnectionPoolV2";

/// <summary>
/// The name of the app context switch that controls whether to preserve
/// legacy idle-timeout behavior in connection pooling.
/// </summary>
private const string UseLegacyIdleTimeoutBehaviorString =
"Switch.Microsoft.Data.SqlClient.UseLegacyIdleTimeoutBehavior";

/// <summary>
/// The name of the app context switch that controls whether pool operations
/// should count against the caller's overall ConnectTimeout budget.
Expand Down Expand Up @@ -241,6 +248,11 @@ private enum SwitchValue : byte
/// </summary>
private static SwitchValue s_useConnectionPoolV2 = SwitchValue.None;

/// <summary>
/// The cached value of the UseLegacyIdleTimeoutBehavior switch.
/// </summary>
private static SwitchValue s_useLegacyIdleTimeoutBehavior = SwitchValue.None;

/// <summary>
/// The cached value of the UseOverallConnectTimeoutForPoolWait switch.
/// </summary>
Expand Down Expand Up @@ -576,6 +588,16 @@ public static bool UseCompatibilityAsyncBehaviour
defaultValue: false,
ref s_useConnectionPoolV2);

/// <summary>
/// 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.
/// </summary>
public static bool UseLegacyIdleTimeoutBehavior =>
AcquireAndReturn(
UseLegacyIdleTimeoutBehaviorString,
defaultValue: true,
ref s_useLegacyIdleTimeoutBehavior);

/// <summary>
/// When set to true, pool operations count against the
/// caller's ConnectTimeout budget. This includes waits and async operations.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -740,7 +740,8 @@ private static DbConnectionPoolGroupOptions CreateConnectionPoolGroupOptions(Sql
opt.MaxPoolSize,
connectionTimeout,
opt.LoadBalanceTimeout,
opt.Enlist);
opt.Enlist,
opt.IdleTimeout);
}
return poolingOptions;
}
Expand Down
Loading
Loading