@@ -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