Skip to content

Commit 513c4d3

Browse files
mdaigleCopilot
andcommitted
Count reclaimed connections and align reclaim traces
Rebasing onto the pool instrumentation work makes two things available that the reclaim path was missing. The metrics seam now exists, so the sweep emits ReclaimedConnectionRequest the way WaitHandleDbConnectionPool does. Previously the number-of-reclaimed-connections counter always read zero under pool V2, which made a leaking application look healthy on exactly the counter that would have identified it. The trace call sites also predate the new pool trace message format, so they still used the legacy prov-prefixed form. Converted the sweep, reclaimer and shutdown messages to match their neighbours. Adds a parity test asserting both pool implementations report identical counters when a leaked connection is reclaimed. It drives the sweep the same way for both, by capping the pool at one connection, leaking it and requesting another, so it asserts observable behaviour rather than an internal entry point. The counters agree exactly, including a shared quirk now pinned by the test: reclamation emits no soft disconnect, so activeSoftConnections drifts up by one for every leaked connection. Also makes the parked-caller test deterministic. It relied on the leaked owner surviving until the second caller parked, but any test running in parallel could collect it first, in which case the caller's inline sweep succeeded and it never parked. The owner is now rooted in a GCHandle that the test frees at the exact point it wants emancipation to become observable. A local cannot express that: in a Debug build its stack slot roots the object for the rest of the method even once it is assigned null. That also removes the thread-pool headroom workaround added earlier, which was aimed at a starvation theory the diagnostics disproved. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 7e34681 commit 513c4d3

4 files changed

Lines changed: 122 additions & 31 deletions

File tree

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -788,7 +788,7 @@ public void Shutdown()
788788
catch (Exception ex)
789789
{
790790
SqlClientEventSource.Log.TryPoolerTraceEvent(
791-
"<prov.DbConnectionPool.Shutdown|RES|CPOOL> {0}, Reclaimer.Dispose threw, continuing shutdown: {1}", Id, ex);
791+
"ChannelDbConnectionPool.Shutdown | INFO | {0}, Reclaimer.Dispose threw, continuing shutdown: {1}", Id, ex);
792792
}
793793

794794
// Dispose the error state so its exit timer is released. Otherwise a timer scheduled
@@ -1574,7 +1574,7 @@ private async Task<DbConnectionInternal> GetInternalConnection(
15741574
internal bool ReclaimEmancipatedConnections()
15751575
{
15761576
SqlClientEventSource.Log.TryPoolerTraceEvent(
1577-
"<prov.DbConnectionPool.ReclaimEmancipatedObjects|RES|CPOOL> {0}", Id);
1577+
"ChannelDbConnectionPool.ReclaimEmancipatedConnections | INFO | {0}, Sweeping for emancipated connections.", Id);
15781578

15791579
List<DbConnectionInternal>? reclaimed = null;
15801580

@@ -1615,10 +1615,14 @@ internal bool ReclaimEmancipatedConnections()
16151615
foreach (DbConnectionInternal connection in reclaimed)
16161616
{
16171617
SqlClientEventSource.Log.TryPoolerTraceEvent(
1618-
"<prov.DbConnectionPool.ReclaimEmancipatedObjects|RES|CPOOL> {0}, Connection {1}, Reclaiming.",
1618+
"ChannelDbConnectionPool.ReclaimEmancipatedConnections | INFO | {0}, Connection {1}, Reclaiming.",
16191619
Id,
16201620
connection.ObjectID);
16211621

1622+
// Matches WaitHandleDbConnectionPool so the number-of-reclaimed-connections counter
1623+
// is meaningful under pool V2 as well; it previously always read zero here.
1624+
Metrics.ReclaimedConnectionRequest();
1625+
16221626
connection.DetachCurrentTransactionIfEnded();
16231627
DeactivateAndRouteConnection(connection);
16241628
}

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ internal void EnterParkedWait()
163163
_timer.Change(SweepInterval, Timeout.InfiniteTimeSpan);
164164

165165
SqlClientEventSource.Log.TryPoolerTraceEvent(
166-
"<prov.PoolReclaimer.EnterParkedWait|RES|INFO|CPOOL> {0}, sweep timer started", _pool.Id);
166+
"PoolReclaimer.EnterParkedWait | INFO | {0}, Sweep timer started.", _pool.Id);
167167
}
168168
}
169169

@@ -189,7 +189,7 @@ internal void ExitParkedWait()
189189
_timer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
190190

191191
SqlClientEventSource.Log.TryPoolerTraceEvent(
192-
"<prov.PoolReclaimer.ExitParkedWait|RES|INFO|CPOOL> {0}, sweep timer stopped, no parked waiters", _pool.Id);
192+
"PoolReclaimer.ExitParkedWait | INFO | {0}, Sweep timer stopped, no parked waiters.", _pool.Id);
193193
}
194194
}
195195

