Emit pool metrics and traces, and fix Count semantics in ChannelDbConnectionPool - #4504
Emit pool metrics and traces, and fix Count semantics in ChannelDbConnectionPool#4504mdaigle wants to merge 20 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR closes parity gaps between ChannelDbConnectionPool (V2) and WaitHandleDbConnectionPool (V1) discovered via differential testing, focusing on correct pooling semantics and consistent diagnostics/metrics behavior across implementations.
Changes:
- Emit pool metrics in the channel-based pool to match the wait-handle pool (pooled/free/active connection counters and connect/disconnect-related counters).
- Fix
ChannelDbConnectionPool.Countsemantics to report tracked connections (slot occupancy) rather than in-flight reservations. - Add an async idle-connection fast path so
OpenAsynccan complete synchronously on a warm pool (excluding transactional requests), and update/parameterize tests accordingly.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs | Implements async idle fast path, fixes Count to use tracked connections, and wires metrics at key lifecycle points. |
| src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ConnectionPoolSlots.cs | Adds ConnectionCount to distinguish tracked connections from reservations. |
| src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/IdleConnectionChannel.cs | Emits free-connection metrics on idle enqueue/dequeue to centralize counter correctness. |
| src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs | Updates stress test to avoid hanging when async acquisition can complete synchronously. |
| src/Microsoft.Data.SqlClient/tests/ManualTests/TracingTests/MetricsTest.cs | Parameterizes pooled connection metrics test across pool versions via ConnectionPoolVersionScope. |
| src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/ConnectivityTests/ConnectivityTest.cs | Parameterizes resiliency SPID test across pool versions via ConnectionPoolVersionScope. |
| src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/ParameterTest/TvpTest.cs | Fixes Task.Factory.StartNew(async …) by unwrapping the nested task so failures/timeouts are observed correctly. |
ebe1433 to
0b0054f
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:963
- The new async idle fast-path can return a pooled connection without ever emitting the standard "Getting connection." pooler trace (it bypasses GetInternalConnection, where that trace is currently emitted). This makes trace captures inconsistent: a successful async open from the idle channel will only show "Popped from general pool" without the usual request-start marker, reducing diagnosability and trace parity with WaitHandleDbConnectionPool.
Transaction? ambientTransaction = taskCompletionSource.Task.AsyncState as Transaction;
// Try to satisfy the request synchronously from the idle channel before paying for a
d31d827 to
a28260e
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:1869
- Pruning is documented as respecting the MinPoolSize floor, but the loop condition uses ReservationCount. Since ReservationCount includes in-flight opens, pruning can run even when the actual tracked-connection count (Count/ConnectionCount) is already at MinPoolSize, potentially dropping the pool below its minimum. WaitHandleDbConnectionPool’s cleanup loop uses Count > MinPoolSize for this floor check.
while (count > 0
&& IsRunning
&& _connectionSlots.ReservationCount > MinPoolSize
&& _idleChannel.TryRead(out var connection))
src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolInstrumentationTest.cs:33
- This test class mutates process-wide AppContext switches (e.g., IdleTimeoutEviction_EmitsReasonTrace sets UseLegacyIdleTimeoutBehavior). Without serializing via AppContextSwitchTestCollection, this can race with other tests and cause cross-test contamination/flakiness.
namespace Microsoft.Data.SqlClient.UnitTests.ConnectionPool
{
/// <summary>
/// Verifies the diagnostic instrumentation of <see cref="ChannelDbConnectionPool"/>: the pooler
/// trace events emitted across the connection lifecycle.
/// </summary>
public class ChannelDbConnectionPoolInstrumentationTest
{
/// <summary>
a28260e to
8edcaeb
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:1845
- PruneConnections uses _connectionSlots.ReservationCount to enforce the MinPoolSize floor. ReservationCount includes in-flight connection creations, so pruning can drop the pool below MinPoolSize (in terms of actual tracked connections) while a create is pending; if that create later fails, the pool can end up permanently under the configured minimum. Since Count now reflects tracked connections (WaitHandle pool semantics), use Count/ConnectionCount here for parity and to keep the floor based on actual connections.
while (count > 0
&& IsRunning
&& _connectionSlots.ReservationCount > MinPoolSize
&& _idleChannel.TryRead(out var connection))
src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolInstrumentationTest.cs:33
- This test class mutates process-wide cached AppContext switch values (UseLegacyIdleTimeoutBehavior) and also asserts against process-wide metric gauges. To avoid cross-test interference/flakiness, it should be placed in the existing non-parallel AppContextSwitchTests collection (same pattern used by ChannelDbConnectionPoolTest and *IdleTimeoutTest).
public class ChannelDbConnectionPoolInstrumentationTest
{
ChannelDbConnectionPool did not emit any of the connection pool counters that WaitHandleDbConnectionPool emits, and reported Count as the reservation count, which includes connections that are still being opened. - Wire the pooled/free connection and soft/hard connect/disconnect counters through ChannelDbConnectionPool and IdleConnectionChannel, at the call sites matching WaitHandleDbConnectionPool. - Add ConnectionPoolSlots.ConnectionCount and point Count at it, so Count reflects connections that actually belong to the pool. This matches WaitHandleDbConnectionPool and fixes the SQL Express user instance path, which branches on pool.Count <= 0. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
8edcaeb to
28c7df8
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:1855
- Pruning currently uses
_connectionSlots.ReservationCount > MinPoolSizeas its floor check. After this PR changesCountto reflect actual tracked connections (excluding in-flight creates), usingReservationCounthere can cause the pruner to remove idle connections based on reservations held for connections still opening. If those in-flight opens fail/cancel, the pool can end up belowMinPoolSizeeven though the loop condition was intended to enforce the floor. This should useCount(or_connectionSlots.ConnectionCount) to match the new semantics and the WaitHandle pool’s pruning logic.
while (count > 0
&& IsRunning
&& _connectionSlots.ReservationCount > MinPoolSize
&& _idleChannel.TryRead(out var connection))
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.
Suppressed comments (4)
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs:777
- The created pooled connection also receives the factory sink, so its active-connection counters do not honor
IDbConnectionPool.Metricswhen the injected sinks differ. Selectpool.Metricsfor pooled creation and retain the factory sink only whenpoolis null.
metrics: Metrics);
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs:228
- Pooled hard-connects are still emitted through the factory's sink rather than the owning pool's
Metrics. Because the pool and factory accept independent injected sinks, a pool-scoped sink can miss every hard connect (the tests currently hide this by passing the same fake to both). Route pooled connects throughpool.Metrics; keep the factory sink for non-pooled connections.
This issue also appears on line 777 of the same file.
Metrics.HardConnectRequest();
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:210
- The tracing-parity behavior is untested: the new instrumentation suite asserts counters only, and the connection-pool tests contain no listener/assertions for
TryPoolerTraceEvent. Add trace capture tests for lifecycle events and each liveness-rejection/transaction reason so missing or malformed events do not silently regress.
SqlClientEventSource.Log.TryPoolerTraceEvent(
"ChannelDbConnectionPool.ChannelDbConnectionPool | INFO | {0}, Constructed. MinPoolSize={1}, MaxPoolSize={2}",
Id,
MinPoolSize,
MaxPoolSize);
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ConnectionPoolSlots.cs:101
- The new in-flight reservation distinction is not covered by a regression test. Existing
ConnectionPoolSlotsTestassertions only inspectReservationCountafter creation callbacks finish, so they cannot catchConnectionCountaccidentally tracking reservations again. Add a gated creation test that observesReservationCount == 1andConnectionCount == 0while creation is blocked, then verifies both are 1 after insertion (and that the channel pool'sCountfollowsConnectionCount).
/// <summary>
/// Gets the number of connections currently tracked by this collection. Unlike
/// <see cref="ReservationCount"/>, this excludes reservations held for connections that are
/// still being opened, so it reports connections that actually belong to the pool.
/// </summary>
internal int ConnectionCount => _connectionCount;
cheenamalhotra
left a comment
There was a problem hiding this comment.
Closing the metrics gap before v2 becomes the default is the right call, and parameterizing the tests over both pools is the correct way to lock parity in. Ran the new tests locally, 13/13 green on net9.0.
Most of my comments are about counters that don't balance, and about Count now meaning two different things inside the same class. Two more asks that don't belong on a line:
- Can we get the full v1 vs v2 counter delta written down in the description and a release note?
ReclaimedConnectionRequestis never emitted by the channel pool (deferred to #4490), so that counter reads a flat zero the moment someone flipsUseConnectionPoolV2. Anyone comparing dashboards across the switch will open a support case, and I'd rather we documented it than debugged it. - The new per-connection traces add roughly four events per checkout on the hottest path we have.
TryPoolerTraceEventisIsEnabled-gated, but theId/ObjectIDarguments still box into theparams object[]at every call site whether tracing is on or not. Can we confirm with a benchmark that tracing-off throughput is unchanged?
| } | ||
|
|
||
| SqlClientDiagnostics.Metrics.SoftConnectRequest(); | ||
| Metrics.SoftConnectRequest(); |
There was a problem hiding this comment.
We emit a soft connect for the replacement but never a soft disconnect for the connection being retired, so one logical checkout produces two soft connects. activeSoftConnections climbs by one per replacement and never comes back down.
Same in WaitHandle, so not a regression, but ReplaceConnection is the connection resiliency path. A customer on a flaky network watches "connections currently in use" grow without bound while the pool is actually idle, and that counter is one of the first things we ask them for.
ReplaceConnection_CountsHardDisconnectForDiscardedConnection now asserts softConnects: 2, softDisconnects: 0 with a single connection in hand, so the test reads as if this were intended. Can we either emit the missing SoftDisconnectRequest() for oldConnection in both pools, or say plainly in the test that the imbalance is known and file it?
There was a problem hiding this comment.
Fixed in both pools. ReplaceConnection now records a soft disconnect for the connection it displaces, plus a hard disconnect, since that connection is destroyed rather than returned.
Chasing this turned up a second one in v1: CreateObject swaps the replacement into the old connection's place in _objectList but only counted the addition, so number-of-pooled-connections drifted up by one per replacement. Fixed alongside.
| } | ||
|
|
||
| PrepareConnection(owningConnection, connection, transaction); | ||
| Metrics.SoftConnectRequest(); |
There was a problem hiding this comment.
The soft connect lands after PrepareConnection, but ReturnInternalConnection emits its soft disconnect unconditionally on the first line. So if activation throws, the async path's catch returns the connection and we decrement a counter we never incremented, and activeSoftConnections goes negative.
The sync path is worse. There's no catch around this at all, so a PrepareConnection failure leaves the connection neither returned nor disposed. It keeps its slot and its pooledConnections and activeHardConnections contributions until GC gets to it, and this pool has no emancipated reclamation to notice.
Can we move the soft connect to the handoff point the way WaitHandle does, and wrap PrepareConnection so a failure goes through RemoveConnection?
There was a problem hiding this comment.
Ordering fixed in both pools. The imbalance was real and a bit worse than described: number-of-active-connections also went negative, because ActivateConnection counted after calling Activate while DeactivateConnection counts before calling Deactivate. Both now count before the virtual call. Added FailedActivation_LeavesCountersBalanced over both pools, which is what caught it.
Two corrections though. PrepareConnection has its own try/catch that calls ReturnInternalConnection and rethrows, so the sync path doesn't leak the connection. And the wait handle pool had the identical ordering, so this wasn't a v2 regression.
| /// the first caller down the cached branch instead, where it reads an instance name that | ||
| /// nothing has set yet. | ||
| /// </remarks> | ||
| public int Count => _connectionSlots.ConnectionCount; |
There was a problem hiding this comment.
Agreed on the fix for the user instance path, but this leaves the class with two definitions of "how big is the pool" that now differ by exactly the in-flight opens:
PruneConnectionsgates onReservationCount > MinPoolSizeRequestWarmup/RunWarmupLoopAsyncgate onCount < MinPoolSize
So warmup can't see opens that are already in flight and will over-create above MinPoolSize, while the pruner stays conservative. Can we pick a definition per decision and say at each site why that one is right?
Separately, SqlConnectionFactory.PruneConnectionPoolGroups reads pool.Count == 0 to decide a released pool has drained, and emits ExitInactiveConnectionPool() off the back of it. With in-flight opens no longer counted, a pool can be declared empty while an open that started before shutdown is still landing, and we decrement the gauge for a pool that still owns a connection. WaitHandle's _totalObjects behaves the same way so this isn't new, but this PR is what moves the value. Worth a line in the remarks confirming it's deliberate.
There was a problem hiding this comment.
Warmup now gates on ReservationCount, matching the max-pool-size gate and the pruner.
Documented the convention on Count itself, including why the SQL Express user instance path in SqlConnectionFactory specifically needs Count rather than ReservationCount.
| { | ||
| if (Interlocked.CompareExchange(ref _connections[i], connection, null) == null) | ||
| { | ||
| Interlocked.Increment(ref _connectionCount); |
There was a problem hiding this comment.
The catch below hands a fully opened connection to cleanupCallback, which disposes it. That connection was already counted by HardConnectRequest in the factory, and nothing emits the matching HardDisconnectRequest, so activeHardConnections leaks.
It's the same failure shape you deliberately balanced in ReplaceConnection's catch, which is what makes it stand out. Only reachable through the "reserved but no empty slot" bug path today, but that callback is the general cleanup hook and the next caller to use it inherits the hole.
Minor while you're here: _connectionCount is declared volatile but only ever mutated through Interlocked. Pick one. (_reservations does the same, so low priority.)
There was a problem hiding this comment.
Both addressed. The cleanup path now records the hard disconnect for a connection that opened but never reached the pool.
On volatile: dropped it from the fields, but the reads go through Volatile.Read now rather than becoming plain reads. TryReserve's spin loop reads the field outside the Interlocked call, so an unordered read there could be hoisted.
| // Without this initialization, ReturnedTime would default to DateTime.MinValue, which would cause | ||
| // IsLiveConnection to immediately evict every new connection whenever IdleTimeout is configured. | ||
| ReturnedTime = CreateTime; | ||
| Metrics = metrics ?? SqlClientDiagnostics.Metrics; |
There was a problem hiding this comment.
Only ActivateConnection and DeactivateConnection were moved onto this. SetInStasis, TerminateStasis, the non-pooled HardDisconnectRequest and both Enter/ExitNonPooledConnection calls still go to SqlClientDiagnostics.Metrics, so one object reports to two sinks depending on which counter fires.
The test consequence is the part I care about: AssertCounters claims any counter it doesn't name must be zero, but stasisConnections and nonPooledConnections can never be non-zero on the fake. Those rows pass vacuously and give us false confidence, and stasis is the counter sitting right next to the #3640 double-deactivation we're regression testing here.
Can we route the rest through the instance too? If there's a reason not to, the vacuous rows should come out of AssertCounters with a note saying why.
There was a problem hiding this comment.
Routed the remaining sites through the instance sink.
Worth noting this is a no-op in production. The factory hands every connection the process-wide instance, so Metrics already is SqlClientDiagnostics.Metrics there. The value is that a test can now observe those counters.
| /// instance. Making it a pool property lets a test give a pool its own counters so | ||
| /// assertions are not perturbed by unrelated connection activity elsewhere in the process. | ||
| /// </remarks> | ||
| ISqlClientMetrics Metrics { get; } |
There was a problem hiding this comment.
The pool and the factory now each take their own optional ISqlClientMetrics, with no relationship between them. Wire them to different instances and the pooled/free counters land in one sink while the hard connect and active connection counters land in another, silently and with nothing asserting otherwise.
Production is safe because both default to the global, but this is a seam we're adding for tests and it has a wrong way to hold it. Can the pool just take its sink from ConnectionFactory.Metrics so there's one owner, or at minimum Debug.Assert they're the same instance?
Also, is this interface intended to be the seam for a future public metrics / OpenTelemetry story? If so let's say so here, so we don't relitigate a 22-member internal interface next time someone reads it as test-only scaffolding.
There was a problem hiding this comment.
This one is deliberate, and I've documented it on the property. Pools take their sink independently of the factory's so a pool can be constructed and asserted on without standing up a factory. Both default to the same process-wide instance, so they agree in production, and the tests pass a single instance to both.
| _metrics = metrics; | ||
| TransactedConnections = new Dictionary<Transaction, TransactedConnectionList>(); | ||
| SqlClientEventSource.Log.TryPoolerTraceEvent("<prov.DbConnectionPool.TransactedConnectionPool.TransactedConnectionPool|RES|CPOOL> {0}, Constructed for connection pool {1}", Id, Pool.Id); | ||
| SqlClientEventSource.Log.TryPoolerTraceEvent("TransactedConnectionPool.TransactedConnectionPool | INFO | {0}, Constructed for connection pool {1}", Id, Pool.Id); |
There was a problem hiding this comment.
TransactedConnectionPool and PoolPruner are shared with WaitHandleDbConnectionPool, so reformatting the traces here changes what v1 users see too, and a v1 trace stream now carries both formats at once.
The new format also drops the RES|CPOOL tokens. Those are what support tooling and customer greps filter on, so this is consumer visible whichever pool you're on.
I'm fine with the new format, but can we either convert everything in one pass in its own PR, or leave the shared components alone in this one? Either way it needs a release note.
There was a problem hiding this comment.
Agreed, reverted. PoolPruner is now unchanged from main, and TransactedConnectionPool keeps only the metrics injection.
Converting the repo to one trace format seems worth doing, just in its own PR.
There was a problem hiding this comment.
Correction on this: PoolPruner keeps the new format after all. It is constructed only by ChannelDbConnectionPool and its constructor is typed to it, so it is not shared with the wait handle pool.
TransactedConnectionPool is genuinely shared and stays on the existing format.
| ("reclaimedConnections", reclaimedConnections, metrics.ReclaimedConnections), | ||
| ("activeConnections", activeConnections, metrics.ActiveConnections), | ||
|
|
||
| // Not emitted through a pool's metrics instance, so any non-zero value here means a |
There was a problem hiding this comment.
This block can't fail as written. stasisConnections and nonPooledConnections are only ever emitted through SqlClientDiagnostics.Metrics (see my comment on DbConnectionInternal), so the fake never sees them and these rows are decorative.
I like the "everything unnamed is zero" design, it's the right shape. It just needs the emitters actually pointed at the injected sink, otherwise it advertises coverage we don't have.
There was a problem hiding this comment.
The stasis row is meaningful now that the routing is fixed.
Keeping the nonPooledConnections row at zero on purpose. AssertCounters treats any counter a test doesn't name as zero, so these rows exist to catch unexpected emissions rather than to assert a behavior.
| /// <summary> | ||
| /// Connection factory that always fails with <see cref="TestConnectionCreateException"/>. | ||
| /// </summary> | ||
| internal sealed class FailingSqlConnectionFactory : SqlConnectionFactory |
There was a problem hiding this comment.
FailingSqlConnectionFactory and TestConnectionCreateException aren't referenced by any test in this file. Dead code, or is a test missing?
If it's the latter, the ones I'd most like to see land with it:
- Failed activation, asserting the counters stay balanced (the path I flagged in
GetInternalConnection). - Pruning and idle-timeout eviction, which are the two channel-pool-only paths that touch
pooledConnectionsandfreeConnections. - A concurrent checkout/return loop asserting every gauge returns to zero at the end.
That last one matters most. Every test here is single threaded, and gauge drift under concurrency is the failure mode that actually reaches customers. Good coverage otherwise, and nice that the commit/rollback pair genuinely drives the transaction completed path via the base EnlistedTransaction setter rather than faking it.
There was a problem hiding this comment.
FailingSqlConnectionFactory is gone. Repurposed TestConnectionCreateException for a fake that opens successfully and then fails to activate, which drives the new failed-activation test.
|
|
||
| /// <inheritdoc /> | ||
| public int Count => _connectionSlots.ReservationCount; | ||
| public ISqlClientMetrics Metrics { get; } |
There was a problem hiding this comment.
Following on @cheenamalhotra's benchmark ask — it should cover the metrics calls too, not just tracing. SoftConnectRequest/SoftDisconnectRequest fire on every pooled open/close and moved from a direct call on a sealed SqlClientMetrics via a static field (JIT-inlinable, no-op when disabled) to a virtual ISqlClientMetrics call through an instance field. Same hot path, different mechanism — worth measuring in the same run.
There was a problem hiding this comment.
This run will show v1 pool compared to v1 on main: https://dev.azure.com/SqlClientDrivers/ADO.Net/_build/results?buildId=167201
This will show v2 compared to v2 on main:
https://dev.azure.com/SqlClientDrivers/ADO.Net/_build/results?buildId=167202
ReplaceConnection vends a new connection to the caller and destroys the one it displaced. Both pools counted the soft connect for the replacement but nothing for the retirement, so active-soft-connects drifted up by one per replacement and never came back down. The wait handle pool also disposed the old connection directly rather than through its destroy path, leaking active-hard-connections the same way. Emit a soft disconnect and a hard disconnect for the retired connection in both pools, and parameterize the instrumentation test over both implementations. The pooled-connection gauge still differs: the channel pool reuses the old slot, while the wait handle pool never emits the matching decrement. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CreateObject swaps the replacement into the old connection's place in the pool's object list, but only incremented the pooled-connection gauge. The removal had no matching decrement, so number-of-pooled-connections drifted up by one per replacement. ReplaceConnection is the only caller that passes an old connection, so this is scoped to replacement. Both pools now report the same counters for a replacement, so the instrumentation test no longer needs a per-implementation expectation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.
Suppressed comments (7)
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs:777
- A pooled internal connection must use its owning pool's sink, but this passes the factory sink. If the new pool-level injection is used without separately configuring the factory, activation/deactivation counters go to a different instance than the pool's free/pooled/disconnect counters, defeating exact pool-scoped assertions. Select
pool.Metricswhen a pool is present and retain the factory sink for non-pooled connections.
metrics: Metrics);
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:1506
PrepareConnectionreturns an activation failure throughReturnInternalConnection, which now emitsSoftDisconnectRequest. Because the matching soft connect is recorded only afterPrepareConnectionsucceeds, every failed enlistment/activation decrements the active-soft gauge even though no checkout was counted. EmitSoftConnectRequestbefore preparation so the return path balances it, matching the failure-safe accounting used by replacement.
PrepareConnection(owningConnection, connection, transaction);
Metrics.SoftConnectRequest();
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs:228
- This pooled-connect counter bypasses the pool-scoped sink and reports to the factory sink instead. A pool constructed with its own
Metrics(the purpose of the new injection point) loses hard-connect events unless its caller also knows to inject the identical object into the factory. Route pooled events throughpool.Metrics; the factory sink remains appropriate for non-pooled creation.
This issue also appears on line 777 of the same file.
Metrics.HardConnectRequest();
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:207
- The new and rewritten messages use a different payload grammar from the established pooler traces. Existing sites consistently emit the
<prov.DbConnectionPool.Method|RES|...|CPOOL>markers (for example,WaitHandleDbConnectionPool.cs:1187andDbConnectionInternal.cs:547), while this change usesClass.Method | INFO |. Besides preventing the stated tracing parity, rewriting existing payloads can break consumers that classify these string events by their markers. Keep the established pooler trace format across the new/updated messages.
SqlClientEventSource.Log.TryPoolerTraceEvent(
"ChannelDbConnectionPool.ChannelDbConnectionPool | INFO | {0}, Constructed. MinPoolSize={1}, MaxPoolSize={2}",
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ConnectionPoolSlots.cs:101
- The core Count fix has no regression test for the behavior that differs from
ReservationCount: while anAddcallback holds a reservation and has not yet returned a connection,ConnectionCountmust remain zero, then become one only after insertion and return to zero on removal. Existing pool-count tests observe only completed opens, so they would also pass with the old reservation-based implementation. Add a deterministic blocked-create test (and replacement/removal assertions) for this new counter.
internal int ConnectionCount => _connectionCount;
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:1248
- No automated test subscribes to the pooler EventSource or asserts any of the newly added trace payloads, including these four distinct liveness-rejection reasons. As a result, missing events and incompatible message formats can regress while all new instrumentation tests still pass because they assert counters only. Add trace-listener tests covering construction/acquisition, each rejection reason, transaction holding, removal, and pruning.
SqlClientEventSource.Log.TryPoolerTraceEvent(
"ChannelDbConnectionPool.IsLiveConnection | INFO | {0}, Connection {1}, exceeded the connection idle timeout and removed.",
Id,
connection.ObjectID);
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:404
- The idle-reuse branch leaves soft checkout metrics incorrect in both outcomes. On success, the final
SoftConnectRequestrecords checkout of the idle replacement but retiringoldConnectionnever records a soft disconnect, so the active-soft gauge grows by one. On activation failure,PrepareConnectionreturns the candidate throughReturnInternalConnection, which records a soft disconnect before this method has recorded its soft connect, so the gauge drops while the old connection remains checked out. Record the candidate soft connect beforePrepareConnectionin the idle branch, balance the old checkout when it is retired, and keep the create-new branch's metrics conditional on successful activation.
This issue also appears on line 1505 of the same file.
SqlClientEventSource.Log.TryPoolerTraceEvent(
"ChannelDbConnectionPool.ReplaceConnection | INFO | {0}, replacing connection.", Id);
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.
Suppressed comments (6)
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:530
- When replacement reuses an idle connection, the old checked-out connection is retired via
RemoveConnection, but that branch never emitsSoftDisconnectRequest. The unconditional soft connect here therefore leaves the active-soft gauge one too high after every idle-backed replacement. Balance the old checkout in both replacement branches.
Metrics.SoftConnectRequest();
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:541
PrepareConnectioncallsReturnInternalConnectionwhen activation throws, before the caller reaches its matchingSoftConnectRequest. This unconditional decrement therefore makes the active-soft counter negative on activation failures (including the existing idle-reuse activation-failure path). Ensure rollback either records the attempted soft connect first or returns without emitting a soft disconnect.
Metrics.SoftDisconnectRequest();
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs:228
- This is a pooled operation, but it records the hard connect on the factory's independently injected sink rather than the owning pool's sink. Constructing a pool with a test sink and a default factory then produces zero hard connects and one hard disconnect on that pool sink after disposal. Route this through
pool.Metricsso the new pool-scoped injection is self-consistent.
Metrics.HardConnectRequest();
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ConnectionPoolSlots.cs:101
- The key new semantic—excluding a reservation while its create callback is still in flight—is not covered by the existing slot or pool tests; they only inspect counts after creation completes. Add a blocked-create test that observes
ReservationCount == 1andConnectionCount == 0, then verifies add/remove transitions, to protect the SQL Express user-instance fix.
/// <summary>
/// Gets the number of connections currently tracked by this collection. Unlike
/// <see cref="ReservationCount"/>, this excludes reservations held for connections that are
/// still being opened, so it reports connections that actually belong to the pool.
/// </summary>
internal int ConnectionCount => _connectionCount;
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:210
- Tracing parity is a primary behavior of this change, but no unit test subscribes to
PoolerTraceor asserts any of the newly added lifecycle and liveness-rejection messages. Add listener-based coverage for representative lifecycle events and each rejection reason so trace regressions are detected.
SqlClientEventSource.Log.TryPoolerTraceEvent(
"ChannelDbConnectionPool.ChannelDbConnectionPool | INFO | {0}, Constructed. MinPoolSize={1}, MaxPoolSize={2}",
Id,
MinPoolSize,
MaxPoolSize);
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs:195
- Non-pooled teardown still records
HardDisconnectRequestonSqlClientDiagnostics.Metrics(DbConnectionInternal.cs:508), while this change records the matching connect on the factory's injected sink. A custom sink therefore permanently reports an extra active hard connection. Keep non-pooled connect/disconnect on the same sink (or leave both global); only pooled accounting can safely use the pool-scoped sink as currently structured.
Metrics.HardConnectRequest();
- Route the remaining DbConnectionInternal counters (hard disconnect, non-pooled exit, stasis enter/exit) through the instance metrics sink instead of the global one. This is a no-op in production, where the factory hands every connection the process-wide instance, but it lets a test observe them. - Emit a hard disconnect when ConnectionPoolSlots.Add fails after opening a connection. The connection was counted as a hard connect and then disposed without a matching decrement. - Stop marking the slot counters volatile and read them through Volatile.Read instead. They are only mutated through Interlocked, but they are also read outside it, including in the TryReserve spin loop. - Gate warmup on ReservationCount rather than Count, matching the max-pool-size gate and the pruner. Count excludes in-flight creations, so warmup created duplicates for connections other threads were already opening. - Restore the original trace format in TransactedConnectionPool and PoolPruner. Both are shared with the wait handle pool, so reformatting them would change v1's trace stream. PoolPruner is now unchanged from main. - Document why pools take a metrics sink independently of the connection factory, and note the Count vs ReservationCount convention. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Both pools activated a connection before counting the checkout, and DbConnectionInternal counted the active connection after calling Activate while DeactivateConnection counts before calling Deactivate. When activation failed, the pool returned the connection and deactivated it, emitting a soft disconnect and an active-connection exit with nothing to pair against. Both gauges went negative. Count the checkout before activating in both pools, and make ActivateConnection symmetric with DeactivateConnection. The wait handle pool also counted a soft connect when it vended no connection at all; that is now scoped to the branch that actually hands one out. Adds two tests, both parameterized over the two pools: - a failed activation leaves every counter balanced - concurrent checkout and return settles all gauges to a consistent resting state, with nothing outstanding and no connection leaked or double counted The failed-activation test is what surfaced the negative gauges. Replaces the dead FailingSqlConnectionFactory with a fake that opens successfully and fails to activate. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Pruning destroys a connection that no caller holds, so it must record a hard disconnect and decrement the pooled and free gauges while leaving the soft counters alone. Channel pool only: the wait handle pool reclaims idle connections through a different mechanism. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
Thanks for the thorough pass. Everything is addressed; a few notes on the two PR-level points. Reclaimed connections. Correct, Boxing in the trace calls. I don't think this one applies. On the counter fixes. Pushing on the The counter semantics I fixed against: a soft connect is the pool vending a connection, a soft disconnect is a connection returning to the pool, a hard connect is a new physical connection, a hard disconnect is a connection being destroyed. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated no new comments.
Suppressed comments (6)
src/Microsoft.Data.SqlClient/src/Microsoft/Data/ProviderBase/DbConnectionInternal.cs:613
- The delegated-transaction cleanup path has the same sink mismatch: this exit uses the injected connection sink, while the asynchronous non-pooled completion path enters the process-wide sink at
SqlConnectionFactory.cs:1106. Once the transaction ends, the two gauges become permanently unbalanced. Route the entry and exit through the same metrics instance.
Metrics.ExitNonPooledConnection();
src/Microsoft.Data.SqlClient/src/Microsoft/Data/ProviderBase/DbConnectionInternal.cs:527
- This exit now uses the injected connection sink, but the matching synchronous non-pooled entry still calls
SqlClientDiagnostics.Metrics.EnterNonPooledConnection()inSqlConnectionFactory.cs:487. With an injected factory sink, closing a non-pooled connection therefore decrements the test sink below zero and leaves the process-wide gauge incremented. Route both sides through the same sink.
This issue also appears on line 613 of the same file.
Metrics.ExitNonPooledConnection();
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ConnectionPoolSlots.cs:103
- Add a concurrency-focused test that holds
ConnectionPoolSlots.Addinside its creation callback and verifiesReservationCount == 1whileConnectionCount(andChannelDbConnectionPool.Count) remains zero, then verifies add/remove transitions. Existing slot tests assert onlyReservationCount, so they cannot detect this semantic regression or an unbalanced_connectionCount.
internal int ConnectionCount => Volatile.Read(ref _connectionCount);
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:210
- These new and rewritten channel-pool traces use a bespoke
ChannelDbConnectionPool.Method | INFO |prefix instead of the established pooler format used throughout the legacy implementation, for exampleWaitHandleDbConnectionPool.cs:489and:527(<prov.DbConnectionPool.Method|RES|CPOOL>). That makes the two implementations non-parallel for consumers that group/filter messages by the existing method and category tags. Preserve the established prefix structure while adding the new events.
SqlClientEventSource.Log.TryPoolerTraceEvent(
"ChannelDbConnectionPool.ChannelDbConnectionPool | INFO | {0}, Constructed. MinPoolSize={1}, MaxPoolSize={2}",
Id,
MinPoolSize,
MaxPoolSize);
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:1050
- Add trace-listener tests for the newly introduced lifecycle events (creation, waits, liveness rejection reasons, transaction parking, removal, and pruning). No test under the connection-pool suites subscribes to pooler traces or asserts any of these messages, so the stated tracing parity—and even whether these calls are observable—can regress unnoticed.
SqlClientEventSource.Log.TryPoolerTraceEvent(
"ChannelDbConnectionPool.OpenNewInternalConnection | INFO | {0}, Creating new connection.", Id);
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:534
- The soft-connect is recorded too late and the idle-reuse branch never records the replaced connection's soft disconnect. On successful idle reuse, the active-soft gauge increases from one checked-out connection to two; if activation of the idle connection fails,
PrepareConnectionreturns it and emits a soft disconnect before this line is reached, driving the gauge negative. Record the replacement checkout before activation and balance the old checkout in both successful branches.
Metrics.SoftConnectRequest();
PoolPruner is constructed only by ChannelDbConnectionPool and its constructor is typed to it, so it is not shared with the wait handle pool and reformatting its trace does not change v1's trace stream. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.
Suppressed comments (4)
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:1695
- Add a regression test that holds a user-requested creation in flight, invokes warmup with
MinPoolSizesatisfied by that reservation, and verifies no duplicate creation is scheduled. The current warmup tests cover a user request arriving during warmup, but not this opposite ordering that specifically distinguishesReservationCountfromCount.
if (_connectionSlots.ReservationCount >= MinPoolSize)
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:534
- The soft counters are still unbalanced when replacement reuses an idle connection. This common
SoftConnectRequestruns only afterPrepareConnection: if activation fails,PrepareConnectionreturns the idle connection and emits a soft disconnect with no matching connect; if activation succeeds,RemoveConnection(oldConnection)does not emit the soft disconnect for the displaced checkout. Count the idle checkout before activation and balance the old checkout after successful replacement, as the wait-handle path does.
Metrics.SoftConnectRequest();
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs:124
- The injected sink is not used consistently for non-pooled connections. Non-pooled opens still call the global sink at
SqlConnectionFactory.cs:487and:1106, while closing now calls the connection's injected sink atDbConnectionInternal.cs:527. With a custom factory sink, each lifecycle leaves the global gauge incremented and the injected gauge decremented. Route bothEnterNonPooledConnectioncalls through thisMetricsinstance.
protected ISqlClientMetrics Metrics { get; }
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:240
- Add a deterministic regression test that blocks creation after a slot is reserved and verifies
Countremains zero until the connection is actually installed. Existing count assertions only run after creation completes, so they would not catchCountreverting to reservation semantics or the SQL Express first-connection failure this change targets.
This issue also appears on line 1695 of the same file.
public int Count => _connectionSlots.ConnectionCount;
The stasis gauge had no unit coverage. FakeSqlClientMetrics exposed it, but AssertCounters only ever asserted zero, and the existing stasis tests assert pool state rather than metrics. The only test asserting a non-zero value was MetricsTest.StasisCounters_Functional, which needs a live server and covers just the non-pooled CloseConnection path. Promotes stasisConnections to an AssertCounters parameter and covers all three ways a pool puts a connection in stasis, plus both ways it leaves: - a non-poolable transaction root on return, both pools - a transaction root returned to a shut down pool, both pools - the wait handle idle sweep aging out a free transaction root, which is the only case that exits stasis back into general circulation rather than being destroyed Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.
Suppressed comments (4)
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:534
- The soft-connect is recorded only after both replacement branches complete. In the idle-reuse branch,
PrepareConnectionreturns a failed activation throughReturnInternalConnection, emitting a soft disconnect before this line is reached; on success,RemoveConnection(oldConnection)never emits the displaced connection's soft disconnect. Thus failed idle activation lowersactive-soft-connects, while successful idle replacement raises it permanently. Record the checkout before preparing an idle connection and balance the displaced checkout on successful idle replacement; add both cases to the metric test.
Metrics.SoftConnectRequest();
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs:92
- The injected factory sink is not used by all factory-owned counters. In particular, both
EnterNonPooledConnectioncall sites still targetSqlClientDiagnostics.Metrics, while the created connection now targets this injected sink forExitNonPooledConnection. A factory constructed with a test sink therefore leaves the global gauge incremented and drives the injected gauge to -1 on close. Route the remaining factory counter calls throughMetricsas well.
Metrics = metrics ?? SqlClientDiagnostics.Metrics;
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:210
- The tracing parity work has no automated trace assertions: the unit-test tree contains no pooler-trace listener or checks for these newly added messages. Add focused coverage for lifecycle, timeout/shutdown, liveness-rejection, transaction-held, and pruning traces so message omissions and format regressions are detected.
This issue also appears on line 534 of the same file.
SqlClientEventSource.Log.TryPoolerTraceEvent(
"ChannelDbConnectionPool.ChannelDbConnectionPool | INFO | {0}, Constructed. MinPoolSize={1}, MaxPoolSize={2}",
Id,
MinPoolSize,
MaxPoolSize);
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ConnectionPoolSlots.cs:103
- Add unit coverage for the new
ConnectionCountinvariant.ConnectionPoolSlotsTestcurrently verifies onlyReservationCount, so it would not catch missed increments/decrements or prove the key distinction that an in-flight reservation raisesReservationCountwhileConnectionCountremains unchanged. Cover construction, add/remove/replace, failed creation, and a blocked create callback.
/// <summary>
/// Gets the number of connections currently tracked by this collection. Unlike
/// <see cref="ReservationCount"/>, this excludes reservations held for connections that are
/// still being opened, so it reports connections that actually belong to the pool.
/// </summary>
internal int ConnectionCount => Volatile.Read(ref _connectionCount);
…utomation/channel-pool-v2-followups
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.
Suppressed comments (5)
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:534
SoftConnectRequestis emitted only after the replacement has been prepared. WhenGetIdleConnection()supplies the replacement and activation throws,PrepareConnectionreturns that connection throughReturnInternalConnection, which emitsSoftDisconnectRequest, but execution never reaches this line. That drivesactive-soft-connectsnegative—the same failure mode this PR fixes on normal checkout. Count the idle replacement before callingPrepareConnection(so its failure return balances it), while keeping the create-new branch balanced separately.
Metrics.SoftConnectRequest();
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnectionFactory.cs:195
- The injected factory sink is not used consistently for non-pooled connections. This hard-connect event and the connection's later hard-disconnect/
ExitNonPooledConnectionnow go toMetrics, but both non-pooled success paths inTryGetConnectionstill callSqlClientDiagnostics.Metrics.EnterNonPooledConnection()(lines 487 and 1106). With a test sink, closing a non-pooled connection therefore leaves its non-pooled gauge at -1 and the global gauge at +1. Route those enter events through the factory'sMetricsas well.
Metrics.HardConnectRequest();
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/IdleConnectionChannel.cs:66
- The connection is published to the channel before the free gauge is incremented. A concurrent reader can consume it and call
ExitFreeConnectionduring this window, makingnumber-of-free-connectionstransiently negative and observable by an EventCounter poll. Increment the count/gauge before publishing, and roll both back ifTryWritefails.
Interlocked.Increment(ref _count);
_metrics.EnterFreeConnection();
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:210
- The new tracing behavior is not exercised by any test: there is no pool test that subscribes to the EventSource or asserts any of the added lifecycle/rejection messages. Because trace parity is a primary behavior of this PR and these exact strings are the operator-facing contract, add listener-based coverage for the representative success, timeout/shutdown, transaction-held, and liveness-rejection paths.
SqlClientEventSource.Log.TryPoolerTraceEvent(
"ChannelDbConnectionPool.ChannelDbConnectionPool | INFO | {0}, Constructed. MinPoolSize={1}, MaxPoolSize={2}",
Id,
MinPoolSize,
MaxPoolSize);
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ConnectionPoolSlots.cs:103
ConnectionCounthas no direct regression coverage. ExistingConnectionPoolSlotsTestassertions only inspectReservationCount, so they would not detect a missing increment/decrement or prove the defining behavior that an in-flightAddreservation is excluded. Add a gated create-callback test that observes both counts while creation is blocked, then verifies add, remove, and replace semantics.
/// Gets the number of connections currently tracked by this collection. Unlike
/// <see cref="ReservationCount"/>, this excludes reservations held for connections that are
/// still being opened, so it reports connections that actually belong to the pool.
/// </summary>
internal int ConnectionCount => Volatile.Read(ref _connectionCount);
Rebased onto
mainand now also contains the tracing parity work that was previously PR #4505.ChannelDbConnectionPooldid not emit any of the connection pool counters thatWaitHandleDbConnectionPoolemits, reportedCountas the reservation count (which includes in-flight creations), and traced far less than the wait handle pool.Bringing the two pools to counter parity surfaced several counter bugs that affect both pools. Those fixes are called out separately below.
Metrics
ChannelDbConnectionPoolandIdleConnectionChannel, at the call sites matchingWaitHandleDbConnectionPool.Count semantics
ConnectionPoolSlots.ConnectionCountand pointCountat it, soCountreflects connections that actually belong to the pool rather than reservations held for connections still being opened. This matchesWaitHandleDbConnectionPooland fixes the SQL Express user instance path, which branches onpool.Count <= 0.ReservationCountinstead ofCount, matching the max-pool-size gate and the pruner.Countexcludes in-flight creations, so warmup created duplicates for connections other threads were already opening.Tracing parity
TransactedConnectionPoolkeep the existing format, since it is shared with the wait handle pool and reformatting it would change v1's trace stream.PoolPruneris constructed only byChannelDbConnectionPool, so it takes the new format. Converting the rest of the repo to a single trace format is left to its own PR.Injectable metrics sink
IDbConnectionPool.Metricsand let both pool implementations take aSqlClientMetricsin their constructor, defaulting to the globalSqlClientDiagnostics.Metrics.SqlConnectionFactoryholds its own sink and feeds it to each connection throughDbConnectionInternal's constructor, so a test can observe connection-level counters too.Counter bugs fixed in both pools
Each of these drove a publicly documented gauge away from reality permanently, since a gauge never recovers from an unmatched increment or decrement.
ReplaceConnectioncounted the replacement checkout but nothing for the connection it displacedactive-soft-connectsReplaceConnectiondisposed the displaced connection without recording its destructionactive-hard-connectionsCreateObjectswapped the replacement into the old connection's place but only counted the additionnumber-of-pooled-connectionsactive-soft-connectsActivateConnectioncounted after callingActivatewhileDeactivateConnectioncounts before callingDeactivatenumber-of-active-connectionsactive-soft-connectsConnectionPoolSlots.Adddisposed an opened connection on its failure path without recording the destructionactive-hard-connectionsCounter semantics these were fixed against, for the record: a soft connect is the pool vending a connection, a soft disconnect is a connection returning to the pool, a hard connect is a new physical connection, and a hard disconnect is a connection being destroyed.
Known gap
number-of-reclaimed-connectionsreads flat zero underChannelDbConnectionPool.ReclaimedConnectionRequestis only emitted by the wait handle pool, because the channel pool does not yet reclaim emancipated connections. That work is tracked in #4490 and the counter will be wired up there.Suggested release note entry
Tests: the pool instrumentation tests are consolidated in
DbConnectionPoolInstrumentationTestand parameterized over both pools. They assert every counter's exact value, treating any counter the test does not name as zero, so an unexpected emission fails. New coverage in this PR: failed activation leaves all counters balanced, concurrent checkout/return settles every gauge to a consistent resting state, and idle pruning records the destruction it performs. The failed-activation test is what surfaced the two negative gauges. The stasis gauge also gained unit coverage, which it previously had none of: all three ways a pool puts a connection in stasis, and both ways it leaves.Not verified:
net462. This branch was developed on macOS, where that target cannot be built.Deferred to separate PRs: emancipated-connection reclamation (#4490), surfacing the underlying cause of a pooled-open timeout as an inner exception, and the async idle-connection fast path.