Close remaining ChannelDbConnectionPool parity gaps with WaitHandleDbConnectionPool - #4490
Draft
mdaigle wants to merge 1 commit into
Draft
Conversation
…ConnectionPool 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>
mdaigle
changed the base branch from
dev/mdaigle/replace-conn-2
to
dev/automation/channel-pool-transactions
July 29, 2026 21:35
Contributor
There was a problem hiding this comment.
Pull request overview
Closes the remaining behavioral and observability parity gaps between ChannelDbConnectionPool (V2) and WaitHandleDbConnectionPool (V1), primarily around reclaiming emancipated connections, emitting pool metrics, reporting Count consistently, and allowing async opens to complete synchronously on a warm pool when safe.
Changes:
- Add an emancipated-connection reclamation sweep on the V2 slow acquisition path to prevent permanent pool-slot leaks at
MaxPoolSize. - Wire up V2 pool metrics (pooled/free/active connections and connect/disconnect counters) and align
Countsemantics with V1 via a new tracked connection count. - Improve V2 async acquisition parity by attempting a non-blocking idle-channel hit before enqueuing work (excluding transactional requests), and update tests to run under both pool versions with proper isolation.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs | Fix test reliability under new synchronous-completion behavior and prevent GC-induced “emancipated” reclamation from invalidating pool-exhaustion tests. |
| src/Microsoft.Data.SqlClient/tests/ManualTests/TracingTests/MetricsTest.cs | Parameterize pooled-connection metrics validation across V1/V2 via a pool-version scope helper. |
| src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/ParameterTest/TvpTest.cs | Fix Task.Factory.StartNew async-lambda misuse by Unwrap() so failures/timeout behavior are observed correctly. |
| src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/ConnectivityTests/ConnectivityTest.cs | Parameterize resiliency test across pool versions with isolation via the new scope helper. |
| src/Microsoft.Data.SqlClient/tests/ManualTests/SQL/ConnectionPoolTest/ConnectionPoolTest.cs | Run emancipated-reclaim and max-pool-wait tests under both pool versions using a shared provider and pool-version scope. |
| src/Microsoft.Data.SqlClient/tests/Common/ConnectionPoolVersionScope.cs | New RAII helper to toggle UseConnectionPoolV2 and clear pools on entry/exit to prevent cross-test implementation leakage. |
| src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/IdleConnectionChannel.cs | Emit free-connection metric transitions at the idle-channel choke point. |
| src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ConnectionPoolSlots.cs | Track actual connection count (distinct from reservations) and add a best-effort snapshot API for infrequent bookkeeping. |
| src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs | Align Count with V1, add idle-channel async fast path, add emancipated reclamation sweep, and wire up pooled/soft/hard metric emission. |
mdaigle
marked this pull request as draft
July 29, 2026 22:13
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #4487 (
dev/automation/channel-pool-transactions). Review only the top commit; the rest is #4487.Why
I ran the whole test suite against both pool implementations and diffed the results, plus built a standalone differential harness that exercises pool semantics the suite doesn't cover. That turned up four behavioural gaps in
ChannelDbConnectionPoolthat had no test coverage, so nothing was catching them.What
1. Emancipated connection reclamation. A
SqlConnectionthat was garbage collected without ever being closed or disposed left its internal connection permanently occupying a pool slot. AtMaxPoolSizethat meant every subsequentOpentimed out — forever, not just once.GetInternalConnectionnow sweeps for emancipated connections before parking on the idle channel, the same wayWaitHandleDbConnectionPooldoes. The sweep is deliberately confined to the slow path: it's O(MaxPoolSize) and allocates a snapshot, so it must not run on the hot acquire path.2. Pool metrics were never emitted.
PooledConnections,FreeConnections,ActiveConnectionsand the soft/hard connect and disconnect counters all read zero under this pool. Wired up the same call sites the wait handle pool uses.IdleConnectionChannelis a convenient single choke point for the free-connection counters.3.
Countreported reservations rather than connections. Reservations include connections that are still being opened, whereas V1'sCountis_totalObjects. This broke the SQL Express user instance path inSqlConnectionFactory.CreateConnection, which branches onpool.Count <= 0— it took the wrong branch and threw an NRE out ofSqlConnectionOptions.ValidateValueLengthbecauseproviderInfo.InstanceNamewas never populated. AddedConnectionPoolSlots.ConnectionCountand pointedCountat it.4. Async opens always completed asynchronously.
WaitHandleDbConnectionPoolmakes a non-blocking, non-creating attempt at an idle connection before enqueuing a pending open; this pool did not, soOpenAsyncagainst a warm pool always took a thread pool hop. Added the same fast path. Transactional requests are excluded from it — they have to go through the transacted store first to find a connection already enlisted in the same transaction, and taking a plain idle connection would both miss that affinity and skip enlistment.Tests
ConnectionPoolVersionScopehelper. It flips the switch and clears all pools on entry and exit — the clearing matters because a pool binds to its implementation at creation time, so without it V2 pools leak into later tests.ReclaimEmancipatedOnOpenTest,MaxPoolWaitForConnectionTest,ConnectionResiliencySPIDTest,MetricsTest.PooledConnectionsCounters_Functional. Each was verified to fail before its corresponding fix and pass after.ChannelDbConnectionPoolTest.StressTestAsyncawaited itsTaskCompletionSourceunconditionally, which hangs now thatTryGetConnectioncan complete synchronously.SqlConnections go out of scope, so reclamation could legitimately hand the "should time out" waiter a connection. They now keep the owners alive, which is also what they actually meant.TvpTest.TestPacketNumberWraparoundpassed an async lambda toTask.Factory.StartNewand so awaited aTask<Task>, never observing the inner task or its failures. Added the missingUnwrap.Verification
Ran all three suites under both pools on net9.0/managed SNI against SQL Server, and the failure sets are identical apart from the one expected difference.
TestDefaultAppContextSwitchValues, which necessarily fails when the switch is globally onThe pre-existing failures in both columns are environmental for my box (no MSDTC, no SQL CLR/UDT support, Windows-only CNG/CSP and named pipe tests).
Notably all
TransactionEnlistmentTest.*cases andMetricsTest.TransactedConnectionPool_VerifyActiveConnectionCountersnow pass under V2 — those were the outstanding failures before #4487 landed.I also wrote a transaction-focused differential harness covering scope commit/rollback, transaction affinity across two connections in one scope, an enlisted connection not being handed to a non-transactional caller, return-to-pool after the transaction ends, explicit
SqlTransaction, manualEnlistTransaction, async open inside a scope, scoped open against a pre-warmed pool (specifically to catch the sync fast path stealing an unenlisted connection), and 15 s of concurrent transaction churn across 16 tasks verifying the committed row count exactly. 10/10 on both pools; V2 sustained ~12% more committed transactions per second.Checklist