@@ -224,7 +224,7 @@ internal void OnSweepCallback()
224224
// would tear down the process. A failed sweep is not fatal to the pool, so trace it
225225
// and let the next sweep try again.
226226
SqlClientEventSource.Log.TryPoolerTraceEvent(
227-
"<prov.PoolReclaimer.OnSweepCallback|RES|CPOOL> {0}, sweep threw, continuing: {1}", _pool.Id, ex);
227+
"PoolReclaimer.OnSweepCallback | ERR | {0}, Sweep threw, continuing: {1}.", _pool.Id, ex);
228228
}
229229

230230
lock (_lock)

src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolReclaimTimerTest.cs

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System;
66
using System.Diagnostics;
77
using System.Runtime.CompilerServices;
8+
using System.Runtime.InteropServices;
89
using System.Threading;
910
using System.Threading.Tasks;
1011
using Microsoft.Data.Common.ConnectionString;
@@ -79,6 +80,31 @@ private static DbConnectionInternal CheckOutAndAbandonOwner(ChannelDbConnectionP
7980
return connection!;
8081
}
8182

83+
/// <summary>
84+
/// Checks out a connection and roots its owner in a <see cref="GCHandle"/> rather than a
85+
/// local. The owner stays alive until the caller frees the returned handle, which gives the
86+
/// test exact control over when the connection becomes emancipated. A local cannot do this:
87+
/// in a Debug build its stack slot roots the object for the rest of the enclosing method
88+
/// even after it is assigned null, so the collection would never happen. The checkout runs
89+
/// in its own non-inlined frame so no slot in the caller's frame ever holds the owner.
90+
/// </summary>
91+
[MethodImpl(MethodImplOptions.NoInlining)]
92+
private static DbConnectionInternal CheckOutAndRootOwner(ChannelDbConnectionPool pool, out GCHandle ownerRoot)
93+
{
94+
SqlConnection owner = new();
95+
ownerRoot = GCHandle.Alloc(owner);
96+
97+
bool completed = pool.TryGetConnection(
98+
owner,
99+
taskCompletionSource: null,
100+
TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
101+
out DbConnectionInternal? connection);
102+
103+
Assert.True(completed);
104+
Assert.NotNull(connection);
105+
return connection!;
106+
}
107+
82108
/// <summary>
83109
/// Forces collection of an abandoned owner so its connection becomes emancipated.
84110
/// </summary>
@@ -104,26 +130,6 @@ private static void WaitFor(Func<bool> condition, string because)
104130
}
105131
}
106132

107-
/// <summary>
108-
/// Guarantees the thread pool can start a queued work item promptly.
109-
/// <see cref="ChannelDbConnectionPool.TryGetConnection"/> dispatches its async path through
110-
/// <see cref="Task.Run(Action)"/>, so a test that needs a caller to actually reach its parked
111-
/// wait depends on a worker being available. Other tests in this assembly block pool threads
112-
/// on sockets and sync-over-async waits, and the pool's own thread injection only adds
113-
/// threads at roughly one per second, which is slow enough to time this test out.
114-
/// The raise is deliberately monotonic and never restored: lowering it again would pull the
115-
/// floor out from under tests running in parallel with this one.
116-
/// </summary>
117-
private static void EnsureThreadPoolHeadroom()
118-
{
119-
ThreadPool.GetMinThreads(out int workerThreads, out int completionPortThreads);
120-
int desired = Environment.ProcessorCount * 4;
121-
if (workerThreads < desired)
122-
{
123-
ThreadPool.SetMinThreads(desired, completionPortThreads);
124-
}
125-
}
126-
127133
#endregion
128134

