Skip to content

ChannelDbConnectionPool transaction support - #4487

Open
mdaigle wants to merge 7 commits into
mainfrom
dev/automation/channel-pool-transactions
Open

ChannelDbConnectionPool transaction support#4487
mdaigle wants to merge 7 commits into
mainfrom
dev/automation/channel-pool-transactions

Conversation

@mdaigle

@mdaigle mdaigle commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Rebased onto main now that #4429 has merged. Unit tests: 834 passed / 0 failed.

Summary

Implements transaction support in ChannelDbConnectionPool, using WaitHandleDbConnectionPool as the reference for correct behavior. Before this change the channel pool constructed a TransactedConnectionPool but never used it, and three IDbConnectionPool members threw NotImplementedException.

Changes

  • PutObjectFromTransactedPool (was NotImplementedException) — 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 (was NotImplementedException) — delegates to TransactedConnectionPool.TransactionEnded, which calls back into PutObjectFromTransactedPool.
  • ReturnInternalConnection — rewritten to mirror WaitHandleDbConnectionPool.DeactivateObject. It now deactivates first (deactivation is what detaches a completed transaction, so reading EnlistedTransaction beforehand 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 new PutConnectionInIdleChannel helper.
  • 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 to ActivateConnection.
  • Async acquisition path — takes the ambient transaction from taskCompletionSource.Task.AsyncState and threads it explicitly through the open, rather than assigning Transaction.Current on 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 with DestroyObject). It comes back through PutObjectFromTransactedPool when the transaction completes.
  • ReplaceConnection — no functional change; the two TODO: 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 by Transaction) 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)"]
Loading

DeactivateConnection runs before EnlistedTransaction is 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"]
Loading

A connection in stasis reaches PutObjectFromTransactedPool too, but by definition it got there because the pool was stopping or it was unpoolable, so it always takes the RemoveConnection branch.

A parked connection keeps its _connectionSlots reservation, so it still counts toward Count and MaxPoolSize, but not IdleCount. Only RemoveConnection releases the slot.

Tests

  • New 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, TransactionEnded for a connection that was never parked, enlistment carry-over through ReplaceConnection, 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 frozen TimeProvider.
  • Removed the stale tests asserting NotImplementedException from 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.UseConnectionPoolV2 enabled, across TransactionEnlistmentTest, TransactionPoolTest, SQL.TransactionTest, ParallelTransactionsTest, ConnectionPoolTest, PoolBlockPeriodTest and DistributedTransactionTest (48 tests):

Run Passed Failed
V1 (WaitHandle) baseline 39 6
V2 without this change 36 9
V2 with this change 37 8

This change fixes three previously failing tests: TestAutoEnlistment_TxScopeNonComplete, TestManualEnlistment_Enlist and TestManualEnlistment_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: ReclaimEmancipatedObjects has never been implemented in ChannelDbConnectionPool. 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.Current does not flow into a Task.Run unless the TransactionScope was created with TransactionScopeAsyncFlowOption.Enabled (the default is Suppress, which keeps the ambient transaction in thread-static storage). The async open path therefore cannot simply read Transaction.Current — it has to take the transaction from the TaskCompletionSource's AsyncState, which is where SqlConnection.InternalOpenAsync captures it. That is the only site in the repo that constructs a TaskCompletionSource<DbConnectionInternal>, and it always passes the ambient transaction, so the mechanism is reliable (including across OpenAsyncRetry.Retry, which reuses the same TCS).

The original approach was to restore it by assigning ADP.SetCurrentTransaction(...) inside the Task.Run. That is unsafe here: assigning Transaction.Current writes to thread-static storage that ExecutionContext does 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 against Transaction.Current. A try/finally restore doesn't fix it either, because the async continuation may resume on a different thread than the one that was polluted. (WaitHandleDbConnectionPool does the same assignment safely because WaitForPendingOpen asserts it is not on a thread pool thread.)

