Skip to content

Commit 04b8001

Browse files
mdaigleCopilot
andcommitted
Close remaining ChannelDbConnectionPool parity gaps with WaitHandleDbConnectionPool
Differential testing of the two pool implementations (running the full Functional/Unit/Manual suites under both, plus a bespoke harness) turned up four behavioural gaps in ChannelDbConnectionPool that had no test coverage. 1. Emancipated connection reclamation. A SqlConnection that was garbage collected without ever being closed or disposed left its internal connection permanently occupying a pool slot. At MaxPoolSize this meant every subsequent Open timed out forever. GetInternalConnection now sweeps for emancipated connections before parking on the idle channel, mirroring WaitHandleDbConnectionPool. The sweep is confined to the slow path since it is O(MaxPoolSize) and allocates a snapshot. 2. Pool metrics were never emitted. PooledConnections, FreeConnections, ActiveConnections and the soft/hard connect and disconnect counters all read zero under this pool. Wired up the same call sites the wait handle pool uses. 3. Count reported reservations rather than connections. Reservations include connections that are still being opened, which broke the SQL Express user instance path in SqlConnectionFactory.CreateConnection (a `pool.Count <= 0` check) with a NullReferenceException. Added ConnectionPoolSlots.ConnectionCount and pointed Count at it. 4. Async opens always completed asynchronously. The wait handle pool makes a non-blocking, non-creating attempt at an idle connection before enqueuing a pending open; this pool did not, so OpenAsync against a warm pool always took a thread pool hop. Added the same fast path, excluding transactional requests so they still go through the transacted store and enlist properly. Test changes: - Added ConnectionPoolVersionScope, which flips the pool version switch and clears all pools on both entry and exit. Clearing is required because a pool binds to its implementation at creation time, so without it pools leak across tests. - Parameterized ReclaimEmancipatedOnOpenTest, MaxPoolWaitForConnectionTest, ConnectionResiliencySPIDTest and MetricsTest.PooledConnectionsCounters_Functional by pool version. Each was verified to fail before the corresponding fix. - ChannelDbConnectionPoolTest.StressTestAsync awaited its TaskCompletionSource unconditionally, which hangs now that TryGetConnection can complete synchronously. - Three pool-exhaustion unit tests let their owning SqlConnections go out of scope, so reclamation could legitimately hand the "should time out" waiter a connection. They now keep the owners alive. - TvpTest.TestPacketNumberWraparound passed an async lambda to Task.Factory.StartNew and so awaited a Task<Task>, never observing the inner task. Added the missing Unwrap. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent c540496 commit 04b8001

9 files changed

Lines changed: 286 additions & 20 deletions

File tree

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

Lines changed: 124 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// See the LICENSE file in the project root for more information.
44
using System;
55
using System.Collections.Concurrent;
6+
using System.Collections.Generic;
67
using System.Collections.ObjectModel;
78
using System.Data.Common;
89
using System.Diagnostics;
@@ -209,7 +210,7 @@ public ConcurrentDictionary<
209210
public SqlConnectionFactory ConnectionFactory { get; }
210211

211212
/// <inheritdoc />
212-
public int Count => _connectionSlots.ReservationCount;
213+
public int Count => _connectionSlots.ConnectionCount;
213214

