-
Notifications
You must be signed in to change notification settings - Fork 329
Implement pool shutdown for ChannelDbConnectionPool and harden WaitHandleDbConnectionPool shutdown #4302
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
base: main
Are you sure you want to change the base?
Implement pool shutdown for ChannelDbConnectionPool and harden WaitHandleDbConnectionPool shutdown #4302
Changes from 1 commit
6431209
b24d4fb
7ba73a8
b8d56d9
9a45a66
954679d
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 |
|---|---|---|
|
|
@@ -329,6 +329,14 @@ public bool IsRunning | |
|
|
||
| private void CleanupCallback(object state) | ||
| { | ||
| // FR-005: if the pool is shutting down, skip work. Shutdown disposes the timer, but | ||
| // a callback may already be in-flight when Shutdown runs; this guard ensures it does | ||
| // not perform pruning or re-arm pool create requests. | ||
| if (State == ShuttingDown) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| // Called when the cleanup-timer ticks over. | ||
|
|
||
| // This is the automatic pruning method. Every period, we will | ||
|
|
@@ -767,6 +775,13 @@ private void DestroyObject(DbConnectionInternal obj) | |
|
|
||
| private void ErrorCallback(object state) | ||
| { | ||
| // FR-005: skip work if the pool is shutting down. The shutdown path disposes the | ||
| // timer; this guard handles the in-flight-callback race. | ||
| if (State == ShuttingDown) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| SqlClientEventSource.Log.TryPoolerTraceEvent("<prov.DbConnectionPool.ErrorCallback|RES|CPOOL> {0}, Resetting Error handling.", Id); | ||
| _errorOccurred = false; | ||
| _waitHandles.ErrorEvent.Reset(); | ||
|
|
@@ -956,6 +971,16 @@ private bool TryGetConnection(DbConnection owningObject, uint waitForMultipleObj | |
| { | ||
| waitResult = WaitHandle.WaitAny(_waitHandles.GetHandles(allowCreate), unchecked((int)waitForMultipleObjectsTimeout)); | ||
|
|
||
| // FR-007: after waking, observe shutdown state and bail out so waiters | ||
| // do not spin against a drained pool. | ||
| if (State != Running) | ||
| { | ||
| SqlClientEventSource.Log.TryPoolerTraceEvent("<prov.DbConnectionPool.GetConnection|RES|CPOOL> {0}, Pool is shutting down; abandoning wait.", Id); | ||
| Interlocked.Decrement(ref _waitCount); | ||
| connection = null; | ||
| return false; | ||
| } | ||
|
|
||
| // From the WaitAny docs: "If more than one object became signaled during | ||
| // the call, this is the array index of the signaled object with the | ||
| // smallest index value of all the signaled objects." This is important | ||
|
|
@@ -1481,14 +1506,45 @@ public void Startup() | |
| public void Shutdown() | ||
| { | ||
| SqlClientEventSource.Log.TryPoolerTraceEvent("<prov.DbConnectionPool.Shutdown|RES|INFO|CPOOL> {0}", Id); | ||
|
|
||
| // FR-006: idempotent. Subsequent calls observe ShuttingDown and bail. | ||
| if (State == ShuttingDown) | ||
| { | ||
| return; | ||
| } | ||
|
Comment on lines
1521
to
+1529
|
||
| State = ShuttingDown; | ||
|
|
||
| // deactivate timer callbacks | ||
| Timer t = _cleanupTimer; | ||
| _cleanupTimer = null; | ||
| if (t != null) | ||
| // FR-005: dispose all background timers so they no longer schedule new work. | ||
| // Note that any timer callback already in flight may still observe State == ShuttingDown | ||
| // and short-circuit (see CleanupCallback / ErrorCallback). | ||
| Timer cleanup = Interlocked.Exchange(ref _cleanupTimer, null); | ||
|
Contributor
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. Let's make sure we've addressed this bug: #1881
Contributor
Author
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 PR adds the per-pool Shutdown() machinery that #1881 ultimately needs, but it doesn't actually touch SqlConnectionFactory._pruningTimer — so the symptom (the PruneConnectionPoolGroups trace firing every 30 s after ClearAllPools()) will still reproduce. I'd prefer to land #4302 as the foundation and address #1881 in a small follow-up that adds a "park the timer when there's no work" guard to PruneConnectionPoolGroups and a re-arm in GetConnectionPoolGroup. Happy to file that follow-up issue and link it. |
||
| cleanup?.Dispose(); | ||
| Timer error = Interlocked.Exchange(ref _errorTimer, null); | ||
| error?.Dispose(); | ||
|
|
||
| // FR-007: wake any threads parked in WaitHandle.WaitAny by releasing the pool semaphore | ||
| // up to its max count. Waiters check State == Running after wake-up and bail. | ||
| // We may release more than necessary; SemaphoreFullException is harmless when the | ||
| // semaphore is already saturated. | ||
| try | ||
| { | ||
| _waitHandles.PoolSemaphore.Release(MaxPoolSize); | ||
| } | ||
| catch (SemaphoreFullException) | ||
| { | ||
| // Already at max count - all slots free. Nothing to do. | ||
| } | ||
|
priyankatiwari08 marked this conversation as resolved.
Outdated
|
||
|
|
||
| // FR-003: drain idle stacks and destroy contained connections. Active checked-out | ||
| // connections are destroyed when they are returned (see DeactivateObject's | ||
| // State is ShuttingDown branch). | ||
| while (_stackNew.TryPop(out DbConnectionInternal newObj)) | ||
|
priyankatiwari08 marked this conversation as resolved.
Outdated
|
||
| { | ||
| DestroyObject(newObj); | ||
| } | ||
| while (_stackOld.TryPop(out DbConnectionInternal oldObj)) | ||
| { | ||
| t.Dispose(); | ||
| DestroyObject(oldObj); | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,190 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
| // See the LICENSE file in the project root for more information. | ||
|
|
||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.Data.Common; | ||
| using System.Threading.Tasks; | ||
| using Microsoft.Data.Common.ConnectionString; | ||
| using Microsoft.Data.ProviderBase; | ||
| using Microsoft.Data.SqlClient.ConnectionPool; | ||
| using Xunit; | ||
|
Comment on lines
+5
to
+12
|
||
|
|
||
| namespace Microsoft.Data.SqlClient.UnitTests.ConnectionPool | ||
| { | ||
| /// <summary> | ||
| /// Deterministic tests for <see cref="ChannelDbConnectionPool"/> shutdown behavior. | ||
| /// Verifies the contract defined by spec 004-pool-shutdown FR-001 .. FR-007 (P1 scope). | ||
| /// </summary> | ||
| public class ChannelDbConnectionPoolShutdownTest | ||
| { | ||
| private static ChannelDbConnectionPool ConstructPool(int maxPoolSize = 5) | ||
| { | ||
| var poolGroupOptions = new DbConnectionPoolGroupOptions( | ||
| poolByIdentity: false, | ||
| minPoolSize: 0, | ||
| maxPoolSize: maxPoolSize, | ||
| creationTimeout: 15, | ||
| loadBalanceTimeout: 0, | ||
| hasTransactionAffinity: true); | ||
|
|
||
| var dbConnectionPoolGroup = new DbConnectionPoolGroup( | ||
| new SqlConnectionOptions("Data Source=localhost;"), | ||
| new ConnectionPoolKey("TestDataSource", credential: null, accessToken: null, accessTokenCallback: null, sspiContextProvider: null), | ||
| poolGroupOptions); | ||
|
|
||
| return new ChannelDbConnectionPool( | ||
| new ChannelDbConnectionPoolTest.SuccessfulSqlConnectionFactory(), | ||
| dbConnectionPoolGroup, | ||
| DbConnectionPoolIdentity.NoIdentity, | ||
| new DbConnectionPoolProviderInfo()); | ||
| } | ||
|
|
||
| // FR-001 | ||
| [Fact] | ||
| public void Shutdown_TransitionsState_ToShuttingDown() | ||
| { | ||
| var pool = ConstructPool(); | ||
| Assert.True(pool.IsRunning); | ||
| Assert.Equal(DbConnectionPoolState.Running, pool.State); | ||
|
|
||
| pool.Shutdown(); | ||
|
|
||
| Assert.False(pool.IsRunning); | ||
| Assert.Equal(DbConnectionPoolState.ShuttingDown, pool.State); | ||
| } | ||
|
|
||
| // FR-003 | ||
| [Fact] | ||
| public void Shutdown_DrainsIdleConnections() | ||
| { | ||
| var pool = ConstructPool(); | ||
|
|
||
| // Vend and return three connections so they sit idle in the channel. | ||
| var owners = new List<SqlConnection>(); | ||
| var conns = new List<DbConnectionInternal>(); | ||
| for (int i = 0; i < 3; i++) | ||
| { | ||
| var owner = new SqlConnection(); | ||
| owners.Add(owner); | ||
| Assert.True(pool.TryGetConnection(owner, taskCompletionSource: null, out DbConnectionInternal? c)); | ||
| Assert.NotNull(c); | ||
| conns.Add(c!); | ||
| } | ||
| for (int i = 0; i < conns.Count; i++) | ||
| { | ||
| pool.ReturnInternalConnection(conns[i], owners[i]); | ||
| } | ||
| Assert.Equal(3, pool.IdleCount); | ||
|
|
||
| pool.Shutdown(); | ||
|
|
||
| Assert.Equal(0, pool.IdleCount); | ||
| Assert.Equal(0, pool.Count); | ||
| } | ||
|
|
||
| // FR-004 - returned connection while shutting down is destroyed, not pooled. | ||
| [Fact] | ||
| public void Shutdown_ReturnedConnection_IsDestroyedNotPooled() | ||
| { | ||
| var pool = ConstructPool(); | ||
| var owner = new SqlConnection(); | ||
| Assert.True(pool.TryGetConnection(owner, taskCompletionSource: null, out DbConnectionInternal? conn)); | ||
| Assert.NotNull(conn); | ||
| Assert.Equal(1, pool.Count); | ||
| Assert.Equal(0, pool.IdleCount); | ||
|
|
||
| pool.Shutdown(); | ||
|
|
||
| // Connection is still checked out; return it now. | ||
| pool.ReturnInternalConnection(conn!, owner); | ||
|
|
||
| Assert.Equal(0, pool.IdleCount); | ||
| Assert.Equal(0, pool.Count); | ||
| } | ||
|
|
||
| // FR-006 | ||
| [Fact] | ||
| public void Shutdown_IsIdempotent() | ||
| { | ||
| var pool = ConstructPool(); | ||
| pool.Shutdown(); | ||
| // Second call must not throw and must leave state intact. | ||
| pool.Shutdown(); | ||
| pool.Shutdown(); | ||
| Assert.Equal(DbConnectionPoolState.ShuttingDown, pool.State); | ||
| } | ||
|
|
||
| // FR-007 - async waiter is unblocked when the pool shuts down. | ||
| [Fact] | ||
| public async Task Shutdown_UnblocksAsyncWaiter() | ||
| { | ||
| var pool = ConstructPool(maxPoolSize: 1); | ||
|
|
||
| // Saturate the pool. | ||
| Assert.True(pool.TryGetConnection(new SqlConnection(), taskCompletionSource: null, out DbConnectionInternal? blocking)); | ||
| Assert.NotNull(blocking); | ||
|
|
||
| // Park an async waiter. | ||
| var tcs = new TaskCompletionSource<DbConnectionInternal>(); | ||
| bool completed = pool.TryGetConnection(new SqlConnection(), tcs, out DbConnectionInternal? waiter); | ||
| Assert.False(completed); | ||
| Assert.Null(waiter); | ||
| Assert.False(tcs.Task.IsCompleted); | ||
|
|
||
| // Shut down the pool. | ||
| pool.Shutdown(); | ||
|
|
||
| // Waiter must complete (faulted or with a null connection) within a bounded window. | ||
| var winner = await Task.WhenAny(tcs.Task, Task.Delay(TimeSpan.FromSeconds(5))); | ||
| Assert.Same(tcs.Task, winner); | ||
| // Either an exception was set (channel closed) or the result is null - both are acceptable | ||
| // shutdown signals. What matters is the waiter does NOT block forever. | ||
| if (tcs.Task.IsFaulted) | ||
| { | ||
| // Expected path: ChannelClosedException or a wrapped exception. | ||
| Assert.NotNull(tcs.Task.Exception); | ||
| } | ||
| else | ||
| { | ||
| // Permitted: completed with null result. | ||
| Assert.Null(tcs.Task.Result); | ||
| } | ||
| } | ||
|
|
||
| // Story 3 acceptance #3 - sync get fails fast after shutdown. | ||
| // The factory-level retry guard checks IsRunning, but the pool itself must not vend | ||
| // new connections after Shutdown. We verify by exhausting the pool first then | ||
| // checking that returned connections are destroyed (Count goes back to 0). | ||
| [Fact] | ||
| public void Shutdown_AfterShutdown_NewReturnsAreDestroyed() | ||
| { | ||
| var pool = ConstructPool(); | ||
| var owner = new SqlConnection(); | ||
| Assert.True(pool.TryGetConnection(owner, taskCompletionSource: null, out DbConnectionInternal? c)); | ||
| Assert.NotNull(c); | ||
|
|
||
| pool.Shutdown(); | ||
| pool.ReturnInternalConnection(c!, owner); | ||
|
|
||
| Assert.False(pool.IsRunning); | ||
| Assert.Equal(0, pool.Count); | ||
| Assert.Equal(0, pool.IdleCount); | ||
| } | ||
|
|
||
| // Sanity: Startup is currently a no-op for this pool but must not throw or change | ||
| // shutdown state if invoked after Shutdown. | ||
| [Fact] | ||
| public void Startup_AfterShutdown_DoesNotResurrectPool() | ||
| { | ||
| var pool = ConstructPool(); | ||
| pool.Shutdown(); | ||
|
|
||
| pool.Startup(); | ||
|
|
||
| Assert.Equal(DbConnectionPoolState.ShuttingDown, pool.State); | ||
| Assert.False(pool.IsRunning); | ||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.