The fix is to never mutate Transaction.Current in the pool: the transaction is captured on the caller's thread and threaded explicitly through GetInternalConnection into GetFromTransactedPool and PrepareConnection. The sync path passes ADP.GetCurrentTransaction() directly, since it runs on the caller's thread.

Four regression tests cover this:

Test What it pins
GetConnection_Sync_UsesAmbientTransactionFromCallersThread the sync path reads Transaction.Current, which is correct because it runs on the caller's thread
GetConnectionAsync_UsesAmbientTransactionCapturedOnCallersThread a scope with AsyncFlowOption.Enabled still enlists
GetConnectionAsync_WithAsyncFlowDisabled_StillEnlistsInAmbientTransaction a default (Suppress) scope still enlists, even though the transaction provably does not flow off the caller's thread
GetConnectionAsync_EnteredFromThreadWithoutAmbientTransaction_EnlistsFromAsyncState the transaction comes from AsyncState, not from whatever is ambient on the entering thread — this is the OpenAsyncRetry.Retry case

The last one is load-bearing and not redundant: TryGetConnection reads AsyncState while still on the caller's thread, so in the scope-based tests Transaction.Current happens to agree with AsyncState. Only entering the pool from a thread with no ambient transaction distinguishes them. Verified by mutation — replacing the AsyncState read with null fails 5 tests, and replacing it with ADP.GetCurrentTransaction() fails only the retry-path and no-leak tests.

Checklist

  • Tests added or updated
  • Public API changes documented — n/a, no public API change
  • Verified against customer repro (if applicable) — n/a
  • Ensure no breaking changes introduced — behavior is behind the existing UseConnectionPoolV2 switch, which defaults to off

Notes

  • ReclaimEmancipatedObjects remains unimplemented in the channel pool; it is orthogonal to transactions and left for a follow-up.

Copilot AI review requested due to automatic review settings July 29, 2026 17:44
@mdaigle
mdaigle requested a review from a team as a code owner July 29, 2026 17:44
@github-project-automation github-project-automation Bot moved this to To triage in SqlClient Board Jul 29, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 NotImplementedException assertions.

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.

@mdaigle mdaigle added this to the 7.1.0-preview3 milestone Jul 29, 2026
@mdaigle
mdaigle marked this pull request as draft July 29, 2026 18:08
Copilot AI review requested due to automatic review settings July 29, 2026 18:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • task2Done is 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

  • GetInternalConnection creates a CancellationTokenSource even when a connection was successfully retrieved from the transacted pool, which adds avoidable allocations on that hot path. Consider returning early after GetFromTransactedPool succeeds 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 mdaigle left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI review requested due to automatic review settings July 29, 2026 19:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 HasTransactionAffinity says connections may be "vended from (and parked in)" the TransactedConnectionPool when enabled. The code still parks connections based on connection.EnlistedTransaction in DecideReturnDisposition even 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;

Copilot AI review requested due to automatic review settings August 3, 2026 22:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 HasTransactionAffinity doc 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>

Copilot AI review requested due to automatic review settings August 3, 2026 23:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings August 3, 2026 23:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copilot AI review requested due to automatic review settings August 3, 2026 23:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTransactionTest.cs:665

  • XML doc comment for this test method is missing the opening <summary> tag, leaving an unterminated XML element (</summary> without a matching start tag). This can trigger CS1570/CS1574 when XML doc processing is enabled and also breaks the repo’s test-doc conventions.
    /// ExecutionContext does not unwind, so doing it on a thread pool thread would leave a stale
    /// transaction behind for unrelated work later scheduled onto that same thread -- including
    /// the login-time auto-enlistment that non-pooled connections perform against the ambient
    /// transaction. The pool must pass the transaction explicitly instead of assigning it.
    ///

