diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs index 3c6347da54..2feab5faaf 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs @@ -5,6 +5,7 @@ using System.Collections.Concurrent; using System.Collections.ObjectModel; using System.Data.Common; +using System.Diagnostics; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Channels; @@ -250,6 +251,17 @@ public ConcurrentDictionary< private int MinPoolSize => PoolGroupOptions.MinPoolSize; + /// + /// Indicates whether the pool automatically enlists connections in the ambient transaction. + /// When disabled, the ambient transaction is neither used to consult the + /// nor handed to activation. + /// + /// This governs the ambient transaction only. A caller may still enlist explicitly via + /// SqlConnection.EnlistTransaction, and such a connection is parked in the transacted store + /// on return regardless of this flag, since it is bound to a transaction either way. + /// + private bool HasTransactionAffinity => PoolGroupOptions.HasTransactionAffinity; + /// /// The most recently launched warmup/replenishment loop task, exposed so tests can await a /// warmup pass to a deterministic completion instead of polling pool counters. May be null @@ -313,7 +325,38 @@ public void Clear() /// public void PutObjectFromTransactedPool(DbConnectionInternal connection) { - throw new NotImplementedException(); + Debug.Assert(connection.EnlistedTransaction is null, + "PutObjectFromTransactedPool was called with a connection that is still enlisted. " + + "The transaction must have ended and been detached before the connection returns to " + + "general circulation, otherwise it could be vended to a caller in a different transaction."); + + // Called by the transacted connection pool once it has removed the connection from its + // list. We put the connection back into general circulation. + // + // NOTE: no locking is required here because if we're in this method we can safely + // presume that the caller is the only one using the connection, that all pre-push logic + // has been done, and that all transactions have ended. + if (State is Running && connection.CanBePooled) + { + SqlClientEventSource.Log.TryPoolerTraceEvent( + " {0}, Connection {1}, Transaction has ended; returning connection to pool.", + Id, + connection.ObjectID); + + connection.ResetConnection(); + PutConnectionInIdleChannel(connection); + } + else + { + SqlClientEventSource.Log.TryPoolerTraceEvent( + " {0}, Connection {1}, Transaction has ended; destroying unpoolable connection.", + Id, + connection.ObjectID); + + // RemoveConnection triggers replenishment, which is the channel pool's equivalent + // of the wait handle pool's QueuePoolCreateRequest. + RemoveConnection(connection); + } } /// @@ -331,8 +374,15 @@ public DbConnectionInternal ReplaceConnection( if (newConnection is not null) { - // TODO: Full transaction enlistment support (Story 2). + // Carry the old connection's enlistment over to the replacement so that a connection + // replaced mid-transaction stays bound to the same transaction. PrepareConnection(owningObject, newConnection, oldConnection.EnlistedTransaction); + + // newConnection came from the idle channel, so it already holds a slot of its own. + // Releasing oldConnection's slot here keeps the pool's count accurate. This is + // deliberately different from the create-new branch below, which hands oldConnection's + // slot to the replacement via _connectionSlots.TryReplace and therefore only disposes + // it -- calling RemoveConnection there would signal a free slot that does not exist. oldConnection.DeactivateConnection(); RemoveConnection(oldConnection); } @@ -373,7 +423,8 @@ public DbConnectionInternal ReplaceConnection( newConnection.PostPop(owningObject); } - // TODO: Full transaction enlistment support (Story 2). + // Carry the old connection's enlistment over to the replacement so that a + // connection replaced mid-transaction stays bound to the same transaction. newConnection.ActivateConnection(oldConnection.EnlistedTransaction); // Place new into old's slot @@ -422,12 +473,135 @@ public void ReturnInternalConnection(DbConnectionInternal connection, DbConnecti { ValidateOwnershipAndSetPoolingState(connection, owningObject); + SqlClientEventSource.Log.TryPoolerTraceEvent( + " {0}, Connection {1}, Deactivating.", + Id, + connection.ObjectID); + + // Deactivate before inspecting the connection, because DeactivateConnection mutates both + // of the gates we branch on below: + // - Deactivate() dooms the connection when async commands are still outstanding, which + // the IsConnectionDoomed check must observe. + // - DeactivateConnection dooms it via DoNotPoolThisConnection when the load-balance + // timeout has elapsed, which the CanBePooled check must observe. + // WaitHandleDbConnectionPool.DeactivateObject orders it the same way. + connection.DeactivateConnection(); + + if (connection.IsConnectionDoomed) + { + // The connection is not fit for reuse -- just dispose of it. + RemoveConnection(connection); + return; + } + + // Note: this logic mirrors WaitHandleDbConnectionPool.ReturnObject, minus one dead + // branch. Its Running path also checks IsTransactionRoot && Pool == null -> SetInStasis, + // under its own "how did we get here if the pool is null?" TODO. A connection cannot + // arrive here without a pool, because this method is called through the connection's own + // Pool reference. The branch is redundant in any case: putting a transaction root in + // stasis is exactly what the first case below does when the connection cannot be pooled. + ReturnDisposition disposition; + lock (connection) + { + if (State is not Running || !connection.CanBePooled) + { + // A transaction root that cannot be pooled must be put in stasis rather than + // closed. Closing it would orphan the root transaction with no means to promote + // itself to a full delegated transaction, or to commit or roll back. + // System.Transactions keeps the connection owned (not lost) and is certain to + // call the appropriate callback when the transaction ends. + if (connection.IsTransactionRoot) + { + connection.SetInStasis(); + disposition = ReturnDisposition.HeldByTransaction; + } + else + { + disposition = ReturnDisposition.Destroy; + } + } + else if (connection.EnlistedTransaction is { } transaction) + { + // A connection that is still enlisted cannot be handed to a different customer + // until its transaction actually completes, so it is parked in the transacted + // store keyed by that transaction and comes back via + // PutObjectFromTransactedPool when it ends. + // + // Transacted connections are deliberately not stamped with a returned time: + // they are never proactively closed (doing so would abort a possibly + // distributed transaction), so idle-timeout enforcement does not apply while + // they are parked. They are stamped when they rejoin the idle channel in + // PutObjectFromTransactedPool. + TransactedConnectionPool.PutTransactedObject(transaction, connection); + disposition = ReturnDisposition.HeldByTransaction; + } + else + { + disposition = ReturnDisposition.Reuse; + } + } + + switch (disposition) + { + case ReturnDisposition.Reuse: + PutConnectionInIdleChannel(connection); + break; + + case ReturnDisposition.Destroy: + RemoveConnection(connection); + break; + + case ReturnDisposition.HeldByTransaction: + // Nothing further to do. The connection is parked in the transacted store or + // in stasis, and comes back through PutObjectFromTransactedPool once its + // transaction ends. + break; + } + } + + /// + /// The outcome of evaluating a connection that is being returned to the pool. + /// + private enum ReturnDisposition + { + /// + /// The connection is fit for general reuse and belongs in the idle channel. + /// + Reuse, + + /// + /// The connection cannot be reused and must be closed. + /// + Destroy, + + /// + /// The connection is owned by a live transaction, either parked in the transacted + /// store or held in stasis, and must not be touched by the pool until that + /// transaction ends. + /// + HeldByTransaction, + } + + /// + /// Places a connection that is fit for general reuse into the idle channel, stamping its + /// idle-return time and dropping it if it is no longer live. + /// + /// The connection to make available to other callers. + private void PutConnectionInIdleChannel(DbConnectionInternal connection) + { // Stamp the return time before IsLiveConnection runs so the idle-expiry gate inside it // measures time-in-pool, not time-since-last-return. Without this, a connection whose // checkout exceeded IdleTimeout (e.g. a long-running query) would be wrongly evicted on // return even though it was actively in use on the wire. The same gating conditions are // applied here as in IsLiveConnection so we avoid the per-return timestamp read when // idle expiry is disabled or the legacy idle-timeout behavior is in effect. + // + // A connection parked in the transacted store does not pass through here, so it is not + // subject to idle timeout for as long as its transaction is live. That is intentional + // and matches WaitHandleDbConnectionPool: closing it would abort a possibly distributed + // transaction. It is stamped here when the transaction ends and + // PutObjectFromTransactedPool returns it to general circulation, so the idle clock + // starts from the moment it actually becomes available to other callers. if (!LocalAppContextSwitches.UseLegacyIdleTimeoutBehavior && PoolGroupOptions.IdleTimeout != TimeSpan.Zero) { @@ -440,27 +614,12 @@ public void ReturnInternalConnection(DbConnectionInternal connection, DbConnecti return; } - SqlClientEventSource.Log.TryPoolerTraceEvent( - " {0}, Connection {1}, Deactivating.", - Id, - connection.ObjectID); - connection.DeactivateConnection(); - - if (connection.IsConnectionDoomed || - !connection.CanBePooled || - State == ShuttingDown) + if (!_idleChannel.TryWrite(connection)) { + // The channel has been completed (pool is shutting down). Race window + // between the State check by the caller and TryWrite: destroy instead of pooling. RemoveConnection(connection); } - else - { - if (!_idleChannel.TryWrite(connection)) - { - // The channel has been completed (pool is shutting down). Race window - // between the State check above and TryWrite: destroy instead of pooling. - RemoveConnection(connection); - } - } } /// @@ -614,7 +773,26 @@ public void Startup() /// public void TransactionEnded(Transaction transaction, DbConnectionInternal transactedObject) { - throw new NotImplementedException(); + // Note: the connection may still be associated with the transaction due to the explicit + // unbinding requirement. + SqlClientEventSource.Log.TryPoolerTraceEvent( + " {0}, Transaction {1}, Connection {2}, Transaction Completed", + Id, + transaction.GetHashCode(), + transactedObject.ObjectID); + + // Removal from the transacted list happens synchronously inside this call, and + // TransactedConnectionPool.TransactionEnded calls back into PutObjectFromTransactedPool + // itself to return the connection to general circulation. + // + // We deliberately do not call PutObjectFromTransactedPool ourselves afterwards: that + // callback is conditional on the connection actually having been found in the list. + // A transaction can complete while the application still holds the connection, in which + // case the connection was never parked, and returning it here would hand a connection + // that is still in use to another caller. In that case the connection instead reaches + // the pool through the normal ReturnInternalConnection path when it is closed. + // This mirrors WaitHandleDbConnectionPool.TransactionEnded. + TransactedConnectionPool.TransactionEnded(transaction, transactedObject); } /// @@ -640,10 +818,12 @@ public bool TryGetConnection( // If taskCompletionSource is null, we are in a sync context. if (taskCompletionSource is null) { + // We're on the caller's thread, so the ambient transaction is directly observable. var task = GetInternalConnection( owningObject, async: false, - timeout); + timeout, + ADP.GetCurrentTransaction()); // When running synchronously, we are guaranteed that the task is already completed. // We don't need to guard the managed threadpool at this spot because we pass the async flag as false @@ -672,6 +852,32 @@ public bool TryGetConnection( // OpenAsync call. This means that we cannot cancel the connection open operation if the caller's token // is cancelled. We can only cancel based on our own timeout, which is set to the owningObject's // ConnectionTimeout. + // + // The ambient transaction is captured by the caller, on the caller's thread, and handed + // to us in the TaskCompletionSource's AsyncState (see SqlConnection.InternalOpenAsync). + // + // We must not read Transaction.Current inside the Task.Run below. A + // TransactionScope created with TransactionScopeAsyncFlowOption.Enabled stores the + // transaction in an AsyncLocal, which does flow onto the pool's worker thread, so that + // would appear to work. But Enabled is not the default: a plain TransactionScope keeps + // the transaction in thread-static storage, which does not flow, and reading + // Transaction.Current on the worker would silently fail to enlist. The WaitHandle pool + // enlists correctly in that case, so this is also a compatibility requirement. + // AsyncState is correct under both options. + // + // This does not make the suppressed-flow pattern work -- the caller's own scope is + // still broken past the first await -- but it keeps the connection in the transaction + // the caller intended rather than silently running outside it. + // + // Note that we deliberately do not assign Transaction.Current on the thread pool + // thread either. That assignment writes to thread-static storage which is *not* unwound + // when the ExecutionContext is restored, so it would outlive this open and be observed + // by unrelated work later scheduled onto the same thread pool thread -- including the + // login-time auto-enlistment that non-pooled connections perform against + // Transaction.Current. The WaitHandle pool can get away with assigning it because it + // processes pending opens on a dedicated non-thread-pool thread. + Transaction? ambientTransaction = taskCompletionSource.Task.AsyncState as Transaction; + Task.Run(async () => { if (taskCompletionSource.Task.IsCompleted) @@ -679,10 +885,6 @@ public bool TryGetConnection( return; } - // We're potentially on a new thread, so we need to properly set the ambient transaction. - // We rely on the caller to capture the ambient transaction in the TaskCompletionSource's AsyncState - // so that we can access it here. Read: area for improvement. - // TODO: ADP.SetCurrentTransaction(taskCompletionSource.Task.AsyncState as Transaction); DbConnectionInternal? connection = null; try @@ -690,7 +892,8 @@ public bool TryGetConnection( connection = await GetInternalConnection( owningObject, async: true, - timeout + timeout, + ambientTransaction ).ConfigureAwait(false); if (!taskCompletionSource.TrySetResult(connection)) @@ -949,6 +1152,19 @@ private bool IsLiveConnection(DbConnectionInternal connection) /// The connection to be closed. private void RemoveConnection(DbConnectionInternal connection) { + // A connection with a delegated transaction cannot be disposed of until the delegated + // transaction has actually completed; disposing it would abort the (possibly + // distributed) transaction. Leave it alone: when the transaction completes it comes + // back through PutObjectFromTransactedPool, which calls us again. + if (connection.IsTxRootWaitingForTxEnd) + { + SqlClientEventSource.Log.TryPoolerTraceEvent( + " {0}, Connection {1}, Has Delegated Transaction, waiting to Dispose.", + Id, + connection.ObjectID); + return; + } + _connectionSlots.TryRemove(connection); // Removing a connection from the pool opens a free slot. @@ -1003,6 +1219,10 @@ private void RemoveConnection(DbConnectionInternal connection) /// A boolean indicating whether the operation should be asynchronous. /// The overall timeout budget for this connection request. Time spent waiting /// in the pool is deducted from the budget available for physical connection creation. + /// The ambient transaction captured on the caller's thread, or + /// null when the caller is not inside a transaction. It is passed explicitly rather than read + /// from because this method may run on a thread pool thread + /// that the ambient transaction does not flow to. /// Returns a DbConnectionInternal that is retrieved from the pool. /// /// Thrown when an OperationCanceledException is caught, indicating that the timeout period @@ -1015,20 +1235,46 @@ private void RemoveConnection(DbConnectionInternal connection) private async Task GetInternalConnection( DbConnection owningConnection, bool async, - TimeoutTimer timeout) + TimeoutTimer timeout, + Transaction? ambientTransaction) { DbConnectionInternal? connection = null; + // When automatic enlistment is disabled, the connection must never be bound to the + // ambient transaction, so we neither consult the transacted store nor hand the + // transaction to activation. HasTransactionAffinity is derived from the connection + // string's Enlist keyword. + Transaction? transaction = HasTransactionAffinity ? ambientTransaction : null; + // 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; - // Continue looping until we create or retrieve a connection - do + // Continue looping until we create or retrieve a connection. + while (connection is null) { try { + // A connection already enlisted in our transaction is always preferred, since + // reusing it avoids promoting the transaction to a distributed one. This is + // re-checked on every iteration so that a connection returned to the transacted + // store while we were looping is picked up rather than being passed over in + // favor of a fresh connection. + if (transaction is not null) + { + connection = GetFromTransactedPool(transaction); + if (connection is not null) + { + // Skip the liveness/idle/generation gate at the bottom of the loop: + // GetFromTransactedPool has already probed liveness, and a transacted + // connection is exempt from idle-timeout, load-balance and + // clear-generation eviction because closing it would abort its + // (possibly distributed) transaction. + break; + } + } + // Optimistically try to get an idle connection from the channel // Doesn't wait if the channel is empty, just returns null. connection ??= GetIdleConnection(); @@ -1070,9 +1316,8 @@ private async Task GetInternalConnection( connection = null; } } - while (connection is null); - PrepareConnection(owningConnection, connection); + PrepareConnection(owningConnection, connection, transaction); return connection; } @@ -1141,6 +1386,55 @@ private void PrepareConnection(DbConnection owningObject, DbConnectionInternal c } } + /// + /// Attempts to retrieve a connection that is already enlisted in the given transaction. + /// + /// The transaction the connection must already be enlisted in. + /// A live connection already enlisted in the transaction, or null. + private DbConnectionInternal? GetFromTransactedPool(Transaction transaction) + { + DbConnectionInternal? connection = TransactedConnectionPool.GetTransactedObject(transaction); + if (connection is null) + { + return null; + } + + SqlClientEventSource.Log.TryPoolerTraceEvent( + " {0}, Transaction {1}, Connection {2}, Popped from transacted pool.", + Id, + transaction.GetHashCode(), + connection.ObjectID); + + SqlClientDiagnostics.Metrics.ExitFreeConnection(); + + // Transacting connections are exempt from idle-timeout and clear-generation eviction + // (closing them would abort the transaction, which may be distributed), so only + // liveness is checked here rather than the full IsLiveConnection gate. + bool isAlive = false; + try + { + // A dead transaction root must surface the underlying failure to the caller, since + // there is no way to recover the delegated transaction on another connection. Any + // other dead connection is simply reported so the caller can pick up or open a + // different one. Either way the connection is dropped in the finally below. + isAlive = connection.IsConnectionAlive(throwOnException: connection.IsTransactionRoot); + } + finally + { + if (!isAlive) + { + SqlClientEventSource.Log.TryPoolerTraceEvent( + " {0}, Connection {1}, found dead and removed.", + Id, + connection.ObjectID); + RemoveConnection(connection); + connection = null; + } + } + + return connection; + } + /// /// Validates that the connection is owned by the provided DbConnection and that it is in a valid state to be returned to the pool. /// diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs index b3c9815eb8..228e949cd0 100644 --- a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs @@ -888,37 +888,6 @@ public void TestReplaceConnection() #endregion - #region Not Implemented Method Tests - - /// - /// Verifies that remains - /// unimplemented and throws . - /// - [Fact] - public void TestPutObjectFromTransactedPool() - { - // Arrange - var pool = ConstructPool(SuccessfulConnectionFactory); - - // Act & Assert - Assert.Throws(() => pool.PutObjectFromTransactedPool(null!)); - } - - /// - /// Verifies that - /// remains unimplemented and throws . - /// - [Fact] - public void TestTransactionEnded() - { - // Arrange - var pool = ConstructPool(SuccessfulConnectionFactory); - - // Act & Assert - Assert.Throws(() => pool.TransactionEnded(null!, null!)); - } - #endregion - #region Pool Clear Tests /// diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTransactionTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTransactionTest.cs new file mode 100644 index 0000000000..e1d56fa004 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTransactionTest.cs @@ -0,0 +1,916 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using System.Transactions; +using Microsoft.Data.Common.ConnectionString; +using Microsoft.Data.ProviderBase; +using Microsoft.Data.SqlClient.ConnectionPool; +using Microsoft.Extensions.Time.Testing; +using Xunit; + +namespace Microsoft.Data.SqlClient.UnitTests.ConnectionPool; + +/// +/// Tests for transaction affinity: routing a returning +/// connection to the transacted store instead of the idle channel, vending an already-enlisted +/// connection back to the same transaction, releasing the connection when the transaction ends, +/// and flowing the ambient transaction on the asynchronous open path. +/// +public class ChannelDbConnectionPoolTransactionTest : IDisposable +{ + private const int DefaultMaxPoolSize = 50; + private const int DefaultMinPoolSize = 0; + private const int DefaultCreationTimeoutInMilliseconds = 15000; + + private IDbConnectionPool _pool; + private MockSqlConnectionFactory _connectionFactory = null!; + + public ChannelDbConnectionPoolTransactionTest() + { + _pool = CreatePool(); + } + + public void Dispose() + { + // A transaction entry that outlives its transaction is a leak: the connections it holds + // are never returned to general circulation. + Assert.Empty(_pool.TransactedConnectionPool.TransactedConnections); + + _pool.Shutdown(); + _pool.Clear(); + } + + #region Helper Methods + + /// + /// Builds a pool for these tests. A frozen is injected so that + /// time-driven background maintenance (idle-timeout pruning, warmup and replenishment, + /// blocking-period expiry) cannot advance and race the assertions about pool contents. + /// + private ChannelDbConnectionPool CreatePool( + int maxPoolSize = DefaultMaxPoolSize, + int minPoolSize = DefaultMinPoolSize, + bool hasTransactionAffinity = true) + { + var poolGroupOptions = new DbConnectionPoolGroupOptions( + poolByIdentity: false, + minPoolSize: minPoolSize, + maxPoolSize: maxPoolSize, + creationTimeout: DefaultCreationTimeoutInMilliseconds, + loadBalanceTimeout: 0, + hasTransactionAffinity: hasTransactionAffinity, + idleTimeout: 0 + ); + + var dbConnectionPoolGroup = new DbConnectionPoolGroup( + new SqlConnectionOptions("Data Source=localhost;"), + new ConnectionPoolKey("TestDataSource", credential: null, accessToken: null, accessTokenCallback: null, sspiContextProvider: null), + poolGroupOptions + ); + + _connectionFactory = new MockSqlConnectionFactory(); + + var pool = new ChannelDbConnectionPool( + _connectionFactory, + dbConnectionPoolGroup, + DbConnectionPoolIdentity.NoIdentity, + new DbConnectionPoolProviderInfo(), + timeProvider: new FakeTimeProvider() + ); + + pool.Startup(); + return pool; + } + + /// + /// Tears down the pool built by the constructor and replaces it with one configured + /// differently, for the few tests that need non-default pool options. + /// + private void ReplaceFixturePool(bool hasTransactionAffinity) + { + _pool.Shutdown(); + _pool.Clear(); + _pool = CreatePool(hasTransactionAffinity: hasTransactionAffinity); + } + + /// + /// Opens a connection synchronously. The pool reads the ambient transaction off the calling + /// thread on this path. + /// + private DbConnectionInternal GetConnection(SqlConnection owner) + { + _pool.TryGetConnection( + owner, + taskCompletionSource: null, + TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), + out DbConnectionInternal? connection); + return connection!; + } + + /// + /// Opens a connection asynchronously the way SqlConnection.InternalOpenAsync does: the ambient + /// transaction is captured here, on the caller's thread, and handed to the pool in the + /// 's AsyncState. The pool must take it from there, + /// because the open itself runs on a thread pool thread the ambient transaction may not flow + /// to, and on a retry the pool is re-entered from a continuation on an arbitrary thread. + /// + /// The owning connection. + /// The transaction to hand to the pool. Defaults to the caller's + /// ambient transaction, matching what InternalOpenAsync captures. + private async Task GetConnectionAsync( + SqlConnection owner, + Transaction? transaction = null) + { + var tcs = new TaskCompletionSource(transaction ?? Transaction.Current); + _pool.TryGetConnection( + owner, + taskCompletionSource: tcs, + TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), + out DbConnectionInternal? connection); + return connection ?? await tcs.Task; + } + + private void ReturnConnection(DbConnectionInternal connection, SqlConnection owner) => + _pool.ReturnInternalConnection(connection, owner); + + /// + /// Asserts the pool's accounting after a step. + /// + /// Total connections owned by the pool, checked out or not. A connection + /// parked in the transacted store still holds its pool slot and so is counted here. + /// Connections sitting in the idle channel, available to any caller. + /// Connections parked in the transacted store across all + /// transactions. These hold a pool slot but are not available to other callers. + private void AssertPoolState(int count, int idleCount, int transactedCount) + { + Assert.Equal(count, _pool.Count); + Assert.Equal(idleCount, _pool.IdleCount); + Assert.Equal(transactedCount, TotalTransactedConnections()); + } + + private int TotalTransactedConnections() + { + int total = 0; + foreach (var entry in _pool.TransactedConnectionPool.TransactedConnections) + { + total += entry.Value.Count; + } + return total; + } + + private int TransactedConnectionsFor(Transaction transaction) => + _pool.TransactedConnectionPool.TransactedConnections.TryGetValue(transaction, out var connections) + ? connections.Count + : 0; + + /// + /// Asserts which transaction a connection is bound to, complementing the pool-state assertions: + /// AssertPoolState says where the connection was filed, this says what it is actually enlisted + /// in. Pass null to assert the connection is not enlisted at all. + /// + /// + /// Compared by equality rather than reference because the EnlistedTransaction setter stores a + /// clone, so that the connection does not hold the caller's transaction past the end of its + /// using block. + /// + private static void AssertEnlistedIn(Transaction? expected, DbConnectionInternal connection) => + Assert.Equal(expected, connection.EnlistedTransaction); + + #endregion + + #region Connection Return Routing + + /// + /// A connection returned while still enlisted must be parked in the transacted store rather + /// than the idle channel, so it cannot be vended to a caller in a different transaction. It + /// keeps its pool slot while parked. + /// + [Fact] + public void ReturnConnection_WhileEnlisted_ParksInTransactedStoreNotIdleChannel() + { + // Arrange + using var scope = new TransactionScope(); + Transaction? transaction = Transaction.Current; + Assert.NotNull(transaction); + + var owner = new SqlConnection(); + var connection = GetConnection(owner); + Assert.NotNull(connection); + AssertEnlistedIn(transaction, connection); + AssertPoolState(count: 1, idleCount: 0, transactedCount: 0); + + // Act + ReturnConnection(connection, owner); + + // Assert + AssertPoolState(count: 1, idleCount: 0, transactedCount: 1); + Assert.Equal(1, TransactedConnectionsFor(transaction!)); + + // The connection stays bound to the transaction while parked, which is what makes it + // ineligible for a caller in any other transaction. + AssertEnlistedIn(transaction, connection); + + scope.Complete(); + } + + /// + /// Without an ambient transaction there is nothing to enlist in, so a returning connection + /// goes straight back into the idle channel. + /// + [Fact] + public void ReturnConnection_WithoutTransaction_ReturnsToIdleChannel() + { + // Arrange + var owner = new SqlConnection(); + var connection = GetConnection(owner); + Assert.NotNull(connection); + AssertEnlistedIn(null, connection); + AssertPoolState(count: 1, idleCount: 0, transactedCount: 0); + + // Act + ReturnConnection(connection, owner); + + // Assert + AssertEnlistedIn(null, connection); + AssertPoolState(count: 1, idleCount: 1, transactedCount: 0); + } + + /// + /// A connection whose transaction has already ended must land in the idle channel, not be + /// parked under a dead transaction where nothing would ever release it. The enlistment is + /// dropped when the transaction completes, so by the time the connection is returned there is + /// nothing left to park it under. + /// + [Fact] + public void ReturnConnection_AfterTransactionCompleted_ReturnsToIdleChannel() + { + // Arrange + var owner = new SqlConnection(); + DbConnectionInternal connection; + using (var scope = new TransactionScope()) + { + connection = GetConnection(owner); + Assert.NotNull(connection); + AssertEnlistedIn(Transaction.Current, connection); + scope.Complete(); + } + + // The completed transaction detached itself from the connection. + AssertEnlistedIn(null, connection); + + // Act - the transaction is fully disposed by this point. + ReturnConnection(connection, owner); + + // Assert + AssertPoolState(count: 1, idleCount: 1, transactedCount: 0); + } + + /// + /// A pool with automatic enlistment disabled must never bind a connection to the ambient + /// transaction, so the transacted store stays out of the picture entirely. + /// + [Fact] + public void ReturnConnection_WithTransactionAffinityDisabled_ReturnsToIdleChannel() + { + // Arrange + ReplaceFixturePool(hasTransactionAffinity: false); + + using var scope = new TransactionScope(); + var owner = new SqlConnection(); + var connection = GetConnection(owner); + Assert.NotNull(connection); + + // The ambient transaction exists but must not reach the connection. + Assert.NotNull(Transaction.Current); + AssertEnlistedIn(null, connection); + + // Act + ReturnConnection(connection, owner); + + // Assert + AssertEnlistedIn(null, connection); + AssertPoolState(count: 1, idleCount: 1, transactedCount: 0); + + scope.Complete(); + } + + /// + /// Returning to a pool that has already shut down destroys the connection instead of pooling + /// it, and must not throw. + /// + [Fact] + public void ReturnConnection_ToShutDownPool_DestroysConnection() + { + // Arrange + using var scope = new TransactionScope(); + var owner = new SqlConnection(); + var connection = GetConnection(owner); + Assert.NotNull(connection); + + _pool.Shutdown(); + + // Act + ReturnConnection(connection, owner); + + // Assert - Dispose() dooms the connection and clears its pool back-reference. + Assert.True(connection.IsConnectionDoomed, + "A connection returned to a shut-down pool should be destroyed, not pooled."); + Assert.Null(connection.Pool); + AssertPoolState(count: 0, idleCount: 0, transactedCount: 0); + } + + #endregion + + #region Vending From The Transacted Store + + /// + /// Round trip: a second request inside the same transaction must be served the connection that + /// is already enlisted in it, rather than a fresh connection. Reusing it is what keeps the + /// transaction from being promoted to a distributed one. + /// + [Fact] + public void GetConnection_UnderSameTransaction_VendsTheAlreadyEnlistedConnection() + { + // Arrange + using var scope = new TransactionScope(); + Transaction? transaction = Transaction.Current; + Assert.NotNull(transaction); + + var owner1 = new SqlConnection(); + var connection1 = GetConnection(owner1); + Assert.NotNull(connection1); + AssertPoolState(count: 1, idleCount: 0, transactedCount: 0); + + ReturnConnection(connection1, owner1); + AssertPoolState(count: 1, idleCount: 0, transactedCount: 1); + + // Act + var owner2 = new SqlConnection(); + var connection2 = GetConnection(owner2); + + // Assert + Assert.Same(connection1, connection2); + AssertEnlistedIn(transaction, connection2); + AssertPoolState(count: 1, idleCount: 0, transactedCount: 0); + + ReturnConnection(connection2, owner2); + AssertPoolState(count: 1, idleCount: 0, transactedCount: 1); + Assert.Equal(1, TransactedConnectionsFor(transaction!)); + + scope.Complete(); + } + + /// + /// The asynchronous path must reuse the enlisted connection exactly as the synchronous path + /// does, even though it runs the open on a thread pool thread. + /// + [Fact] + public async Task GetConnectionAsync_UnderSameTransaction_VendsTheAlreadyEnlistedConnection() + { + // Arrange + using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled); + Transaction? transaction = Transaction.Current; + Assert.NotNull(transaction); + + var owner1 = new SqlConnection(); + var connection1 = await GetConnectionAsync(owner1, transaction); + Assert.NotNull(connection1); + AssertEnlistedIn(transaction, connection1); + ReturnConnection(connection1, owner1); + AssertPoolState(count: 1, idleCount: 0, transactedCount: 1); + + // Act + var owner2 = new SqlConnection(); + var connection2 = await GetConnectionAsync(owner2, transaction); + + // Assert + Assert.Same(connection1, connection2); + AssertEnlistedIn(transaction, connection2); + AssertPoolState(count: 1, idleCount: 0, transactedCount: 0); + + ReturnConnection(connection2, owner2); + AssertPoolState(count: 1, idleCount: 0, transactedCount: 1); + + scope.Complete(); + } + + /// + /// A connection enlisted in one transaction must never be handed to a caller in a different + /// transaction; each transaction gets its own entry in the transacted store. + /// + [Fact] + public void GetConnection_UnderDifferentTransaction_DoesNotVendTheEnlistedConnection() + { + // Arrange + using var outerScope = new TransactionScope(); + Transaction? outerTransaction = Transaction.Current; + Assert.NotNull(outerTransaction); + + var owner1 = new SqlConnection(); + var connection1 = GetConnection(owner1); + AssertEnlistedIn(outerTransaction, connection1); + ReturnConnection(connection1, owner1); + AssertPoolState(count: 1, idleCount: 0, transactedCount: 1); + + // Act - RequiresNew starts an unrelated transaction. + Transaction? innerTransaction; + using (var innerScope = new TransactionScope(TransactionScopeOption.RequiresNew)) + { + innerTransaction = Transaction.Current; + Assert.NotEqual(outerTransaction, innerTransaction); + + var owner2 = new SqlConnection(); + var connection2 = GetConnection(owner2); + + // Assert - a fresh connection, because connection1 belongs to the outer transaction. + Assert.NotSame(connection1, connection2); + AssertEnlistedIn(innerTransaction, connection2); + AssertEnlistedIn(outerTransaction, connection1); + AssertPoolState(count: 2, idleCount: 0, transactedCount: 1); + + // The store holds only the outer transaction's connection, so connection2 was newly + // created rather than taken from the store. + Assert.Equal(1, TransactedConnectionsFor(outerTransaction!)); + Assert.Equal(0, TransactedConnectionsFor(innerTransaction!)); + + ReturnConnection(connection2, owner2); + AssertPoolState(count: 2, idleCount: 0, transactedCount: 2); + Assert.Equal(1, TransactedConnectionsFor(outerTransaction!)); + Assert.Equal(1, TransactedConnectionsFor(innerTransaction!)); + + innerScope.Complete(); + } + + // Completing the inner transaction releases only its connection; the outer transaction's + // connection stays parked. + AssertPoolState(count: 2, idleCount: 1, transactedCount: 1); + Assert.Equal(1, TransactedConnectionsFor(outerTransaction!)); + Assert.Equal(0, TransactedConnectionsFor(innerTransaction!)); + + outerScope.Complete(); + } + + #endregion + + #region Transaction Completion + + /// + /// Committing releases the parked connection back into the idle channel, where it becomes + /// available to any caller. + /// + [Fact] + public void TransactionCommit_ReturnsParkedConnectionToIdleChannel() + { + // Arrange + DbConnectionInternal connection; + using (var scope = new TransactionScope()) + { + var owner = new SqlConnection(); + connection = GetConnection(owner); + ReturnConnection(connection, owner); + AssertPoolState(count: 1, idleCount: 0, transactedCount: 1); + + // Act + scope.Complete(); + } + + // Assert + AssertPoolState(count: 1, idleCount: 1, transactedCount: 0); + + // The transaction released it, so it carries no enlistment into general circulation. + AssertEnlistedIn(null, connection); + + // The released connection is reusable by a caller with no ambient transaction. + var owner2 = new SqlConnection(); + var connection2 = GetConnection(owner2); + Assert.Same(connection, connection2); + AssertEnlistedIn(null, connection2); + ReturnConnection(connection2, owner2); + } + + /// + /// Rolling back must release the parked connection just as committing does; otherwise an + /// aborted transaction would strand its connection in the transacted store forever. + /// + [Fact] + public void TransactionRollback_ReturnsParkedConnectionToIdleChannel() + { + // Arrange + DbConnectionInternal connection; + using (new TransactionScope()) + { + var owner = new SqlConnection(); + connection = GetConnection(owner); + ReturnConnection(connection, owner); + AssertPoolState(count: 1, idleCount: 0, transactedCount: 1); + + // Act - leaving the scope without calling Complete rolls the transaction back. + } + + // Assert + AssertPoolState(count: 1, idleCount: 1, transactedCount: 0); + AssertEnlistedIn(null, connection); + } + + /// + /// A connection parked in the transacted store survives pool shutdown, because closing it + /// would abort a possibly distributed transaction. Once that transaction ends, the shut-down + /// pool must destroy the connection rather than return it to circulation. + /// + [Fact] + public void TransactionCompletion_AfterShutdown_DestroysConnection() + { + // Arrange + DbConnectionInternal connection; + using (var scope = new TransactionScope()) + { + var owner = new SqlConnection(); + connection = GetConnection(owner); + ReturnConnection(connection, owner); + AssertPoolState(count: 1, idleCount: 0, transactedCount: 1); + + // Act - the shutdown drain must leave the transacted connection alone. + _pool.Shutdown(); + AssertPoolState(count: 1, idleCount: 0, transactedCount: 1); + + scope.Complete(); + } + + // Assert + AssertPoolState(count: 0, idleCount: 0, transactedCount: 0); + Assert.True(connection.IsConnectionDoomed, + "A shut-down pool must destroy a connection released by its transaction."); + } + + /// + /// TransactionEnded on its own, with none of the surrounding lifecycle events, must be a no-op + /// for a connection that was never parked. Only parking hands the connection to the pool, so + /// even an enlisted connection is still checked out here, and pushing it into the idle channel + /// would let a second caller pick it up while the first is still using it. + /// + [Fact] + public void TransactionEnded_ForEnlistedConnectionThatWasNeverParked_LeavesItCheckedOut() + { + // Arrange + using var scope = new TransactionScope(); + Transaction? transaction = Transaction.Current; + Assert.NotNull(transaction); + + var owner = new SqlConnection(); + var connection = GetConnection(owner); + Assert.NotNull(connection); + + // The connection is enlisted, but checked out rather than parked. + AssertEnlistedIn(transaction, connection); + AssertPoolState(count: 1, idleCount: 0, transactedCount: 0); + + // Act - the completion notification arrives on its own, with no detach and no return. + _pool.TransactionEnded(transaction!, connection); + + // Assert - nothing to release, and the enlistment is untouched. + AssertEnlistedIn(transaction, connection); + AssertPoolState(count: 1, idleCount: 0, transactedCount: 0); + + // It still reaches the pool through the normal return path. + ReturnConnection(connection, owner); + AssertPoolState(count: 1, idleCount: 0, transactedCount: 1); + + scope.Complete(); + } + + /// + /// A transaction can complete while the application still holds its connection. The connection + /// was never parked, so there is nothing for the completion to release, and pushing it into the + /// idle channel would hand a connection that is still in use to a second caller. + /// + [Fact] + public void TransactionCompletes_WhileConnectionStillCheckedOut_LeavesItCheckedOut() + { + // Arrange + var owner = new SqlConnection(); + DbConnectionInternal connection; + using (var scope = new TransactionScope()) + { + connection = GetConnection(owner); + Assert.NotNull(connection); + AssertEnlistedIn(Transaction.Current, connection); + AssertPoolState(count: 1, idleCount: 0, transactedCount: 0); + + // Act - the transaction completes with the connection still checked out. + scope.Complete(); + } + + // Assert + AssertPoolState(count: 1, idleCount: 0, transactedCount: 0); + + // It reaches the pool only when its owner returns it. + ReturnConnection(connection, owner); + AssertPoolState(count: 1, idleCount: 1, transactedCount: 0); + } + + #endregion + + #region Connection Replacement + + /// + /// Replacing a connection mid-transaction must carry the enlistment across, so the replacement + /// is the one that parks in the transacted store when it is returned. + /// + [Fact] + public void ReplaceConnection_CarriesEnlistedTransactionToNewConnection() + { + // Arrange + using var scope = new TransactionScope(); + Transaction? transaction = Transaction.Current; + Assert.NotNull(transaction); + + var owner = new SqlConnection(); + var oldConnection = GetConnection(owner); + Assert.NotNull(oldConnection); + AssertEnlistedIn(transaction, oldConnection); + AssertPoolState(count: 1, idleCount: 0, transactedCount: 0); + + // Act + var newConnection = _pool.ReplaceConnection( + owner, + oldConnection, + TimeoutTimer.StartNew(TimeSpan.FromSeconds(15))); + + // Assert - a distinct connection took over the old connection's slot, carrying the + // enlistment with it. + Assert.NotNull(newConnection); + Assert.NotSame(oldConnection, newConnection); + AssertEnlistedIn(transaction, newConnection); + Assert.True(oldConnection.IsConnectionDoomed); + AssertPoolState(count: 1, idleCount: 0, transactedCount: 0); + + ReturnConnection(newConnection, owner); + + // The replacement inherited the transaction, so it parks in the transacted store. + AssertPoolState(count: 1, idleCount: 0, transactedCount: 1); + Assert.Equal(1, TransactedConnectionsFor(transaction!)); + + scope.Complete(); + } + + #endregion + + #region Async Ambient Transaction Flow + + /// + /// A created without + /// -- the default -- keeps its ambient + /// transaction in thread-static storage, so it does not flow onto the thread pool thread the + /// pool opens on. The connection must still enlist, because the transaction is captured on the + /// caller's thread before the open is scheduled (see SqlConnection.InternalOpenAsync). + /// + /// This is one of three guards on that capture. With Enabled the transaction rides an + /// AsyncLocal and is live on the pool's worker thread, so reading Transaction.Current there + /// would pass every other test in this class -- and silently stop enlisting for the default + /// option, which the WaitHandle pool handles correctly. This case is the realistic shape of + /// that regression; see also + /// + /// and . + /// + /// The open is started inside the scope but awaited outside it. That is not incidental: + /// awaiting inside resumes the continuation on a thread pool thread, and disposing the scope + /// there throws "A TransactionScope must be disposed on the same thread that it was created." + /// That is the very limitation TransactionScopeAsyncFlowOption.Enabled exists to remove, so a + /// test of the suppressed case cannot avoid it. The scope is given an explicit + /// so that leaving the scope ends the scope without + /// ending the transaction the pool is still enlisting in. + /// + [Fact] + public async Task GetConnectionAsync_WithAsyncFlowDisabled_StillEnlistsInAmbientTransaction() + { + // Arrange + using var transaction = new CommittableTransaction(); + var owner = new SqlConnection(); + Task openTask; + + using (var scope = new TransactionScope(transaction)) + { + Assert.Equal(transaction, Transaction.Current); + + // Act - starting the open captures the ambient transaction synchronously, here, and + // hands the rest of the work to a thread pool thread. + openTask = GetConnectionAsync(owner); + scope.Complete(); + } + + var connection = await openTask; + + // Assert + Assert.NotNull(connection); + Assert.Equal(transaction, connection.EnlistedTransaction); + + ReturnConnection(connection, owner); + AssertPoolState(count: 1, idleCount: 0, transactedCount: 1); + Assert.Equal(1, TransactedConnectionsFor(transaction)); + + transaction.Rollback(); + } + + /// + /// The pool must take the transaction from AsyncState rather than from whatever is ambient on + /// the thread that happens to enter it. TryGetConnection is normally called synchronously from + /// the caller's thread, where the two agree -- but on the retry path + /// (SqlConnection.OpenAsyncRetry.Retry) the pool is re-entered from a continuation running on + /// an arbitrary thread, which has no ambient transaction. Reading Transaction.Current there + /// would silently drop the enlistment, or worse, pick up an unrelated transaction. + /// + /// No TransactionScope is opened here, so this thread already stands in for that continuation: + /// the transaction exists but is not ambient anywhere, and AsyncState is the only channel + /// carrying it. + /// + [Fact] + public async Task GetConnectionAsync_EnteredFromThreadWithoutAmbientTransaction_EnlistsFromAsyncState() + { + // Arrange + using var transaction = new CommittableTransaction(); + var owner = new SqlConnection(); + Assert.Null(Transaction.Current); + + // Act - carry the transaction only in AsyncState, the way a retry continuation does. + var connection = await GetConnectionAsync(owner, transaction); + + // Assert + Assert.NotNull(connection); + Assert.Equal(transaction, connection.EnlistedTransaction); + + ReturnConnection(connection, owner); + AssertPoolState(count: 1, idleCount: 0, transactedCount: 1); + Assert.Equal(1, TransactedConnectionsFor(transaction)); + + transaction.Rollback(); + } + + /// + /// The pool must enlist the connection in the caller's transaction without making that + /// transaction ambient on the thread it opens on. Assigning Transaction.Current writes to + /// thread-static storage that 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. + /// + /// The connection factory runs inside the pool's own open work, so it observes what the pool + /// made ambient there. That is an observation of the code under test rather than of whichever + /// thread happened to run it, so it does not depend on thread identity or scheduling. + /// + [Fact] + public async Task GetConnectionAsync_DoesNotSetAmbientTransactionOnPoolWorkerThread() + { + // Arrange - a transaction the pool must enlist in but must not make ambient. + using var transaction = new CommittableTransaction(); + Assert.Null(Transaction.Current); + + // Act + var owner = new SqlConnection(); + var connection = await GetConnectionAsync(owner, transaction); + + // Assert - the pool left the worker's ambient transaction alone while still enlisting the + // connection. + Assert.Equal(1, _connectionFactory.CreateCount); + Assert.Null(_connectionFactory.AmbientTransactionAtCreate); + Assert.Equal(transaction, connection.EnlistedTransaction); + + ReturnConnection(connection, owner); + AssertPoolState(count: 1, idleCount: 0, transactedCount: 1); + + transaction.Rollback(); + } + + /// + /// The synchronous path runs on the caller's thread, where the ambient transaction set by a + /// TransactionScope is directly observable and must still be honored even though no + /// transaction is handed to the pool explicitly. + /// + [Fact] + public void GetConnection_Sync_UsesAmbientTransactionFromCallersThread() + { + // Arrange + using var scope = new TransactionScope(); + Transaction? transaction = Transaction.Current; + Assert.NotNull(transaction); + + // Act + var owner = new SqlConnection(); + var connection = GetConnection(owner); + + // Assert + Assert.NotNull(connection); + Assert.Equal(transaction, connection.EnlistedTransaction); + + ReturnConnection(connection, owner); + AssertPoolState(count: 1, idleCount: 0, transactedCount: 1); + + scope.Complete(); + } + + /// + /// The asynchronous equivalent of the case above. The pool cannot read the caller's ambient + /// transaction itself, because the open runs on a thread pool thread it does not flow to, so + /// the caller captures it and passes it in AsyncState. Even with a scope created with + /// , where the transaction is genuinely + /// ambient on the calling thread, that capture is what has to carry it through. + /// + [Fact] + public async Task GetConnectionAsync_UsesAmbientTransactionCapturedOnCallersThread() + { + // Arrange + using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled); + Transaction? transaction = Transaction.Current; + Assert.NotNull(transaction); + + // Act - no transaction is passed explicitly, so the helper captures the ambient one on + // this thread exactly as SqlConnection.InternalOpenAsync does. + var owner = new SqlConnection(); + var connection = await GetConnectionAsync(owner); + + // Assert + Assert.NotNull(connection); + Assert.Equal(transaction, connection.EnlistedTransaction); + + ReturnConnection(connection, owner); + AssertPoolState(count: 1, idleCount: 0, transactedCount: 1); + Assert.Equal(1, TransactedConnectionsFor(transaction!)); + + scope.Complete(); + } + + #endregion + + #region Mock Classes + + internal class MockSqlConnectionFactory : SqlConnectionFactory + { + /// + /// The value of observed on the thread the pool used to + /// create the connection. On the asynchronous path that is the thread pool thread the pool + /// runs its open work on, so this is a direct observation of whether the pool assigned the + /// ambient transaction there. + /// + public Transaction? AmbientTransactionAtCreate { get; private set; } + + public int CreateCount { get; private set; } + + protected override DbConnectionInternal CreateConnection( + SqlConnectionOptions options, + ConnectionPoolKey poolKey, + DbConnectionPoolGroupProviderInfo poolGroupProviderInfo, + IDbConnectionPool pool, + DbConnection owningConnection, + TimeoutTimer timeout) + { + AmbientTransactionAtCreate = Transaction.Current; + CreateCount++; + return new MockDbConnectionInternal(); + } + } + + 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; + } + + protected override void Deactivate() + { + } + + public override string ToString() => $"MockConnection_{MockId}"; + + internal override void ResetConnection() + { + } + } + + #endregion +}