129135
/// <summary>
@@ -284,14 +290,14 @@ public void Shutdown_DisposesReclaimer()
284290
[Fact]
285291
public async Task ParkedCaller_IsWokenBySweep_WhenConnectionIsEmancipatedAfterParking()
286292
{
287-
EnsureThreadPoolHeadroom();
288-
289293
var fakeTime = new FakeTimeProvider();
290294
var pool = ConstructPool(maxPoolSize: 1, timeProvider: fakeTime);
291295

292-
// Occupy the pool's only slot and abandon the owner. The connection is not yet
293-
// collected, so it is not yet emancipated.
294-
DbConnectionInternal leaked = CheckOutAndAbandonOwner(pool);
296+
// Occupy the pool's only slot. The owner is deliberately kept rooted until after the
297+
// second caller has parked: a collection triggered by a test running in parallel would
298+
// otherwise emancipate it early, letting the second caller's inline sweep succeed so it
299+
// never parks and the case under test never arises.
300+
DbConnectionInternal leaked = CheckOutAndRootOwner(pool, out GCHandle leakedOwnerRoot);
295301
Assert.Equal(1, pool.Count);
296302

297303
// A second caller finds the pool exhausted and parks. Its inline sweep runs before the
@@ -313,6 +319,7 @@ public async Task ParkedCaller_IsWokenBySweep_WhenConnectionIsEmancipatedAfterPa
313319

314320
// Only now does the abandoned owner become collectable, which is precisely the case the
315321
// caller's own inline sweep cannot cover.
322+
leakedOwnerRoot.Free();
316323
CollectAbandonedOwners();
317324
Assert.True(leaked.IsEmancipated);
318325

src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/DbConnectionPoolInstrumentationTest.cs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Collections.Generic;
77
using System.Data.Common;
88
using System.Linq;
9+
using System.Runtime.CompilerServices;
910
using System.Threading;
1011
using System.Transactions;
1112
using Microsoft.Data.Common;
@@ -413,6 +414,85 @@ public void TransactionRollback_ReleasingParkedConnection_KeepsActiveConnectionC
413414
activeConnections: 0);
414415
}
415416

417+
/// <summary>
418+
/// Verifies that a connection whose owner was collected without being closed is reclaimed
419+
/// and counted, in both pool implementations.
420+
/// </summary>
421+
/// <remarks>
422+
/// The sweep is triggered the same way for both pools rather than by calling their internal
423+
/// reclaim methods: the pool is capped at a single connection, that connection is leaked,
424+
/// and a second caller then requests one. Each implementation reaches its own reclaim path
425+
/// from that saturated request, so the test asserts observable behavior rather than a
426+
/// specific internal entry point.
427+
/// </remarks>
428+
[Theory]
429+
[InlineData(PoolImplementation.WaitHandle)]
430+
[InlineData(PoolImplementation.Channel)]
431+
public void EmancipatedConnection_IsReclaimedAndCounted(PoolImplementation implementation)
432+
{
433+
// Arrange
434+
FakeSqlClientMetrics metrics = new();
435+
IDbConnectionPool pool = ConstructPool(
436+
implementation,
437+
metrics,
438+
new SuccessfulSqlConnectionFactory(metrics),
439+
maxPoolSize: 1);
440+
441+
DbConnectionInternal leaked = CheckOutAndAbandonOwner(pool);
442+
AssertCounters(
443+
metrics,
444+
hardConnects: 1,
445+
softConnects: 1,
446+
pooledConnections: 1,
447+
activeConnections: 1);
448+
449+
// The owner is only collectable now that the helper's frame has been popped.
450+
GC.Collect();
451+
GC.WaitForPendingFinalizers();
452+
GC.Collect();
453+
Assert.True(leaked.IsEmancipated);
454+
455+
// Act - the pool's only slot is occupied by a connection nobody can return, so this
456+
// request can only be served by reclaiming it.
457+
Assert.True(pool.TryGetConnection(
458+
new SqlConnection(),
459+
null,
460+
TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
461+
out DbConnectionInternal? reclaimed));
462+
463+
// Assert - the same physical connection was handed out again, with no second connect.
464+
// softDisconnects stays at zero: reclamation never balances the leaked checkout, so
465+
// activeSoftConnections drifts up by one per leaked connection. That is long-standing
466+
// WaitHandle behavior, asserted here to pin the two implementations to the same numbers.
467+
Assert.Same(leaked, reclaimed);
468+
AssertCounters(
469+
metrics,
470+
hardConnects: 1,
471+
softConnects: 2,
472+
softDisconnects: 0,
473+
pooledConnections: 1,
474+
reclaimedConnections: 1,
475+
activeConnections: 1);
476+
}
477+
478+
/// <summary>
479+
/// Checks out a connection and drops the only reference to its owner. Kept in its own
480+
/// non-inlined method so the owner becomes collectable as soon as this frame is popped; a
481+
/// local in the calling test would stay rooted until the end of that method in a debug build.
482+
/// </summary>
483+
[MethodImpl(MethodImplOptions.NoInlining)]
484+
private static DbConnectionInternal CheckOutAndAbandonOwner(IDbConnectionPool pool)
485+
{
486+
SqlConnection owner = new();
487+
Assert.True(pool.TryGetConnection(
488+
owner,
489+
null,
490+
TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
491+
out DbConnectionInternal? connection));
492+
Assert.NotNull(connection);
493+
return connection!;
494+
}
495+
416496
#region Test classes
417497

418498
/// <summary>

0 commit comments

Comments
 (0)