src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTransactionTest.cs:813

  • MockDbConnectionInternal uses the base default UnbindOnTransactionCompletion == true, but SqlClient’s real SqlConnectionInternal overrides it to false (explicit unbinding). With the current mock, ReturnConnection_AfterTransactionCompleted_ReturnsToIdleChannel can pass even if the pool reads EnlistedTransaction before deactivation, because the TransactionCompleted handler will have already cleared the enlistment. Override UnbindOnTransactionCompletion to false and detach ended transactions during Deactivate() so the tests actually cover the “deactivate first to detach completed transaction” behavior.
        protected override void Activate(Transaction? transaction)
        {
            EnlistedTransaction = transaction;
        }

        protected override void Deactivate()
        {
        }

@mdaigle
mdaigle marked this pull request as ready for review August 3, 2026 23:47

// Note: this logic mirrors WaitHandleDbConnectionPool.ReturnObject
ReturnDisposition disposition;
lock (connection)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do some basic locking on the connection to make sure its state doesn't change out from under us.

I have doubts about how this interacts with "TransactionEnded". For now, I want to simply replicate the behavior of the WaitHandleDbConnectionPool. Any improvements to thread safety of transactions can come based on bug reports, in a separate PR, and target both pools.

Base automatically changed from dev/mdaigle/replace-conn-2 to main August 4, 2026 19:09
mdaigle and others added 7 commits August 4, 2026 12:10
Ports the transacted-pool state machine from WaitHandleDbConnectionPool so
the channel pool honors ambient System.Transactions enlistment:

- Implement PutObjectFromTransactedPool and TransactionEnded (previously
  NotImplementedException).
- Rewrite ReturnInternalConnection to mirror DeactivateObject: deactivate
  first, then route the connection to the transacted pool, stasis, the idle
  channel, or destruction under the connection lock.
- Vend connections already enlisted in the ambient transaction via a new
  GetFromTransactedPool helper, and pass the transaction through to
  PrepareConnection/ActivateConnection.
- Set the ambient transaction on the async acquisition path from the
  TaskCompletionSource's AsyncState.
- Guard RemoveConnection against disposing a transaction root that is still
  waiting for its delegated transaction to end.

Adds ChannelDbConnectionPoolTransactionTest mirroring the WaitHandle pool's
transaction test suite, and drops the stale NotImplementedException tests.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The async open path ran GetInternalConnection inside a Task.Run and restored
the ambient transaction by assigning Transaction.Current on that thread pool
thread. That assignment writes to thread-static storage which ExecutionContext
does not unwind, so the transaction outlived the open and was observable by
unrelated work later scheduled onto the same thread -- including the login-time
auto-enlistment that non-pooled connections perform against Transaction.Current.
A try/finally restore is not sufficient either, because the continuation may
resume on a different thread than the one that was polluted.

Instead, capture the ambient transaction on the caller's thread (from the
TaskCompletionSource's AsyncState, which is where SqlConnection.OpenAsync puts
it) and thread it explicitly through GetInternalConnection into
GetFromTransactedPool and PrepareConnection. The sync path passes
ADP.GetCurrentTransaction() directly since it runs on the caller's thread.

Also gate the transaction on HasTransactionAffinity in one place so a pool
without automatic enlistment neither reads from nor writes to the transacted
store.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Source:
- Flesh out the PutObjectFromTransactedPool asserts to say what invariant is
  being violated and why it matters.
- Replace the five-way nested branch and three bool flags in
  ReturnInternalConnection with a ReturnDisposition enum and a single
  DecideReturnDisposition helper. The "shutting down", "transaction root with no
  pool" and "no longer poolable" cases all collapse into one reusability test
  followed by "stasis if it's a transaction root, otherwise destroy", which
  removes the need for the postcondition assert entirely.
- Move the transacted-store lookup inside the acquisition loop so a connection
  returned to the store while we were looping is preferred over opening a fresh
  one. It breaks out of the loop to keep skipping the idle/generation gate,
  which must not apply to a transacted connection.
- Document why a parked transacted connection is exempt from idle timeout and
  when its idle clock actually starts.

