Skip to content

Commit 4afd45e

Browse files
Add automatic idle connection pruning to ChannelDbConnectionPool (#4304)
Introduce a PoolPruner that periodically samples the pool's idle connection count and, once a sampling window completes, prunes the median number of idle connections. This lets the pool shrink back toward MinPoolSize after load subsides while avoiding over-pruning during brief lulls.
1 parent f880e40 commit 4afd45e

4 files changed

Lines changed: 960 additions & 61 deletions

File tree

src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/AdapterUtil.cs

Lines changed: 13 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -159,32 +159,22 @@ internal static Timer UnsafeCreateTimer(
159159
state,
160160
TimeSpan.FromMilliseconds(dueTimeMilliseconds),
161161
TimeSpan.FromMilliseconds(periodMilliseconds));
162-
162+
163163
internal static Timer UnsafeCreateTimer(TimerCallback callback, object state, TimeSpan dueTime, TimeSpan period)
164164
{
165-
// Don't capture the current ExecutionContext and its AsyncLocals onto
165+
// Don't capture the current ExecutionContext and its AsyncLocals onto
166166
// a global timer causing them to live forever
167-
bool restoreFlow = false;
168-
try
167+
if (ExecutionContext.IsFlowSuppressed())
169168
{
170-
if (!ExecutionContext.IsFlowSuppressed())
171-
{
172-
ExecutionContext.SuppressFlow();
173-
restoreFlow = true;
174-
}
175-
176169
return new Timer(callback, state, dueTime, period);
177170
}
178-
finally
171+
172+
using (ExecutionContext.SuppressFlow())
179173
{
180-
// Restore the current ExecutionContext
181-
if (restoreFlow)
182-
{
183-
ExecutionContext.RestoreFlow();
184-
}
174+
return new Timer(callback, state, dueTime, period);
185175
}
186176
}
187-
177+
188178

189179
#region COM+ exceptions
190180
internal static ArgumentException Argument(string error)
@@ -639,7 +629,7 @@ internal static string BuildMultiPartName(string[] strings)
639629
{
640630
StringBuilder bld = new();
641631
// Assume we want to build a full multi-part name with all parts except trimming separators for
642-
// leading empty names (null or empty strings, but not whitespace). Separators in the middle
632+
// leading empty names (null or empty strings, but not whitespace). Separators in the middle
643633
// should be added, even if the name part is null/empty, to maintain proper location of the parts.
644634
for (int i = 0; i < strings.Length; i++)
645635
{
@@ -839,14 +829,14 @@ internal static Version GetAssemblyVersion()
839829
/// Represents a collection of Azure SQL Server endpoint URLs for various regions and environments.
840830
/// </summary>
841831
/// <remarks>This array includes endpoint URLs for Azure SQL in global, Germany, US Government,
842-
/// China, and Fabric environments. These endpoints are used to identify and interact with Azure SQL services
832+
/// China, and Fabric environments. These endpoints are used to identify and interact with Azure SQL services
843833
/// in their respective regions or environments.</remarks>
844834
internal static readonly List<string> s_azureSqlServerEndpoints = new() { AZURE_SQL,
845835
AZURE_SQL_GERMANY,
846836
AZURE_SQL_USGOV,
847837
AZURE_SQL_CHINA,
848838
AZURE_SQL_FABRIC };
849-
839+
850840
/// <summary>
851841
/// Contains endpoint strings for Azure SQL Server on-demand services.
852842
/// Each entry is a combination of the ONDEMAND_PREFIX and a specific Azure SQL endpoint string.
@@ -872,9 +862,9 @@ internal static Version GetAssemblyVersion()
872862
internal static bool IsAzureSynapseOnDemandEndpoint(string dataSource)
873863
{
874864
return IsEndpoint(dataSource, s_azureSynapseOnDemandEndpoints)
875-
|| dataSource.IndexOf(AZURE_SYNAPSE, StringComparison.OrdinalIgnoreCase) >= 0;
865+
|| dataSource.IndexOf(AZURE_SYNAPSE, StringComparison.OrdinalIgnoreCase) >= 0;
876866
}
877-
867+
878868
internal static bool IsAzureSqlServerEndpoint(string dataSource)
879869
{
880870
return IsEndpoint(dataSource, s_azureSqlServerEndpoints);
@@ -1088,7 +1078,7 @@ internal enum InternalErrorCode
10881078

10891079
SqlDependencyObtainProcessDispatcherFailureObjectHandle = 50,
10901080
SqlDependencyProcessDispatcherFailureCreateInstance = 51,
1091-
1081+
10921082
SqlDependencyCommandHashIsNotAssociatedWithNotification = 53,
10931083

10941084
UnknownTransactionFailure = 60,

src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs

Lines changed: 95 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ namespace Microsoft.Data.SqlClient.ConnectionPool
2323
/// <summary>
2424
/// A connection pool implementation based on the channel data structure.
2525
/// Provides methods to manage the pool of connections, including acquiring and releasing connections.
26-
///
26+
///
2727
/// This implementation uses <see cref="System.Threading.Channels.Channel{T}"/> for managing idle connections,
2828
/// which offers several advantages over the traditional <c>WaitHandleDbConnectionPool</c>:
29-
///
29+
///
3030
/// <list type="bullet">
3131
/// <item><description>
3232
/// <strong>Better async performance:</strong> Channels provide native async/await support without blocking
@@ -45,11 +45,11 @@ namespace Microsoft.Data.SqlClient.ConnectionPool
4545
/// the potential for race conditions in connection lifecycle management.
4646
/// </description></item>
4747
/// </list>
48-
///
48+
///
4949
/// The trade-off is slightly higher memory overhead per pool instance due to the channel infrastructure,
5050
/// but this is generally offset by the performance benefits in async-heavy workloads.
5151
/// </summary>
52-
internal sealed class ChannelDbConnectionPool : IDbConnectionPool
52+
internal sealed class ChannelDbConnectionPool : IDbConnectionPool, IDisposable
5353
{
5454
#region Fields
5555
// Limits synchronous operations which depend on async operations on managed
@@ -115,13 +115,20 @@ internal ChannelDbConnectionPool(
115115
_connectionSlots = new(MaxPoolSize);
116116
_idleChannel = new();
117117

118+
// Pruning is only useful when the pool can grow beyond MinPoolSize.
119+
// If min >= max, the pool is fixed-size and pruning would never activate.
120+
if (MinPoolSize < MaxPoolSize)
121+
{
122+
Pruner = new PoolPruner(this, PoolGroupOptions.LoadBalanceTimeout);
123+
}
124+
118125
State = Running;
119126
}
120127

121128
#region Properties
122129
/// <inheritdoc />
123130
public ConcurrentDictionary<
124-
DbConnectionPoolAuthenticationContextKey,
131+
DbConnectionPoolAuthenticationContextKey,
125132
DbConnectionPoolAuthenticationContext> AuthenticationContexts { get; }
126133

127134
/// <inheritdoc />
@@ -168,6 +175,8 @@ public ConcurrentDictionary<
168175
public bool UseLoadBalancing => PoolGroupOptions.UseLoadBalancing;
169176

170177
private uint MaxPoolSize { get; }
178+
179+
private int MinPoolSize => PoolGroupOptions.MinPoolSize;
171180
#endregion
172181

173182
#region Methods
@@ -223,7 +232,7 @@ public void PutObjectFromTransactedPool(DbConnectionInternal connection)
223232

224233
/// <inheritdoc />
225234
public DbConnectionInternal ReplaceConnection(
226-
DbConnection owningObject,
235+
DbConnection owningObject,
227236
DbConnectionInternal oldConnection,
228237
TimeoutTimer timeout)
229238
{
@@ -242,13 +251,13 @@ public void ReturnInternalConnection(DbConnectionInternal connection, DbConnecti
242251
}
243252

244253
SqlClientEventSource.Log.TryPoolerTraceEvent(
245-
"<prov.DbConnectionPool.DeactivateObject|RES|CPOOL> {0}, Connection {1}, Deactivating.",
246-
Id,
254+
"<prov.DbConnectionPool.DeactivateObject|RES|CPOOL> {0}, Connection {1}, Deactivating.",
255+
Id,
247256
connection.ObjectID);
248257
connection.DeactivateConnection();
249258

250-
if (connection.IsConnectionDoomed ||
251-
!connection.CanBePooled ||
259+
if (connection.IsConnectionDoomed ||
260+
!connection.CanBePooled ||
252261
State == ShuttingDown)
253262
{
254263
RemoveConnection(connection);
@@ -263,9 +272,15 @@ public void ReturnInternalConnection(DbConnectionInternal connection, DbConnecti
263272
/// <inheritdoc />
264273
public void Shutdown()
265274
{
266-
// No-op for now, warmup will be implemented later.
275+
State = ShuttingDown;
276+
Pruner?.Dispose();
267277
}
268278

279+
/// <summary>
280+
/// Disposes the pool by calling <see cref="Shutdown"/>. Does not throw.
281+
/// </summary>
282+
public void Dispose() => Shutdown();
283+
269284
/// <inheritdoc />
270285
public void Startup()
271286
{
@@ -280,7 +295,7 @@ public void TransactionEnded(Transaction transaction, DbConnectionInternal trans
280295

281296
/// <inheritdoc />
282297
public bool TryGetConnection(
283-
DbConnection owningObject,
298+
DbConnection owningObject,
284299
TaskCompletionSource<DbConnectionInternal>? taskCompletionSource,
285300
TimeoutTimer timeout,
286301
out DbConnectionInternal? connection)
@@ -307,19 +322,19 @@ public bool TryGetConnection(
307322
connection = null;
308323
return false;
309324
}
310-
325+
311326
// This is ugly, but async anti-patterns above and below us in the stack necessitate a fresh task to be
312-
// created. Ideally we would just return the Task from GetInternalConnection and let the caller await
327+
// created. Ideally we would just return the Task from GetInternalConnection and let the caller await
313328
// it as needed, but instead we need to signal to the provided TaskCompletionSource when the connection
314-
// is established. This pattern has implications for connection open retry logic that are intricate
315-
// enough to merit dedicated work. For now, callers that need to open many connections asynchronously
316-
// and in parallel *must* pre-prevision threads in the managed thread pool to avoid exhaustion and
329+
// is established. This pattern has implications for connection open retry logic that are intricate
330+
// enough to merit dedicated work. For now, callers that need to open many connections asynchronously
331+
// and in parallel *must* pre-prevision threads in the managed thread pool to avoid exhaustion and
317332
// timeouts.
318-
//
319-
// Also note that we don't have access to the cancellation token passed by the caller to the original
333+
//
334+
// Also note that we don't have access to the cancellation token passed by the caller to the original
320335
// OpenAsync call. This means that we cannot cancel the connection open operation if the caller's token
321-
// is cancelled. We can only cancel based on our own timeout, which is set to the owningObject's
322-
// ConnectionTimeout.
336+
// is cancelled. We can only cancel based on our own timeout, which is set to the owningObject's
337+
// ConnectionTimeout.
323338
Task.Run(async () =>
324339
{
325340
if (taskCompletionSource.Task.IsCompleted)
@@ -372,14 +387,14 @@ public bool TryGetConnection(
372387
/// </summary>
373388
/// <param name="owningConnection">The owning connection.</param>
374389
/// <param name="cancellationToken">The cancellation token to cancel the operation.</param>
375-
/// <param name="timeout">The overall timeout budget. Passed through to the physical connection
390+
/// <param name="timeout">The overall timeout budget. Passed through to the physical connection
376391
/// so it uses the remaining budget rather than starting a fresh timeout.</param>
377392
/// <returns>A task representing the asynchronous operation, with a result of the new internal connection.</returns>
378393
/// <exception cref="OperationCanceledException">
379394
/// Thrown when the cancellation token is cancelled before the connection operation completes.
380395
/// </exception>
381396
private DbConnectionInternal? OpenNewInternalConnection(
382-
DbConnection? owningConnection,
397+
DbConnection? owningConnection,
383398
CancellationToken cancellationToken,
384399
TimeoutTimer timeout)
385400
{
@@ -389,16 +404,16 @@ public bool TryGetConnection(
389404
// Instead, we reserve a connection slot prior to attempting to open a new connection and release the slot
390405
// in case of an exception.
391406

392-
return _connectionSlots.Add(
407+
var result = _connectionSlots.Add(
393408
createCallback: () =>
394409
{
395410
// https://github.com/dotnet/SqlClient/issues/3459
396411
// TODO: This blocks the thread for several network calls!
397-
// When running async, the blocked thread is one allocated from the managed thread pool (due to
398-
// use of Task.Run in TryGetConnection). This is why it's critical for async callers to
399-
// pre-provision threads in the managed thread pool. Our options are limited because
412+
// When running async, the blocked thread is one allocated from the managed thread pool (due to
413+
// use of Task.Run in TryGetConnection). This is why it's critical for async callers to
414+
// pre-provision threads in the managed thread pool. Our options are limited because
400415
// DbConnectionInternal doesn't support an async open. It's better to block this thread and keep
401-
// throughput high than to queue all of our opens onto a single worker thread. Add an async path
416+
// throughput high than to queue all of our opens onto a single worker thread. Add an async path
402417
// when this support is added to DbConnectionInternal.
403418
// TODO: ultimately, the connection factory should also accept our cancellation token.
404419
var connection = ConnectionFactory.CreatePooledConnection(
@@ -420,6 +435,15 @@ public bool TryGetConnection(
420435
_idleChannel?.TryWrite(null);
421436
newConnection?.Dispose();
422437
});
438+
439+
if (result is not null)
440+
{
441+
// A new connection was added to the pool. If we've grown past MinPoolSize,
442+
// start the pruning timer so idle connections can be reclaimed.
443+
Pruner?.UpdateTimer();
444+
}
445+
446+
return result;
423447
}
424448

425449
/// <summary>
@@ -457,13 +481,16 @@ private bool IsLiveConnection(DbConnectionInternal connection)
457481
private void RemoveConnection(DbConnectionInternal connection)
458482
{
459483
_connectionSlots.TryRemove(connection);
460-
484+
461485
// Removing a connection from the pool opens a free slot.
462486
// Write a null to the idle connection channel to wake up a waiter, who can now open a new
463487
// connection. Statement order is important since we have synchronous completions on the channel.
464488
_idleChannel.TryWrite(null);
465489

466490
connection.Dispose();
491+
492+
// If this removal brought us back to MinPoolSize, disable the pruning timer.
493+
Pruner?.UpdateTimer();
467494
}
468495

469496
/// <summary>
@@ -475,7 +502,7 @@ private void RemoveConnection(DbConnectionInternal connection)
475502
// The channel may contain nulls. Read until we find a non-null connection or exhaust the channel.
476503
while (_idleChannel.TryRead(out DbConnectionInternal? connection))
477504
{
478-
if (connection is null)
505+
if (connection is null)
479506
{
480507
continue;
481508
}
@@ -501,16 +528,16 @@ private void RemoveConnection(DbConnectionInternal connection)
501528
/// in the pool is deducted from the budget available for physical connection creation.</param>
502529
/// <returns>Returns a DbConnectionInternal that is retrieved from the pool.</returns>
503530
/// <exception cref="InvalidOperationException">
504-
/// Thrown when an OperationCanceledException is caught, indicating that the timeout period
531+
/// Thrown when an OperationCanceledException is caught, indicating that the timeout period
505532
/// elapsed prior to obtaining a connection from the pool.
506533
/// </exception>
507534
/// <exception cref="Exception">
508-
/// Thrown when a ChannelClosedException is caught, indicating that the connection pool
535+
/// Thrown when a ChannelClosedException is caught, indicating that the connection pool
509536
/// has been shut down.
510537
/// </exception>
511538
private async Task<DbConnectionInternal> GetInternalConnection(
512-
DbConnection owningConnection,
513-
bool async,
539+
DbConnection owningConnection,
540+
bool async,
514541
TimeoutTimer timeout)
515542
{
516543
DbConnectionInternal? connection = null;
@@ -530,7 +557,7 @@ private async Task<DbConnectionInternal> GetInternalConnection(
530557
connection ??= GetIdleConnection();
531558

532559

533-
// If we didn't find an idle connection, try to open a new one.
560+
// If we didn't find an idle connection, try to open a new one.
534561
connection ??= OpenNewInternalConnection(
535562
owningConnection,
536563
cancellationToken,
@@ -604,18 +631,18 @@ private async Task<DbConnectionInternal> GetInternalConnection(
604631
}
605632

606633
/// <summary>
607-
/// Sets connection state and activates the connection for use. Should always be called after a connection is
634+
/// Sets connection state and activates the connection for use. Should always be called after a connection is
608635
/// created or retrieved from the pool.
609636
/// </summary>
610637
/// <param name="owningObject">The owning DbConnection instance.</param>
611638
/// <param name="connection">The DbConnectionInternal to be activated.</param>
612639
/// <exception cref="Exception">
613-
/// Thrown when any exception occurs during connection activation.
640+
/// Thrown when any exception occurs during connection activation.
614641
/// </exception>
615642
private void PrepareConnection(DbConnection owningObject, DbConnectionInternal connection)
616643
{
617644
lock (connection)
618-
{
645+
{
619646
// Protect against Clear which calls IsEmancipated, which is affected by PrePush and PostPop
620647
connection.PostPop(owningObject);
621648
}
@@ -653,5 +680,35 @@ private void ValidateOwnershipAndSetPoolingState(DbConnectionInternal connection
653680
}
654681
}
655682
#endregion
683+
684+
#region Pruning
685+
/// <summary>
686+
/// Manages idle connection pruning. Null when the pool is fixed-size (MinPoolSize >= MaxPoolSize)
687+
/// because pruning would never activate.
688+
/// </summary>
689+
internal PoolPruner? Pruner { get; }
690+
691+
/// <summary>
692+
/// Removes up to <paramref name="count"/> idle connections from the pool, respecting
693+
/// the <see cref="MinPoolSize"/> floor. Called by <see cref="PoolPruner"/> after computing
694+
/// the median of collected samples.
695+
/// </summary>
696+
internal void PruneConnections(int count)
697+
{
698+
while (count > 0
699+
&& IsRunning
700+
&& _connectionSlots.ReservationCount > MinPoolSize
701+
&& _idleChannel.TryRead(out var connection))
702+
{
703+
if (connection is null)
704+
{
705+
continue;
706+
}
707+
708+
RemoveConnection(connection);
709+
count--;
710+
}
711+
}
712+
#endregion
656713
}
657714
}

0 commit comments

Comments
 (0)