ChannelDbConnectionPool transaction support - #4487
Conversation
There was a problem hiding this comment.
Pull request overview
Adds full transaction enlistment/routing support to the V2 ChannelDbConnectionPool, aligning behavior with the legacy WaitHandleDbConnectionPool so pooled connections correctly participate in ambient System.Transactions flows (including async acquisition).
Changes:
- Implemented transaction lifecycle plumbing in
ChannelDbConnectionPool(PutObjectFromTransactedPool,TransactionEnded, transacted acquisition path, and updated return/deactivation logic). - Enabled async acquisition to restore the captured ambient transaction on the worker thread (
ADP.SetCurrentTransaction(...)). - Added a comprehensive unit test suite for channel-pool transaction behavior and removed the now-stale
NotImplementedExceptionassertions.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTransactionTest.cs | New transaction-focused unit tests for the channel-based pool, mirroring WaitHandle pool coverage and adding channel-specific scenarios. |
| src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs | Removes tests that asserted transaction methods were unimplemented (now implemented). |
| src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs | Implements transaction support: transacted pool vending/parking, correct return paths for enlisted connections, and async ambient transaction propagation. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTransactionTest.cs:649
task2Doneis declared but never used, which adds noise and makes the synchronization intent harder to follow. Remove it (or use it if it was meant to assert task2 completion).
using var task2Done = new ManualResetEventSlim(false);
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:1206
GetInternalConnectioncreates aCancellationTokenSourceeven when a connection was successfully retrieved from the transacted pool, which adds avoidable allocations on that hot path. Consider returning early afterGetFromTransactedPoolsucceeds so the CTS/loop is skipped entirely.
// Derive a CancellationTokenSource from the TimeoutTimer so pool-internal wait operations
// (channel reads, semaphore waits) are cancelled when the overall budget expires.
using CancellationTokenSource cancellationTokenSource = timeout.CreateCancellationTokenSource();
CancellationToken cancellationToken = cancellationTokenSource.Token;
mdaigle
left a comment
There was a problem hiding this comment.
Overall, the tests need a lot of cleanup. Verify pool count, idle, general stats and metrics at each step. Remove tests that are a strict subset of other tests. Add comments, think deeply about which tests are really required and provide good coverage. Use code coverage metrics to guide your decisions.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:260
- The new XML doc for
HasTransactionAffinitysays connections may be "vended from (and parked in)" theTransactedConnectionPoolwhen enabled. The code still parks connections based onconnection.EnlistedTransactioninDecideReturnDispositioneven when transaction affinity is disabled (e.g., manually enlisted connections), so the doc is misleading about the parking behavior. Consider rewording to clarify that this flag controls automatic transaction affinity (consulting the transacted pool / auto-enlisting on activation), not whether parking can occur at all.
/// <summary>
/// Indicates whether connections may be vended from (and parked in) the
/// <see cref="TransactedConnectionPool"/>. This mirrors automatic transaction enlistment:
/// when enlistment is disabled a connection is never bound to an ambient transaction, so
/// the transacted store must not be consulted.
/// </summary>
private bool HasTransactionAffinity => PoolGroupOptions.HasTransactionAffinity;
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 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:260
- The
HasTransactionAffinitydoc comment currently states the transacted store “must not be consulted” when enlistment is disabled, but the return path still parks any manually-enlisted connection (connection.EnlistedTransaction != null) in the transacted store (matching WaitHandle behavior). The summary should be narrowed to describe ambient-transaction consultation/activation only, to avoid misleading future maintainers.
/// Indicates whether connections may be vended from (and parked in) the
/// <see cref="TransactedConnectionPool"/>. This mirrors automatic transaction enlistment:
/// when enlistment is disabled a connection is never bound to an ambient transaction, so
/// the transacted store must not be consulted.
/// </summary>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 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:258
- The HasTransactionAffinity summary is misleading: the pool can still park explicitly-enlisted connections in the TransactedConnectionPool even when transaction affinity (ambient auto-enlist) is disabled. The property is only used to decide whether to consult the transacted store for the ambient transaction and pass it to activation.
/// <summary>
/// Indicates whether connections may be vended from (and parked in) the
/// <see cref="TransactedConnectionPool"/>. This mirrors automatic transaction enlistment:
/// when enlistment is disabled a connection is never bound to an ambient transaction, so
/// the transacted store must not be consulted.
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:1366
- SqlClientDiagnostics.Metrics free-connection accounting looks inconsistent in ChannelDbConnectionPool: this path decrements free connections when vending from the transacted pool, and TransactedConnectionPool.TransactionEnded also decrements before calling PutObjectFromTransactedPool, but the channel pool never increments/decrements free-connection metrics when writing to/reading from the idle channel (unlike WaitHandleDbConnectionPool.PutNewObject/GetFromGeneralPool). This can leave free-connection telemetry incorrect after transaction completion (and generally makes metrics hard to interpret for the V2 pool).
connection.ObjectID);
SqlClientDiagnostics.Metrics.ExitFreeConnection();
// Transacting connections are exempt from idle-timeout and clear-generation eviction
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4487 +/- ##
==========================================
- Coverage 64.71% 62.74% -1.98%
==========================================
Files 288 283 -5
Lines 44088 67136 +23048
==========================================
+ Hits 28532 42122 +13590
- Misses 15556 25014 +9458
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Correct the ReturnInternalConnection comment: deactivation does not detach a completed transaction, that happens in CloseConnection before the pool is called. The real constraint is that DeactivateConnection mutates both gates the return path branches on. Explain why ReplaceConnection releases the old connection's slot in the idle branch but not in the create-new branch, and note that the V1 stasis case for a null pool is unreachable and redundant. Assert EnlistedTransaction alongside the pool-state assertions so the tests confirm what a connection is bound to, not just where it was filed. Add transacted store counts for the inner transaction at vend and after completion. Cover a transaction completing while its connection is still checked out, and strengthen the bare TransactionEnded test to use an enlisted connection. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTransactionTest.cs:897
EnlistTransaction(null)is commonly used to unenlist/detach. The mock currently ignoresnull, which can leaveEnlistedTransactionset and make the tests diverge from real provider behavior (and potentially mask bugs around detachment). Consider settingEnlistedTransaction = transaction;unconditionally so null clears enlistment.
public override void EnlistTransaction(Transaction? transaction)
{
if (transaction != null)
{
EnlistedTransaction = transaction;
}
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTransactionTest.cs:902
- The new transaction-affinity tests don’t exercise the transaction-root pathways added/relied on by the implementation (stasis via IsTransactionRoot / IsTxRootWaitingForTxEnd, and the dead-root rethrow behavior in GetFromTransactedPool). In this file, MockDbConnectionInternal never overrides IsTransactionRoot (so it stays false) and never simulates liveness failures, so those branches in ChannelDbConnectionPool aren’t covered by unit tests.
internal class MockDbConnectionInternal : DbConnectionInternal
{
private static int s_nextId = 1;
public int MockId { get; } = Interlocked.Increment(ref s_nextId);
public override string ServerVersion => "Mock";
public override ConnectionCapabilities Capabilities => new();
public override DbTransaction BeginTransaction(System.Data.IsolationLevel il)
{
throw new NotImplementedException();
}
public override void EnlistTransaction(Transaction? transaction)
{
if (transaction != null)
{
EnlistedTransaction = transaction;
}
}
protected override void Activate(Transaction? transaction)
{
EnlistedTransaction = transaction;
}
Skip the liveness probe when releasing a connection from the transacted store. That path runs on the System.Transactions completion callback thread, under DelegatedTransactionEnded's requirement that the caller holds a lock on the connection, and IsConnectionAlive polls the socket. WaitHandleDbConnectionPool does not probe there either. The idle-expiry, load-balance and clear-generation checks still run, so only the socket poll is suppressed and a connection that died during the transaction is still caught when it is vended. Document why ReplaceConnection's RemoveConnection call always frees the slot: stasis is only ever entered from ReturnInternalConnection, and the old connection is still checked out there, so the double reservation cannot occur. Note that transaction end would reclaim the slot anyway. Caveat RemoveConnection's summary, which everywhere else means the slot is now free, and record that no current caller reaches its transaction root early return. Add coverage for the paths this feedback exercised: - stasis entry via both triggers, and slot reclamation on transaction end - returning a doomed connection that is still enlisted - a waiter on an exhausted pool woken when a transaction ends - Clear while a connection is parked, retired rather than re-pooled - liveness probe suppressed on release but applied on ordinary return Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
cheenamalhotra
left a comment
There was a problem hiding this comment.
LGTM, I'd like to see Metrics coming live for the v2 connection pool, abd that's the next big thing I'm looking forward to testing!
Task.WaitAsync(TimeSpan) is .NET 6+, so the unit test project failed to compile for net462 with CS1501. Replace it with a Task.WhenAny-based WithTimeout helper that works on both target frameworks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Rebased onto
mainnow that #4429 has merged. Unit tests: 834 passed / 0 failed.Summary
Implements transaction support in
ChannelDbConnectionPool, usingWaitHandleDbConnectionPoolas the reference for correct behavior. Before this change the channel pool constructed aTransactedConnectionPoolbut never used it, and threeIDbConnectionPoolmembers threwNotImplementedException.Changes
PutObjectFromTransactedPool(wasNotImplementedException) — returns a connection to general circulation once its transaction has ended, or destroys it if the pool is no longer running or the connection can't be pooled.TransactionEnded(wasNotImplementedException) — delegates toTransactedConnectionPool.TransactionEnded, which calls back intoPutObjectFromTransactedPool.ReturnInternalConnection— rewritten to mirrorWaitHandleDbConnectionPool.DeactivateObject. It now deactivates first (deactivation is what detaches a completed transaction, so readingEnlistedTransactionbeforehand could park a connection under an already-ended transaction), then decides under the connection lock between the transacted pool, stasis, the idle channel, and destruction. The idle-channel path moved into a newPutConnectionInIdleChannelhelper.GetFromTransactedPool(new) — vends a connection already enlisted in the ambient transaction. Transacted connections are exempt from idle-timeout and clear-generation eviction, since closing them would abort a possibly-distributed transaction, so only liveness is checked. A dead transaction root rethrows rather than silently retrying, because its delegated transaction cannot be recovered on another connection.GetInternalConnection/PrepareConnection— consult the transacted pool when the pool group has transaction affinity, and pass the ambient transaction through toActivateConnection.taskCompletionSource.Task.AsyncStateand threads it explicitly through the open, rather than assigningTransaction.Currenton the thread pool thread. See the section below.RemoveConnection— no longer disposes a transaction root that is still waiting for its delegated transaction to end (parity withDestroyObject). It comes back throughPutObjectFromTransactedPoolwhen the transaction completes.ReplaceConnection— no functional change; the twoTODO: Full transaction enlistment support (Story 2)markers from ChannelDbConnectionPool replace connection #4429 are removed now that enlistment is wired through.How connections move between the idle channel and the transacted store
The transacted store (
TransactedConnectionPool, keyed byTransaction) reserves a connection for one specific transaction, so reusing it avoids promoting that transaction to a distributed one. The only edge into it is a return while still enlisted; the only edges out are a pop by a caller in the same transaction, or the transaction ending.Return path (
ReturnInternalConnection)flowchart TD R["ReturnInternalConnection"] --> V["ValidateOwnershipAndSetPoolingState"] V --> D["DeactivateConnection"] D --> Doomed{"IsConnectionDoomed?"} Doomed -- yes --> Destroy["RemoveConnection (Destroy)"] Doomed -- no --> Poolable{"State is Running and CanBePooled?"} Poolable -- no --> Root{"IsTransactionRoot?"} Root -- yes --> Stasis["SetInStasis (HeldByTransaction)"] Root -- no --> Destroy Poolable -- yes --> Enl{"EnlistedTransaction is not null?"} Enl -- yes --> Park["PutTransactedObject (HeldByTransaction)"] Enl -- no --> Reuse["PutConnectionInIdleChannel (Reuse)"]DeactivateConnectionruns beforeEnlistedTransactionis read, because deactivation is what detaches an already-completed transaction. Reading first would park the connection under a transaction that has already ended, and it would never be released.Transaction end: back to general circulation
flowchart TD Sig["System.Transactions signals completion"] --> Where{"where is the connection?"} Where -- "parked in the transacted store" --> TE["pool.TransactionEnded"] TE --> TCP["TransactedConnectionPool.TransactionEnded removes it from the list"] TCP --> Put["PutObjectFromTransactedPool"] Where -- "in stasis" --> DTE["DelegatedTransactionEnded then TerminateStasis(true)"] DTE --> Put Where -- "never parked, still checked out" --> NoOp["no-op: stays with its owner"] Put --> Ok{"State is Running and CanBePooled?"} Ok -- yes --> Reset["ResetConnection then PutConnectionInIdleChannel"] Ok -- no --> Rm["RemoveConnection"]A connection in stasis reaches
PutObjectFromTransactedPooltoo, but by definition it got there because the pool was stopping or it was unpoolable, so it always takes theRemoveConnectionbranch.A parked connection keeps its
_connectionSlotsreservation, so it still counts towardCountandMaxPoolSize, but notIdleCount. OnlyRemoveConnectionreleases the slot.Tests
ChannelDbConnectionPoolTransactionTest(18 tests): return routing (enlisted, not enlisted, completed transaction,Enlist=false, shut-down pool), vending from the transacted store under the same and different transactions, commit/rollback, completion after shutdown,TransactionEndedfor a connection that was never parked, enlistment carry-over throughReplaceConnection, and the ambient-transaction flow cases below. Every test asserts full pool state (Count,IdleCount, transacted count) after each step, and the pool is built with a frozenTimeProvider.NotImplementedExceptionfrom the three now-implemented members.Validation
Unit tests: 834 passed / 0 failed on
net9.0.Manual/integration tests were run against a local SQL Server with
Switch.Microsoft.Data.SqlClient.UseConnectionPoolV2enabled, acrossTransactionEnlistmentTest,TransactionPoolTest,SQL.TransactionTest,ParallelTransactionsTest,ConnectionPoolTest,PoolBlockPeriodTestandDistributedTransactionTest(48 tests):This change fixes three previously failing tests:
TestAutoEnlistment_TxScopeNonComplete,TestManualEnlistment_EnlistandTestManualEnlistment_Enlist_TxScopeComplete.The 6 failures shared with the V1 baseline are all
PlatformNotSupportedException: This platform does not support distributed transactions— MSDTC isn't available on the test machine. The 2 remaining failures (ConnectionPoolTest.ReclaimEmancipatedOnOpenTest) are a pre-existing V2 gap:ReclaimEmancipatedObjectshas never been implemented inChannelDbConnectionPool. Both were confirmed by reverting this change and reproducing.A broader V2 sweep (
SqlCommand,AsyncTest,MARSTest,DataReaderTest,ConnectivityTests,WeakRefTest,AdapterTest,ExceptionTest,RetryLogic) gave 213 passed / 9 failed, where all 9 are named-pipe tests that fail identically on V1.Ambient transaction flow on the async path
Transaction.Currentdoes not flow into aTask.Rununless theTransactionScopewas created withTransactionScopeAsyncFlowOption.Enabled(the default isSuppress, which keeps the ambient transaction in thread-static storage). The async open path therefore cannot simply readTransaction.Current— it has to take the transaction from theTaskCompletionSource'sAsyncState, which is whereSqlConnection.InternalOpenAsynccaptures it. That is the only site in the repo that constructs aTaskCompletionSource<DbConnectionInternal>, and it always passes the ambient transaction, so the mechanism is reliable (including acrossOpenAsyncRetry.Retry, which reuses the same TCS).The original approach was to restore it by assigning
ADP.SetCurrentTransaction(...)inside theTask.Run. That is unsafe here: assigningTransaction.Currentwrites to thread-static storage thatExecutionContextdoes not unwind, so the transaction outlives the open and is observable by unrelated work later scheduled onto the same thread pool thread — most notably the login-time auto-enlistment that non-pooled connections perform againstTransaction.Current. Atry/finallyrestore doesn't fix it either, because the async continuation may resume on a different thread than the one that was polluted. (WaitHandleDbConnectionPooldoes the same assignment safely becauseWaitForPendingOpenasserts it is not on a thread pool thread.)The fix is to never mutate
Transaction.Currentin the pool: the transaction is captured on the caller's thread and threaded explicitly throughGetInternalConnectionintoGetFromTransactedPoolandPrepareConnection. The sync path passesADP.GetCurrentTransaction()directly, since it runs on the caller's thread.Four regression tests cover this:
GetConnection_Sync_UsesAmbientTransactionFromCallersThreadTransaction.Current, which is correct because it runs on the caller's threadGetConnectionAsync_UsesAmbientTransactionCapturedOnCallersThreadAsyncFlowOption.Enabledstill enlistsGetConnectionAsync_WithAsyncFlowDisabled_StillEnlistsInAmbientTransactionSuppress) scope still enlists, even though the transaction provably does not flow off the caller's threadGetConnectionAsync_EnteredFromThreadWithoutAmbientTransaction_EnlistsFromAsyncStateAsyncState, not from whatever is ambient on the entering thread — this is theOpenAsyncRetry.RetrycaseThe last one is load-bearing and not redundant:
TryGetConnectionreadsAsyncStatewhile still on the caller's thread, so in the scope-based testsTransaction.Currenthappens to agree withAsyncState. Only entering the pool from a thread with no ambient transaction distinguishes them. Verified by mutation — replacing theAsyncStateread withnullfails 5 tests, and replacing it withADP.GetCurrentTransaction()fails only the retry-path and no-leak tests.Checklist
UseConnectionPoolV2switch, which defaults to offNotes
ReclaimEmancipatedObjectsremains unimplemented in the channel pool; it is orthogonal to transactions and left for a follow-up.