Tests:
- Rewrite the suite around specific behaviors: 16 focused tests replacing 33.
  Dropped the loop-driven stress tests (alternating commit/rollback, mixed
  workloads, deeply nested scopes, pool saturation, the flaky two-thread test)
  and the granular cases that were covered by a round trip.
- Name the tests after the operation whose behavior they pin down, and document
  what invariant each one protects.
- Assert full pool accounting (Count / IdleCount / transacted connections) at
  every step via AssertPoolState rather than spot-checking one collection.
- Inject a frozen FakeTimeProvider so background maintenance cannot race the
  assertions.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ess probe

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The async path only read the transaction out of the TaskCompletionSource's
AsyncState, so a caller whose ambient transaction did flow (a TransactionScope
created with TransactionScopeAsyncFlowOption.Enabled) but who supplied no
AsyncState got no enlistment at all, while the same caller on the sync path did.
Fall back to ADP.GetCurrentTransaction(), still read on the caller's thread
before the open is scheduled, so both paths agree on the caller's transaction.
AsyncState keeps priority because SqlConnection.OpenAsync captures it at the
point of the call, which stays correct when a retry re-enters from a
continuation on another thread.

Test the ambient-transaction leak directly instead of inferring it from thread
pool reuse: the mock connection factory runs on exactly the thread the pool does
its open work on, so it now records that thread's id and ambient transaction.
The test asserts the open really happened off the calling thread and that the
pool left that thread's Transaction.Current null while still enlisting the
connection. Both this and the new async ambient test were verified to fail when
the corresponding defect is reintroduced.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Reverts the ADP.GetCurrentTransaction() fallback added in 41a689d. It was
both unreachable and unsafe:

- SqlConnection.InternalOpenAsync is the only site repo-wide that constructs
  a TaskCompletionSource<DbConnectionInternal>, and it always captures the
  ambient transaction into AsyncState. OpenAsyncRetry.Retry re-enters TryOpen
  with that same TCS, so AsyncState survives retries. The fallback could never
  fire in production.
- Reading Transaction.Current here is thread-sensitive in exactly the way the
  rest of this change set set out to eliminate: on a retry we are re-entered
  from a continuation on an arbitrary thread, so we could enlist in an
  unrelated ambient transaction.

The async equivalent of the sync ambient-transaction test now exercises the
real mechanism instead: the GetConnectionAsync helper mirrors
InternalOpenAsync by capturing the caller's ambient transaction into
AsyncState, and the test asserts enlistment from inside a TransactionScope.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds GetConnectionAsync_WithAsyncFlowDisabled_StillEnlistsInAmbientTransaction:
a TransactionScope created with the default TransactionScopeAsyncFlowOption
.Suppress keeps its transaction in thread-static storage, so it is ambient on
the caller's thread but does not flow to the thread pool thread the pool opens
on. The connection must still enlist. The open is started inside the scope and
awaited outside it, because a suppressed scope must be disposed on the thread
that created it; an explicit CommittableTransaction keeps the transaction alive
past the scope so the pool is still enlisting in a live transaction.

Also restores the retry-path coverage as
GetConnectionAsync_EnteredFromThreadWithoutAmbientTransaction_EnlistsFromAsyncState.
This is not redundant: TryGetConnection reads AsyncState while still on the
caller's thread, so in the scope-based tests Transaction.Current happens to
agree with AsyncState. Only entering the pool from a thread with no ambient
transaction -- as OpenAsyncRetry.Retry does -- distinguishes the two.

Verified by mutation: replacing the AsyncState read with null fails 5 tests,
and replacing it with ADP.GetCurrentTransaction() fails only the retry-path
and no-leak tests.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 4, 2026 19:12
@mdaigle
mdaigle force-pushed the dev/automation/channel-pool-transactions branch from b25ac8e to d7ea9d3 Compare August 4, 2026 19:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: To triage

Development

Successfully merging this pull request may close these issues.

4 participants