214215
/// <inheritdoc />
215216
public int IdleCount => _idleChannel.Count;
@@ -447,8 +448,23 @@ public DbConnectionInternal ReplaceConnection(
447448
/// <inheritdoc />
448449
public void ReturnInternalConnection(DbConnectionInternal connection, DbConnection owningObject)
449450
{
451+
SqlClientDiagnostics.Metrics.SoftDisconnectRequest();
452+
450453
ValidateOwnershipAndSetPoolingState(connection, owningObject);
451454

455+
DeactivateAndRouteConnection(connection);
456+
}
457+
458+
/// <summary>
459+
/// Deactivates a connection that is already marked as owned by the pool (via
460+
/// <see cref="DbConnectionInternal.PrePush"/>) and routes it to the idle channel, the
461+
/// transacted pool, stasis, or destruction as appropriate. Shared by the normal return path
462+
/// and by emancipated connection reclamation, which has already performed the
463+
/// <c>PrePush</c> itself and must not re-validate ownership.
464+
/// </summary>
465+
/// <param name="connection">The connection to deactivate and route.</param>
466+
private void DeactivateAndRouteConnection(DbConnectionInternal connection)
467+
{
452468
SqlClientEventSource.Log.TryPoolerTraceEvent(
453469
"<prov.DbConnectionPool.DeactivateObject|RES|CPOOL> {0}, Connection {1}, Deactivating.",
454470
Id,
@@ -826,6 +842,30 @@ public bool TryGetConnection(
826842
// processes pending opens on a dedicated non-thread-pool thread.
827843
Transaction? ambientTransaction = taskCompletionSource.Task.AsyncState as Transaction;
828844

845+
// Try to satisfy the request synchronously from the idle channel before paying for a
846+
// thread pool hop. WaitHandleDbConnectionPool makes the same non-blocking, non-creating
847+
// attempt before enqueuing a pending open, so without this an async open against a warm
848+
// pool would always complete asynchronously under this pool but synchronously under the
849+
// other -- a behavioural difference callers can observe. We deliberately do not try to
850+
// *create* a connection here; that can block on the wire and must stay off the caller's
851+
// thread.
852+
//
853+
// Transactional requests are excluded: they must first consult the transacted store for
854+
// a connection already enlisted in the same transaction, which only GetInternalConnection
855+
// does. Taking a plain idle connection here would both miss that affinity and skip
856+
// enlistment.
857+
if (!(HasTransactionAffinity && ambientTransaction is not null))
858+
{
859+
DbConnectionInternal? idleConnection = GetIdleConnection();
860+
if (idleConnection is not null)
861+
{
862+
PrepareConnection(owningObject, idleConnection);
863+
SqlClientDiagnostics.Metrics.SoftConnectRequest();
864+
connection = idleConnection;
865+
return true;
866+
}
867+
}
868+
829869
Task.Run(async () =>
830870
{
831871
if (taskCompletionSource.Task.IsCompleted)
@@ -1008,6 +1048,8 @@ _connectionCreationRateLimiter is not null &&
10081048

10091049
if (connection is not null)
10101050
{
1051+
SqlClientDiagnostics.Metrics.EnterPooledConnection();
1052+
10111053
// A new connection was added to the pool. If we've grown past MinPoolSize,
10121054
// start the pruning timer so idle connections can be reclaimed.
10131055
Pruner?.UpdateTimer();
@@ -1113,14 +1155,18 @@ private void RemoveConnection(DbConnectionInternal connection)
11131155
return;
11141156
}
11151157

1116-
_connectionSlots.TryRemove(connection);
1158+
if (_connectionSlots.TryRemove(connection))
1159+
{
1160+
SqlClientDiagnostics.Metrics.ExitPooledConnection();
1161+
}
11171162

11181163
// Removing a connection from the pool opens a free slot.
11191164
// Write a null to the idle connection channel to wake up a waiter, who can now open a new
11201165
// connection. Statement order is important since we have synchronous completions on the channel.
11211166
_idleChannel.TryWrite(null);
11221167

11231168
connection.Dispose();
1169+
SqlClientDiagnostics.Metrics.HardDisconnectRequest();
11241170

11251171
// If this removal brought us back to MinPoolSize, disable the pruning timer.
11261172
Pruner?.UpdateTimer();
@@ -1225,6 +1271,18 @@ private async Task<DbConnectionInternal> GetInternalConnection(
12251271
cancellationToken,
12261272
timeout);
12271273

1274+
// Before parking on the idle channel (potentially for the full timeout), sweep
1275+
// for connections whose owning SqlConnection was garbage collected without ever
1276+
// being closed or disposed. Those "emancipated" connections still occupy pool
1277+
// slots, so at MaxPoolSize every subsequent request would otherwise time out
1278+
// forever. WaitHandleDbConnectionPool performs the same sweep before waiting.
1279+
// This is deliberately confined to the slow path: it is O(MaxPoolSize) and
1280+
// allocates a snapshot, so it must not run on the hot acquire path.
1281+
if (connection is null && ReclaimEmancipatedConnections())
1282+
{
1283+
connection = GetIdleConnection();
1284+
}
1285+
12281286
// If we're at max capacity and couldn't open a connection. Block on the idle channel with a
12291287
// timeout. Note that Channels guarantee fair FIFO behavior to callers of ReadAsync
12301288
// (first-come, first-served), which is crucial to us.
@@ -1255,9 +1313,73 @@ private async Task<DbConnectionInternal> GetInternalConnection(
12551313
}
12561314

12571315
PrepareConnection(owningConnection, connection, transaction);
1316+
SqlClientDiagnostics.Metrics.SoftConnectRequest();
12581317
return connection;
12591318
}
12601319

1320+
/// <summary>
1321+
/// Reclaims connections whose owning <see cref="DbConnection"/> has been garbage collected
1322+
/// without being closed or disposed. Such connections are still tracked by the pool but can
1323+
/// never be returned by their owner, so without this sweep they would leak pool slots.
1324+
/// </summary>
1325+
/// <returns>True if at least one connection was reclaimed; otherwise, false.</returns>
1326+
private bool ReclaimEmancipatedConnections()
1327+
{
1328+
SqlClientEventSource.Log.TryPoolerTraceEvent(
1329+
"<prov.DbConnectionPool.ReclaimEmancipatedObjects|RES|CPOOL> {0}", Id);
1330+
1331+
List<DbConnectionInternal>? reclaimed = null;
1332+
1333+
foreach (DbConnectionInternal connection in _connectionSlots.Snapshot())
1334+
{
1335+
// TryEnter rather than Enter: IsEmancipated must be read under the connection lock to
1336+
// avoid racing PrePush/PostPop, but a connection that is currently locked is being
1337+
// actively handed out or returned and therefore is not emancipated anyway. Skipping
1338+
// it keeps this sweep from blocking the caller.
1339+
bool locked = false;
1340+
try
1341+
{
1342+
Monitor.TryEnter(connection, ref locked);
1343+
1344+
if (locked && connection.IsEmancipated)
1345+
{
1346+
// Do as little as possible under the lock: just claim the connection for the
1347+
// pool and defer deactivation (which can make server round trips) until the
1348+
// lock is released.
1349+
connection.PrePush(null);
1350+
(reclaimed ??= new List<DbConnectionInternal>()).Add(connection);
1351+
}
1352+
}
1353+
finally
1354+
{
1355+
if (locked)
1356+
{
1357+
Monitor.Exit(connection);
1358+
}
1359+
}
1360+
}
1361+
1362+
if (reclaimed is null)
1363+
{
1364+
return false;
1365+
}
1366+
1367+
foreach (DbConnectionInternal connection in reclaimed)
1368+
{
1369+
SqlClientEventSource.Log.TryPoolerTraceEvent(
1370+
"<prov.DbConnectionPool.ReclaimEmancipatedObjects|RES|CPOOL> {0}, Connection {1}, Reclaiming.",
1371+
Id,
1372+
connection.ObjectID);
1373+
1374+
SqlClientDiagnostics.Metrics.ReclaimedConnectionRequest();
1375+
1376+
connection.DetachCurrentTransactionIfEnded();
1377+
DeactivateAndRouteConnection(connection);
1378+
}
1379+
1380+
return true;
1381+
}
1382+
12611383
/// <summary>
12621384
/// Performs a blocking synchronous read from the idle connection channel.
12631385
/// </summary>

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

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// See the LICENSE file in the project root for more information.
44

55
using System;
6+
using System.Collections.Generic;
67
using System.Diagnostics;
78
using System.Threading;
89
using Microsoft.Data.ProviderBase;
@@ -60,6 +61,7 @@ internal void Keep()
6061
private readonly DbConnectionInternal?[] _connections;
6162
private readonly uint _capacity;
6263
private volatile int _reservations;
64+
private volatile int _connectionCount;
6365

6466
/// <summary>
6567
/// Constructs a ConnectionPoolSlots instance with the given fixed capacity.
@@ -82,14 +84,23 @@ internal ConnectionPoolSlots(uint fixedCapacity)
8284

8385
_capacity = fixedCapacity;
8486
_reservations = 0;
87+
_connectionCount = 0;
8588
_connections = new DbConnectionInternal?[fixedCapacity];
8689
}
8790

8891
/// <summary>
89-
/// Gets the total number of reservations currently held.
92+
/// Gets the total number of reservations currently held. This includes reservations held on
93+
/// behalf of connections that are still being opened and are therefore not yet tracked.
9094
/// </summary>
9195
internal int ReservationCount => _reservations;
9296

97+
/// <summary>
98+
/// Gets the number of connections currently tracked by this collection. Unlike
99+
/// <see cref="ReservationCount"/>, this excludes reservations held for connections that are
100+
/// still being opened, so it reports connections that actually belong to the pool.
101+
/// </summary>
102+
internal int ConnectionCount => _connectionCount;
103+
93104
/// <summary>
94105
/// Adds a connection to the collection.
95106
/// </summary>
@@ -127,6 +138,7 @@ internal ConnectionPoolSlots(uint fixedCapacity)
127138
{
128139
if (Interlocked.CompareExchange(ref _connections[i], connection, null) == null)
129140
{
141+
Interlocked.Increment(ref _connectionCount);
130142
reservation.Keep();
131143
return connection;
132144
}
@@ -162,6 +174,7 @@ internal bool TryRemove(DbConnectionInternal connection)
162174
{
163175
if (Interlocked.CompareExchange(ref _connections[i], null, connection) == connection)
164176
{
177+
Interlocked.Decrement(ref _connectionCount);
165178
ReleaseReservation();
166179
return true;
167180
}
@@ -190,6 +203,28 @@ internal bool TryReplace(DbConnectionInternal oldConnection, DbConnectionInterna
190203
return false;
191204
}
192205

206+
/// <summary>
207+
/// Returns a point-in-time snapshot of the connections currently tracked by this collection.
208+
/// The snapshot is best-effort: connections may be added or removed while it is being taken,
209+
/// so callers must tolerate entries that have since left the pool. Intended for infrequent
210+
/// bookkeeping passes (e.g. reclaiming emancipated connections), not for hot paths.
211+
/// </summary>
212+
internal List<DbConnectionInternal> Snapshot()
213+
{
214+
List<DbConnectionInternal> snapshot = new(_connections.Length);
215+
216+
for (int i = 0; i < _connections.Length; i++)
217+
{
218+
DbConnectionInternal? connection = Volatile.Read(ref _connections[i]);
219+
if (connection is not null)
220+
{
221+
snapshot.Add(connection);
222+
}
223+
}
224+
225+
return snapshot;
226+
}
227+
193228
/// <summary>
194229
/// Attempts to reserve a spot in the collection.
195230
/// </summary>

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ internal bool TryWrite(DbConnectionInternal? connection)
5656
if (connection is not null)
5757
{
5858
Interlocked.Increment(ref _count);
59+
SqlClientDiagnostics.Metrics.EnterFreeConnection();
5960
}
6061
return true;
6162
}
@@ -74,6 +75,7 @@ internal bool TryRead(out DbConnectionInternal? connection)
7475
if (connection is not null)
7576
{
7677
Interlocked.Decrement(ref _count);
78+
SqlClientDiagnostics.Metrics.ExitFreeConnection();
7779
}
7880

7981
return true;
@@ -93,6 +95,7 @@ internal bool TryRead(out DbConnectionInternal? connection)
9395
if (connection is not null)
9496
{
9597
Interlocked.Decrement(ref _count);
98+
SqlClientDiagnostics.Metrics.ExitFreeConnection();
9699
}
97100

98101
return connection;
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
namespace Microsoft.Data.SqlClient.Tests.Common;
6+
7+
/// <summary>
8+
/// Selects the connection pool implementation (<c>WaitHandleDbConnectionPool</c> or
9+
/// <c>ChannelDbConnectionPool</c>) for the duration of a test.
10+
///
11+
/// A pool is bound to an implementation when it is created, so simply flipping the
12+
/// <c>UseConnectionPoolV2</c> switch is not enough: pools created before the switch was flipped
13+
/// keep their original implementation, and pools created inside the scope would otherwise outlive
14+
/// it and leak the chosen implementation into unrelated tests. This scope therefore clears all
15+
/// pools both on entry and on exit.
16+
///
17+
/// This follows the RAII pattern; construct it at the start of a test and dispose it at the end.
18+
/// Like <see cref="LocalAppContextSwitchesHelper"/>, it manipulates global state and enforces a
19+
/// single-instance policy, so it must not be held for longer than necessary.
20+
/// </summary>
21+
public sealed class ConnectionPoolVersionScope : IDisposable
22+
{
23+
private readonly LocalAppContextSwitchesHelper _switches;
24+
25+
/// <summary>
26+
/// Clears all existing pools and selects the requested pool implementation.
27+
/// </summary>
28+
/// <param name="usePoolV2">
29+
/// True to use <c>ChannelDbConnectionPool</c>; false to use <c>WaitHandleDbConnectionPool</c>.
30+
/// </param>
31+
public ConnectionPoolVersionScope(bool usePoolV2)
32+
{
33+
_switches = new LocalAppContextSwitchesHelper();
34+
35+
try
36+
{
37+
SqlConnection.ClearAllPools();
38+
_switches.UseConnectionPoolV2 = usePoolV2;
39+
}
40+
catch
41+
{
42+
_switches.Dispose();
43+
throw;
44+
}
45+
}
46+
47+
/// <summary>
48+
/// Clears all pools created under the selected implementation and restores the original
49+
/// switch values.
50+
/// </summary>
51+
public void Dispose()
52+
{
53+
try
54+
{
55+
SqlConnection.ClearAllPools();
56+
}
57+
finally
58+
{
59+
_switches.Dispose();
60+
}
61+
}
62+
}

0 commit comments

Comments
 (0)