diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/AdapterUtil.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/AdapterUtil.cs index 568592072d..2f61af5136 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/AdapterUtil.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/Common/AdapterUtil.cs @@ -175,6 +175,28 @@ internal static Timer UnsafeCreateTimer(TimerCallback callback, object state, Ti } } + /// + /// Creates an using the supplied without + /// capturing the current . + /// + internal static ITimer UnsafeCreateTimer( + TimeProvider timeProvider, + Action callback, + T state, + TimeSpan dueTime, + TimeSpan period) + { + if (ExecutionContext.IsFlowSuppressed()) + { + return timeProvider.CreateTimer(s => callback((T)s), state, dueTime, period); + } + + using (ExecutionContext.SuppressFlow()) + { + return timeProvider.CreateTimer(s => callback((T)s), state, dueTime, period); + } + } + #region COM+ exceptions internal static ArgumentException Argument(string error) diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs index d2c3b57323..9e835a7708 100644 --- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs +++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/WaitHandleDbConnectionPool.cs @@ -198,7 +198,11 @@ public void Dispose() private volatile bool _errorOccurred; private int _errorWait; - private Timer _errorTimer; + private ITimer _errorTimer; + + // Clock used to create the error backoff timer. Defaults to the system clock in + // production; tests inject a fake clock to drive the exit timer deterministically. + private readonly TimeProvider _timeProvider; private Timer _cleanupTimer; @@ -212,7 +216,8 @@ internal WaitHandleDbConnectionPool( SqlConnectionFactory connectionFactory, DbConnectionPoolGroup connectionPoolGroup, DbConnectionPoolIdentity identity, - DbConnectionPoolProviderInfo connectionPoolProviderInfo) + DbConnectionPoolProviderInfo connectionPoolProviderInfo, + TimeProvider timeProvider = null) { Debug.Assert(connectionPoolGroup != null, "null connectionPoolGroup"); @@ -249,6 +254,7 @@ internal WaitHandleDbConnectionPool( _connectionPoolGroupOptions = connectionPoolGroup.PoolGroupOptions; _connectionPoolProviderInfo = connectionPoolProviderInfo; _identity = identity; + _timeProvider = timeProvider ?? TimeProvider.System; _waitHandles = new PoolWaitHandles(); @@ -597,7 +603,7 @@ private DbConnectionInternal CreateObject(DbConnection owningObject, DbConnectio _resError = e; // Make sure the timer starts even if ThreadAbort occurs after setting the ErrorEvent. - Timer t = new Timer(new TimerCallback(this.ErrorCallback), null, Timeout.Infinite, Timeout.Infinite); + ITimer t = ADP.UnsafeCreateTimer(_timeProvider, state => state.ErrorCallback(null), this, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); bool timerIsNotDisposed; @@ -608,7 +614,7 @@ private DbConnectionInternal CreateObject(DbConnection owningObject, DbConnectio // Note that the timer is created to allow periodic invocation. If ThreadAbort occurs in the middle of ErrorCallback, // the timer will restart. Otherwise, the timer callback (ErrorCallback) destroys the timer after resetting the error to avoid second callback. _errorTimer = t; - timerIsNotDisposed = t.Change(_errorWait, _errorWait); + timerIsNotDisposed = t.Change(TimeSpan.FromMilliseconds(_errorWait), TimeSpan.FromMilliseconds(_errorWait)); Debug.Assert(timerIsNotDisposed, "ErrorCallback timer has been disposed"); @@ -799,7 +805,7 @@ private void ErrorCallback(object state) _waitHandles.ErrorEvent.Reset(); // the error state is cleaned, destroy the timer to avoid periodic invocation - Timer t = _errorTimer; + ITimer t = _errorTimer; _errorTimer = null; if (t != null) { diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/SqlExceptionHelper.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/SqlExceptionHelper.cs new file mode 100644 index 0000000000..2f68f58638 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/SqlExceptionHelper.cs @@ -0,0 +1,25 @@ +// 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. + +namespace Microsoft.Data.SqlClient.UnitTests.ConnectionPool +{ + /// + /// Test helper for creating instances. Since has + /// an internal constructor, instances must be created via the factory method. + /// + internal static class SqlExceptionHelper + { + /// + /// Creates a with the specified message using the internal factory method. + /// + /// The error message for the exception. + /// A new with the specified message. + internal static SqlException CreateSqlException(string message) + { + var collection = new SqlErrorCollection(); + collection.Add(new SqlError(0, (byte)0, (byte)0, "TestServer", message, "", 0)); + return SqlException.CreateException(collection, ""); + } + } +} diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolBlockingPeriodTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolBlockingPeriodTest.cs new file mode 100644 index 0000000000..5adb2d8661 --- /dev/null +++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/WaitHandleDbConnectionPoolBlockingPeriodTest.cs @@ -0,0 +1,401 @@ +// 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.Transactions; +using Microsoft.Data.ProviderBase; +using Microsoft.Data.SqlClient.ConnectionPool; +using Microsoft.Extensions.Time.Testing; +using Xunit; + +#nullable enable + +namespace Microsoft.Data.SqlClient.UnitTests.ConnectionPool; + +/// +/// End-to-end coverage of the blocking-period +/// behavior, exercising the full connection-acquisition path rather than the +/// state machine in isolation. Verifies the wiring +/// between a failed physical connection create and the pool's error state: +/// - A failed create enters the blocking period and surfaces the error (CreateObjectEnter). +/// - Subsequent requests fast-fail with the cached (cloned) exception without re-invoking the +/// connection factory (error wait-handle → ThrowIfActive). +/// - A successful create does not trip the blocking period. +/// - bypasses the blocking period entirely so each +/// request retries the factory. +/// - After the blocking-period exit timer fires, the next request retries the factory and a +/// successful create recovers the pool and resets the backoff (driven deterministically by an +/// injected ). +/// +public class WaitHandleDbConnectionPoolBlockingPeriodTest : IDisposable +{ + private const int DefaultMaxPoolSize = 50; + private const int DefaultMinPoolSize = 0; + private const int DefaultCreationTimeoutInMilliseconds = 15_000; + + private WaitHandleDbConnectionPool? _pool; + + public void Dispose() + { + _pool?.Shutdown(); + _pool?.Clear(); + } + + /// + /// Builds a running backed by the supplied factory. + /// The controls the data source and + /// policy used to resolve whether blocking is enabled. + /// When is supplied, the pool's + /// uses it as its clock so the exit timer can be driven + /// deterministically; otherwise the system clock is used. + /// + private WaitHandleDbConnectionPool CreatePool( + SqlConnectionFactory connectionFactory, + string connectionString = "Data Source=localhost;", + TimeProvider? timeProvider = null) + { + var poolGroupOptions = new DbConnectionPoolGroupOptions( + poolByIdentity: false, + minPoolSize: DefaultMinPoolSize, + maxPoolSize: DefaultMaxPoolSize, + creationTimeout: DefaultCreationTimeoutInMilliseconds, + loadBalanceTimeout: 0, + hasTransactionAffinity: true, + idleTimeout: 0); + + var dbConnectionPoolGroup = new DbConnectionPoolGroup( + new SqlConnectionOptions(connectionString), + new ConnectionPoolKey("TestDataSource", credential: null, accessToken: null, accessTokenCallback: null, sspiContextProvider: null), + poolGroupOptions); + + var pool = new WaitHandleDbConnectionPool( + connectionFactory, + dbConnectionPoolGroup, + DbConnectionPoolIdentity.NoIdentity, + new DbConnectionPoolProviderInfo(), + timeProvider); + + pool.Startup(); + _pool = pool; + return pool; + } + + /// + /// Synchronously requests a connection from the pool, mirroring the sync acquisition path + /// (taskCompletionSource == null) used by callers of SqlConnection.Open. + /// + private static bool TryGetConnectionSync( + WaitHandleDbConnectionPool pool, + DbConnection owner, + out DbConnectionInternal? connection) + { + TimeoutTimer timer = TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)); + return pool.TryGetConnection(owner, taskCompletionSource: null, timer, out connection); + } + + /// + /// Verifies that when the connection factory fails, the pool enters the blocking period: + /// the originating exception is surfaced to the caller and + /// becomes true. Guards the CreateObject wiring. + /// + [Fact] + public void TryGetConnection_WhenFactoryThrows_EntersBlockingPeriod() + { + // Arrange + SqlException failure = SqlExceptionHelper.CreateSqlException("server unreachable"); + var factory = new ConfigurableSqlConnectionFactory(_ => throw failure); + var pool = CreatePool(factory); + using var owner = new SqlConnection(); + + // Act + SqlException thrown = Assert.Throws( + () => TryGetConnectionSync(pool, owner, out _)); + + // Assert + Assert.Equal(failure.Message, thrown.Message); + Assert.True(pool.ErrorOccurred); + Assert.Equal(1, factory.CreateConnectionCallCount); + } + + /// + /// Verifies that once the pool is in the blocking period, a subsequent request fast-fails + /// with the cached exception without invoking the connection factory again. The first throw + /// rethrows the original instance; the fast-fail throw returns a clone (to avoid sharing stack + /// traces). Guards the error wait-handle → path. + /// + [Fact] + public void TryGetConnection_WhileBlocked_FastFailsWithCachedExceptionWithoutInvokingFactory() + { + // Arrange + SqlException failure = SqlExceptionHelper.CreateSqlException("server unreachable"); + var factory = new ConfigurableSqlConnectionFactory(_ => throw failure); + var pool = CreatePool(factory); + using var owner = new SqlConnection(); + + // Act: first request enters the blocking period and rethrows the original exception. + SqlException first = Assert.Throws( + () => TryGetConnectionSync(pool, owner, out _)); + + // Second request must fast-fail from the cached state without reaching the factory. + SqlException second = Assert.Throws( + () => TryGetConnectionSync(pool, owner, out _)); + + // Assert + Assert.Same(failure, first); // original instance rethrown on entry + Assert.NotSame(failure, second); // cloned on fast-fail + Assert.Equal(failure.Message, second.Message); // but message is preserved + Assert.Equal(1, factory.CreateConnectionCallCount); // factory not re-invoked while blocked + Assert.True(pool.ErrorOccurred); + } + + /// + /// Verifies that with , a failed create does not + /// cache the error: stays false and each + /// request retries the connection factory. Guards the + /// IsBlockingPeriodEnabled() == false bypass in CreateObject. + /// + [Fact] + public void TryGetConnection_WithNeverBlockPolicy_DoesNotCacheErrorAndRetriesFactory() + { + // Arrange + SqlException failure = SqlExceptionHelper.CreateSqlException("server unreachable"); + var factory = new ConfigurableSqlConnectionFactory(_ => throw failure); + var pool = CreatePool(factory, "Data Source=localhost;PoolBlockingPeriod=NeverBlock"); + using var owner = new SqlConnection(); + + // Act: two independent failures, neither of which should be cached. + SqlException first = Assert.Throws(() => TryGetConnectionSync(pool, owner, out _)); + SqlException second = Assert.Throws(() => TryGetConnectionSync(pool, owner, out _)); + + // Assert + Assert.Same(failure, first); // original instance rethrown, never cached/cloned + Assert.Same(failure, second); // original instance rethrown again on retry + Assert.False(pool.ErrorOccurred); + Assert.Equal(2, factory.CreateConnectionCallCount); + } + + /// + /// Verifies that a successful create returns a connection and does not trip the blocking + /// period, confirming the normal path leaves + /// false (and exercises the fast no-op on success). + /// + [Fact] + public void TryGetConnection_WhenFactorySucceeds_DoesNotEnterBlockingPeriod() + { + // Arrange + var factory = new ConfigurableSqlConnectionFactory(_ => new MockDbConnectionInternal()); + var pool = CreatePool(factory); + using var owner = new SqlConnection(); + + // Act + bool completed = TryGetConnectionSync(pool, owner, out DbConnectionInternal? connection); + + // Assert + Assert.True(completed); + Assert.NotNull(connection); + Assert.False(pool.ErrorOccurred); + Assert.Equal(1, factory.CreateConnectionCallCount); + } + + /// + /// Verifies that once the blocking period's exit timer fires, the next request retries the + /// factory and a successful create recovers the pool: + /// returns to false and a connection is produced. Drives the exit timer deterministically with + /// an injected , guarding the + /// timer-exit → retry → Clear path at the pool level. + /// + [Fact] + public void TryGetConnection_AfterBlockingPeriodExpires_RetriesFactoryAndRecovers() + { + // Arrange: first create fails, every later create succeeds. + SqlException failure = SqlExceptionHelper.CreateSqlException("server unreachable"); + var factory = new ConfigurableSqlConnectionFactory( + call => call == 1 ? throw failure : new MockDbConnectionInternal()); + var fakeTime = new FakeTimeProvider(); + var pool = CreatePool(factory, timeProvider: fakeTime); + using var owner = new SqlConnection(); + + // Act: first request enters the blocking period. + Assert.Throws(() => TryGetConnectionSync(pool, owner, out _)); + Assert.True(pool.ErrorOccurred); + Assert.Equal(1, factory.CreateConnectionCallCount); + + // While blocked, a request fast-fails without reaching the factory. + Assert.Throws(() => TryGetConnectionSync(pool, owner, out _)); + Assert.Equal(1, factory.CreateConnectionCallCount); + + // Advance past the initial 5s blocking period so the exit timer fires. + fakeTime.Advance(TimeSpan.FromSeconds(5)); + + // Assert: the pool has recovered and a new request reaches the factory and succeeds. + Assert.False(pool.ErrorOccurred); + bool completed = TryGetConnectionSync(pool, owner, out DbConnectionInternal? connection); + Assert.True(completed); + Assert.NotNull(connection); + Assert.False(pool.ErrorOccurred); + Assert.Equal(2, factory.CreateConnectionCallCount); + } + + /// + /// Verifies that a successful create resets the exponential backoff to the initial 5-second + /// wait. The backoff is first allowed to grow (fail → timer-exit → fail → timer-exit, observing + /// the doubled 10-second wait) so the reset is observable. A successful create then resets the + /// backoff via , and a subsequent failure blocks for + /// only the initial 5 seconds rather than the accumulated 20 seconds, confirming the success + /// reset is wired through the pool. Drives timing deterministically with an injected + /// . + /// + [Fact] + public void TryGetConnection_SuccessfulCreate_ResetsBackoffToInitialWait() + { + // Arrange: creates #1, #2 and #4 fail; #3 succeeds (recovery) to reset the backoff. + SqlException failure = SqlExceptionHelper.CreateSqlException("server unreachable"); + var factory = new ConfigurableSqlConnectionFactory( + call => call == 3 ? new MockDbConnectionInternal() : throw failure); + var fakeTime = new FakeTimeProvider(); + var pool = CreatePool(factory, timeProvider: fakeTime); + using var owner = new SqlConnection(); + + // Act: first failure enters the blocking period with the initial 5s wait. + Assert.Throws(() => TryGetConnectionSync(pool, owner, out _)); + Assert.Equal(1, factory.CreateConnectionCallCount); + fakeTime.Advance(TimeSpan.FromSeconds(5)); // timer fires -> backoff doubles to 10s + Assert.False(pool.ErrorOccurred); + + // Second failure enters with the doubled 10s wait; confirm it lasts the full 10s so the + // backoff has demonstrably grown before we reset it. + Assert.Throws(() => TryGetConnectionSync(pool, owner, out _)); // create #2 fails + Assert.Equal(2, factory.CreateConnectionCallCount); + fakeTime.Advance(TimeSpan.FromSeconds(9)); + Assert.True(pool.ErrorOccurred); // still blocked at 9s -> wait is 10s, not 5s + fakeTime.Advance(TimeSpan.FromSeconds(1)); // timer fires at 10s -> backoff would double to 20s + Assert.False(pool.ErrorOccurred); + + // A successful create resets the backoff to the initial 5s. + Assert.True(TryGetConnectionSync(pool, owner, out _)); // create #3 succeeds -> Clear() + Assert.Equal(3, factory.CreateConnectionCallCount); + + // A new failure enters the blocking period again. + Assert.Throws(() => TryGetConnectionSync(pool, owner, out _)); // create #4 fails + Assert.Equal(4, factory.CreateConnectionCallCount); + Assert.True(pool.ErrorOccurred); + + // Assert: the wait was reset to the initial 5s (not the accumulated 20s). + fakeTime.Advance(TimeSpan.FromSeconds(4)); + Assert.True(pool.ErrorOccurred); // still blocked just before the 5s mark + fakeTime.Advance(TimeSpan.FromSeconds(1)); + Assert.False(pool.ErrorOccurred); // cleared exactly at 5s, proving the backoff reset + } + + /// + /// Verifies that a timer-driven exit alone does NOT reset the backoff: when a failure recurs + /// after the exit timer fires (with no intervening successful create), the next blocking period + /// uses the doubled wait (10s, not the initial 5s). Confirms that only + /// — invoked on a successful create — resets the + /// backoff at the pool level. Drives timing deterministically with an injected + /// . + /// + [Fact] + public void TryGetConnection_FailingAgainAfterExitTimer_StillDoublesBackoff() + { + // Arrange: every create fails, so the backoff is never reset by a success. + SqlException failure = SqlExceptionHelper.CreateSqlException("server unreachable"); + var factory = new ConfigurableSqlConnectionFactory(_ => throw failure); + var fakeTime = new FakeTimeProvider(); + var pool = CreatePool(factory, timeProvider: fakeTime); + using var owner = new SqlConnection(); + + // Act: first failure enters the blocking period (initial 5s wait). + Assert.Throws(() => TryGetConnectionSync(pool, owner, out _)); + Assert.Equal(1, factory.CreateConnectionCallCount); + + // Advance past the 5s wait so the exit timer fires (no success -> backoff not reset). + fakeTime.Advance(TimeSpan.FromSeconds(5)); + Assert.False(pool.ErrorOccurred); + + // A new failure re-enters the blocking period using the doubled 10s wait. The factory is + // invoked again, confirming the request reached creation rather than fast-failing. + Assert.Throws(() => TryGetConnectionSync(pool, owner, out _)); + Assert.Equal(2, factory.CreateConnectionCallCount); + Assert.True(pool.ErrorOccurred); + + // Assert: still blocked at the original 5s mark (proves the wait is not 5s)... + fakeTime.Advance(TimeSpan.FromSeconds(5)); + Assert.True(pool.ErrorOccurred); + + // ...and not cleared until the full doubled 10s elapses. + fakeTime.Advance(TimeSpan.FromSeconds(4)); + Assert.True(pool.ErrorOccurred); + fakeTime.Advance(TimeSpan.FromSeconds(1)); + Assert.False(pool.ErrorOccurred); // cleared exactly at 10s, proving the backoff doubled + } + + /// + /// test double whose CreateConnection behavior is + /// supplied per-call (to throw or return a mock connection) and which records how many times + /// the factory was invoked so tests can assert fast-fail (no re-invocation) versus retry. + /// + internal sealed class ConfigurableSqlConnectionFactory : SqlConnectionFactory + { + private readonly Func _createBehavior; + private int _callCount; + + /// + /// The number of times CreateConnection has been invoked. The argument passed to + /// the behavior delegate is the 1-based invocation index. + /// + internal int CreateConnectionCallCount => Volatile.Read(ref _callCount); + + internal ConfigurableSqlConnectionFactory(Func createBehavior) + => _createBehavior = createBehavior; + + protected override DbConnectionInternal CreateConnection( + SqlConnectionOptions options, + ConnectionPoolKey poolKey, + DbConnectionPoolGroupProviderInfo poolGroupProviderInfo, + IDbConnectionPool pool, + DbConnection owningConnection, + TimeoutTimer timeout) + { + int call = Interlocked.Increment(ref _callCount); + return _createBehavior(call); + } + } + + /// + /// Minimal stub used as a successful create result. + /// Duplicated locally so this test file remains self-contained, mirroring the helpers in the + /// sibling WaitHandleDbConnectionPool test files. + /// + internal sealed class MockDbConnectionInternal : DbConnectionInternal + { + public override string ServerVersion => "Mock"; + + 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() + { + } + + internal override void ResetConnection() + { + } + } +}