-
Notifications
You must be signed in to change notification settings - Fork 331
Reclaim emancipated connections, including while callers are parked #4529
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6c9fb0b
2c6faef
7e34681
513c4d3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ | |
| // See the LICENSE file in the project root for more information. | ||
| using System; | ||
| using System.Collections.Concurrent; | ||
| using System.Collections.Generic; | ||
| using System.Collections.ObjectModel; | ||
| using System.Data.Common; | ||
| using System.Diagnostics; | ||
|
|
@@ -201,6 +202,12 @@ internal ChannelDbConnectionPool( | |
| Pruner = new PoolPruner(this, PoolGroupOptions.IdleTimeout); | ||
| } | ||
|
|
||
| // Reclamation, unlike pruning, applies to every pool configuration: a caller can leak a | ||
| // connection whatever the pool's sizing or idle timeout, and a fixed-size pool is the | ||
| // case where a leaked slot hurts most. The timer inside is created disarmed and only | ||
| // runs while callers are parked, so an unblocked pool pays nothing for it. | ||
| Reclaimer = new PoolReclaimer(this, _timeProvider); | ||
|
|
||
| State = Running; | ||
|
|
||
| SqlClientEventSource.Log.TryPoolerTraceEvent( | ||
|
|
@@ -539,6 +546,19 @@ public void ReturnInternalConnection(DbConnectionInternal connection, DbConnecti | |
|
|
||
| ValidateOwnershipAndSetPoolingState(connection, owningObject); | ||
|
|
||
| DeactivateAndRouteConnection(connection); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Deactivates a connection that is already marked as owned by the pool (via | ||
| /// <see cref="DbConnectionInternal.PrePush"/>) and routes it to the idle channel, the | ||
| /// transacted pool, stasis, or destruction as appropriate. Shared by the normal return path | ||
| /// and by emancipated connection reclamation, which has already performed the | ||
| /// <c>PrePush</c> itself and must not re-validate ownership. | ||
| /// </summary> | ||
| /// <param name="connection">The connection to deactivate and route.</param> | ||
| private void DeactivateAndRouteConnection(DbConnectionInternal connection) | ||
| { | ||
| SqlClientEventSource.Log.TryPoolerTraceEvent( | ||
| "ChannelDbConnectionPool.ReturnInternalConnection | INFO | {0}, Connection {1}, Deactivating.", | ||
| Id, | ||
|
|
@@ -758,6 +778,19 @@ public void Shutdown() | |
| "ChannelDbConnectionPool.Shutdown | INFO | {0}, Pruner.Dispose threw, continuing shutdown: {1}", Id, ex); | ||
| } | ||
|
|
||
| // Stop the background reclaim sweep before draining, for the same reason as the pruning | ||
| // timer: an in-flight sweep must not route a reclaimed connection back into the channel | ||
| // after the drain below has already passed it. | ||
| try | ||
| { | ||
| Reclaimer.Dispose(); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| SqlClientEventSource.Log.TryPoolerTraceEvent( | ||
| "ChannelDbConnectionPool.Shutdown | INFO | {0}, Reclaimer.Dispose threw, continuing shutdown: {1}", Id, ex); | ||
| } | ||
|
|
||
| // Dispose the error state so its exit timer is released. Otherwise a timer scheduled | ||
| // during the blocking period would keep this pool reachable and continue firing | ||
| // callbacks/logging after shutdown. | ||
|
|
@@ -1465,16 +1498,40 @@ private async Task<DbConnectionInternal> GetInternalConnection( | |
| cancellationToken, | ||
| timeout); | ||
|
|
||
| // Before parking on the idle channel (potentially for the full timeout), sweep | ||
| // for connections whose owning SqlConnection was garbage collected without ever | ||
| // being closed or disposed. Those "emancipated" connections still occupy pool | ||
| // slots, so at MaxPoolSize every subsequent request would otherwise time out | ||
| // forever. WaitHandleDbConnectionPool performs the same sweep before waiting. | ||
| // This is deliberately confined to the slow path: it is O(MaxPoolSize) and | ||
| // allocates a snapshot, so it must not run on the hot acquire path. | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Stale — it no longer allocates a snapshot after the enumerator change in this same PR. |
||
| if (connection is null && ReclaimEmancipatedConnections()) | ||
| { | ||
| connection = GetIdleConnection(); | ||
| } | ||
|
|
||
| // If we're at max capacity and couldn't open a connection. Block on the idle channel with a | ||
| // timeout. Note that Channels guarantee fair FIFO behavior to callers of ReadAsync | ||
| // (first-come, first-served), which is crucial to us. | ||
| if (async) | ||
| { | ||
| connection ??= await _idleChannel.ReadAsync(cancellationToken).ConfigureAwait(false); | ||
| } | ||
| else | ||
| // | ||
| // That FIFO guarantee is also why the reclaim sweep above cannot simply be retried | ||
| // on this thread while we wait: cancelling the read to re-sweep would send us to | ||
| // the back of the queue behind callers that arrived later. Instead we register | ||
| // with the reclaimer, which sweeps on a timer for as long as anyone is parked and | ||
| // routes anything it reclaims back through this channel, waking us here. | ||
| if (connection is null) | ||
| { | ||
| connection ??= ReadChannelSyncOverAsync(cancellationToken); | ||
| Reclaimer.EnterParkedWait(); | ||
| try | ||
| { | ||
| connection = async | ||
| ? await _idleChannel.ReadAsync(cancellationToken).ConfigureAwait(false) | ||
| : ReadChannelSyncOverAsync(cancellationToken); | ||
| } | ||
| finally | ||
| { | ||
| Reclaimer.ExitParkedWait(); | ||
| } | ||
| } | ||
| } | ||
| catch (OperationCanceledException) | ||
|
|
@@ -1508,6 +1565,71 @@ private async Task<DbConnectionInternal> GetInternalConnection( | |
| return connection; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Reclaims connections whose owning <see cref="DbConnection"/> has been garbage collected | ||
| /// without being closed or disposed. Such connections are still tracked by the pool but can | ||
| /// never be returned by their owner, so without this sweep they would leak pool slots. | ||
| /// </summary> | ||
| /// <returns>True if at least one connection was reclaimed; otherwise, false.</returns> | ||
| internal bool ReclaimEmancipatedConnections() | ||
| { | ||
| SqlClientEventSource.Log.TryPoolerTraceEvent( | ||
| "ChannelDbConnectionPool.ReclaimEmancipatedConnections | INFO | {0}, Sweeping for emancipated connections.", Id); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This fires once per second per blocked pool even when the sweep finds nothing. Move it below the |
||
|
|
||
| List<DbConnectionInternal>? reclaimed = null; | ||
|
|
||
| foreach (DbConnectionInternal connection in _connectionSlots) | ||
| { | ||
| // TryEnter rather than Enter: IsEmancipated must be read under the connection lock to | ||
| // avoid racing PrePush/PostPop, but a connection that is currently locked is being | ||
| // actively handed out or returned and therefore is not emancipated anyway. Skipping | ||
| // it keeps this sweep from blocking the caller. | ||
| bool locked = false; | ||
| try | ||
| { | ||
| Monitor.TryEnter(connection, ref locked); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Legacy does this scan under |
||
|
|
||
| if (locked && connection.IsEmancipated) | ||
| { | ||
| // Do as little as possible under the lock: just claim the connection for the | ||
| // pool and defer deactivation (which can make server round trips) until the | ||
| // lock is released. | ||
| connection.PrePush(null); | ||
| (reclaimed ??= new List<DbConnectionInternal>()).Add(connection); | ||
| } | ||
| } | ||
| finally | ||
| { | ||
| if (locked) | ||
| { | ||
| Monitor.Exit(connection); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (reclaimed is null) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| foreach (DbConnectionInternal connection in reclaimed) | ||
| { | ||
| SqlClientEventSource.Log.TryPoolerTraceEvent( | ||
| "ChannelDbConnectionPool.ReclaimEmancipatedConnections | INFO | {0}, Connection {1}, Reclaiming.", | ||
| Id, | ||
| connection.ObjectID); | ||
|
|
||
| // Matches WaitHandleDbConnectionPool so the number-of-reclaimed-connections counter | ||
| // is meaningful under pool V2 as well; it previously always read zero here. | ||
| Metrics.ReclaimedConnectionRequest(); | ||
|
|
||
| connection.DetachCurrentTransactionIfEnded(); | ||
| DeactivateAndRouteConnection(connection); | ||
| } | ||
|
|
||
| return true; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Performs a blocking synchronous read from the idle connection channel. | ||
| /// </summary> | ||
|
|
@@ -1891,5 +2013,14 @@ internal void PruneConnections(int count) | |
| pruned); | ||
| } | ||
| #endregion | ||
|
|
||
| #region Reclamation | ||
| /// <summary> | ||
| /// Drives background sweeps for emancipated connections while callers are parked on the idle | ||
| /// channel. Unlike <see cref="Pruner"/> this is always present, because a connection can leak | ||
| /// in any pool configuration, including a fixed-size one. | ||
| /// </summary> | ||
| internal PoolReclaimer Reclaimer { get; } | ||
| #endregion | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ITimer.Dispose()doesn't wait for an in-flight callback, so this doesn't close the race the comment claims to close. A sweep already past theIsRunningcheck inOnSweepCallbackkeeps running and canTryWritea reclaimed connection into a channel the drain below has already passed — that connection is then never disposed.Either make it true (re-check state before routing in the reclaim path, or drain again after disposing) or soften the comment to "narrows the window".