diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index c0bbc25457..b0b7a88dde 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -150,6 +150,24 @@ When a new issue is created, follow these steps:
- Do not modify the `CODEOWNERS` file directly.
- Do not modify `CHANGELOG.md` unless executing a release workflow (see `release-notes` prompt).
- Do not close issues without a fix or without providing a clear reason.
+- **Check `.github/instructions/` for matching `applyTo` patterns BEFORE starting any file creation or modification task.** Apply all conventions from those files from the start—do not refactor later.
+
+## Instruction File Lookup (Required Workflow)
+
+Before creating or modifying any file, check if matching instruction files exist:
+
+1. Scan `.github/instructions/` for files with `applyTo` patterns that match your file path
+2. Read **all** matching instruction files completely before writing code
+3. Apply **all** conventions, patterns, and requirements from the start
+4. Do not skip instructions or plan to refactor later
+
+**Example:** Creating `src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/NewTest.cs`
+- File matches `applyTo: "**/tests/**,**/*Test*.cs"` from `testing.instructions.md`
+- Must read `testing.instructions.md` before writing the first test method
+- Must apply AAA pattern, XML doc comments, and all testing conventions from the start
+- Do not create tests first and add documentation later
+
+This ensures consistency across the codebase and prevents rework.
## Terminal Execution Safety
- Treat any non-zero shell exit code as a failed step that requires correction before proceeding.
diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md
index df6b909f8a..025d0fe5f1 100644
--- a/.github/instructions/testing.instructions.md
+++ b/.github/instructions/testing.instructions.md
@@ -189,10 +189,15 @@ class and method level.
#### What the Comments Must Explain
- The behavior/contract being tested (not just restating the method name).
- Why the scenario matters (for example: regression guard, parsing contract, sync/async parity,
- isolation requirement).
+ isolation requirement, thread-safety validation, edge case handling).
- For helper methods, what side effects occur (for example console redirection, file system
copying, process execution) and why they are needed.
+#### Implementation Guidance
+- Always use the **Arrange-Act-Assert (AAA)** pattern with explicit section comments (see [Test Structure](#test-structure-arrange-act-assert-aaa-pattern)).
+- Keep the test body focused on a single logical assertion; avoid testing multiple unrelated behaviors.
+- Extract complex setup into helper methods or fixtures rather than embedding it in the test.
+
#### Style Guidance
- Keep comments concise and factual.
- Prefer behavior-focused wording over implementation trivia.
@@ -212,25 +217,41 @@ public void AppRunWithMalformedConnectionStringReturnsOneAndWritesParseError()
}
```
-### Test Structure
+### Test Structure: Arrange-Act-Assert (AAA) Pattern
+
+All test methods **must** follow the **Arrange-Act-Assert (AAA)** pattern with explicit section comments.
+This pattern improves readability and maintainability by clearly delineating setup, execution, and validation.
+
+**Pattern:**
```csharp
-public class FeatureNameTests
+[Fact]
+public void MethodName_Scenario_ExpectedResult()
{
- [Fact]
- public void MethodName_Scenario_ExpectedResult()
- {
- // Arrange
- var sut = new SystemUnderTest();
-
- // Act
- var result = sut.PerformAction();
-
- // Assert
- Assert.Equal(expected, result);
- }
+ // Arrange
+ // Set up test fixtures, initial state, dependencies, and test data
+ var sut = new SystemUnderTest();
+ var input = new TestData();
+
+ // Act
+ // Execute the code under test
+ var result = sut.PerformAction(input);
+
+ // Assert
+ // Validate outcomes and expectations
+ Assert.Equal(expected, result);
}
```
+**Benefits:**
+- Clear visual separation of setup, execution, and validation
+- Easier to identify test logic flow
+- Simpler to debug failing tests (know which section failed)
+- Consistent convention across codebase
+- Improves AI agent comprehension and test generation
+
+**Guideline:** Each section should be visually distinct. If AAA sections become too large (e.g., Arrange > 20 lines),
+consider extracting helper methods or fixtures rather than embedding complexity in the test method itself.
+
### Naming Conventions
- Test class: `{ClassName}Tests`
- Test method: `{Method}_{Scenario}_{ExpectedBehavior}`
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 59839f2351..76142810ca 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -150,6 +150,7 @@
+
@@ -157,6 +158,7 @@
+
diff --git a/specs/006-pool-rate-limiting/diagrams.md b/specs/006-pool-rate-limiting/diagrams.md
new file mode 100644
index 0000000000..4b3f3130d7
--- /dev/null
+++ b/specs/006-pool-rate-limiting/diagrams.md
@@ -0,0 +1,41 @@
+# Rate limiting comparison
+
+## Existing rate limiting
+
+```mermaid
+flowchart TD
+ Start([Open request]) --> WaitAny["WaitHandle.WaitAny
(blocking, no queue)"]
+
+ WaitAny -->|idle available| S0["PoolSemaphore
Semaphore 0..MAX"]
+ WaitAny -->|error state| S1["ErrorEvent
ManualResetEvent"]
+ WaitAny -->|permit to open one conn| S2["CreationSemaphore
Semaphore 1,1"]
+
+ S0 -->|got connection| Done([Return connection])
+ S2 --> Open["Open physical connection"]
+ Open --> Release["Semaphore.Release 1"]
+ Release -->|got connection| Done
+
+ classDef prim fill:#bfdbfe,stroke:#1e3a8a,color:#111
+ class WaitAny,S0,S1,S2,Open,Release prim
+```
+
+## New rate limiting
+
+```mermaid
+flowchart TD
+ Start([Open request]) --> Idle["Idle channel
TryRead
(non-blocking)"]
+
+ Idle -->|got connection| Done([Return connection])
+ Idle -->|empty| Limiter["RateLimiter
AttemptAcquire 1
(non-blocking)"]
+
+ Limiter -->|acquired lease| Open["Open physical connection"]
+ Limiter -->|not acquired| Channel["Idle channel
await ReadAsync
(FIFO queued)"]
+
+ Open --> Lease["RateLimitLease.Dispose"]
+ Lease --> |got connection| Done
+ Channel -->|loop on wake signal| Idle
+ Channel --> |got connection| Done
+
+ classDef prim fill:#bfdbfe,stroke:#1e3a8a,color:#111
+ class Idle,Limiter,Open,Lease,Channel prim
+```
\ No newline at end of file
diff --git a/specs/006-pool-rate-limiting/spec.md b/specs/006-pool-rate-limiting/spec.md
new file mode 100644
index 0000000000..2c3c164309
--- /dev/null
+++ b/specs/006-pool-rate-limiting/spec.md
@@ -0,0 +1,146 @@
+# Feature Specification: Pool Rate Limiting and Blocking Period
+
+**Feature Branch**: `dev/mdaigle/pool-rate-limit`
+**Created**: 2026-05-19
+**Status**: Draft
+**Input**: ADO Work Item 37824 — "Implement connection open rate limiting"
+
+## Description
+
+Add rate limiting to `ChannelDbConnectionPool` to control how many physical connections can be
+created concurrently. Without throttling, a burst of concurrent requests can trigger a login
+storm against SQL Server. The implementation uses
+`System.Threading.RateLimiting.ConcurrencyLimiter` from the BCL — no custom rate limiting
+primitives are defined.
+
+This feature also adds the `PoolBlockingPeriod` error state (fast-fail after a connection
+creation failure) with exponential backoff recovery, matching the existing
+`WaitHandleDbConnectionPool` behavior.
+
+> Time spent waiting for the rate limiter counts against the caller's overall `ConnectTimeout`
+> budget. `ReplaceConnection` (when implemented) MUST bypass the rate limiter: it already holds
+> a pool slot and must not deadlock.
+
+## User Scenarios & Testing
+
+### User Story 1 — Throttled Connection Creation Under Burst Demand (P1)
+
+The pool limits the number of simultaneous physical connection creation attempts. Callers that
+cannot immediately create a connection wait in FIFO order until the limiter allows them to
+proceed, subject to their `ConnectTimeout`.
+
+**Acceptance Scenarios**:
+
+1. **Given** the pool has no idle connections and many callers request connections simultaneously,
+ **When** the concurrency limit is reached, **Then** additional callers wait until an in-flight
+ creation completes before starting their own.
+2. **Given** a caller is waiting for the rate limiter, **When** its `ConnectTimeout` elapses,
+ **Then** the caller receives a timeout error without ever attempting to create a connection.
+3. **Given** the rate limiter has available capacity, **When** a caller requests a new connection,
+ **Then** the create proceeds immediately with no added latency.
+4. **Given** a connection creation completes (success or failure), **When** the `RateLimitLease`
+ is disposed, **Then** the next waiting caller is allowed to proceed.
+
+---
+
+### User Story 2 — Blocking Period Fast-Fail on Connection Failure (P1)
+
+When a connection creation attempt fails because the server is unreachable, the pool enters an
+error state and immediately fails subsequent requests for a limited period, returning the cached
+error. This prevents cascading timeouts when the server is down.
+
+**Acceptance Scenarios**:
+
+1. **Given** a creation failure has occurred and blocking period is enabled, **When** a new
+ connection is requested within the blocking window, **Then** the request fails immediately
+ with the cached error.
+2. **Given** a creation failure has occurred and blocking period is enabled, **When** the
+ blocking window expires, **Then** the next request attempts fresh connection creation.
+3. **Given** `PoolBlockingPeriod=NeverBlock`, **When** a creation failure occurs, **Then** each
+ subsequent request independently attempts creation (no fast-fail).
+4. **Given** `PoolBlockingPeriod=Auto` connecting to an Azure SQL endpoint and a failure occurs,
+ **Then** no blocking period is applied (same as `NeverBlock`).
+5. **Given** `PoolBlockingPeriod=Auto` connecting to an on-premises SQL Server and a failure
+ occurs, **Then** the blocking period is applied (same as `AlwaysBlock`).
+
+---
+
+### User Story 3 — Error State Recovery with Exponential Backoff (P2)
+
+While in the error state the pool waits using exponential backoff (5s → 10s → 20s → 30s → 60s
+cap) before allowing the next attempt. Once an attempt after the backoff succeeds, the error
+state clears and backoff resets.
+
+**Acceptance Scenarios**:
+
+1. **Given** the pool is in error state, **When** the backoff timer fires and the next caller's
+ attempt succeeds, **Then** the error state is cleared and subsequent requests attempt normal
+ creation.
+2. **Given** the pool is in error state, **When** the backoff timer fires and the next caller's
+ attempt fails, **Then** the backoff interval increases (up to the 60s cap) and the pool
+ re-enters the error state.
+3. **Given** the pool is in error state, **When** the error is cleared, **Then** the cached
+ exception, the error flag, and the backoff interval are all reset.
+
+---
+
+### User Story 4 — Rate Limiting Counts Against Connection Timeout (P2)
+
+Time spent waiting for rate limiter capacity counts against the caller's overall
+`ConnectTimeout` budget.
+
+**Acceptance Scenarios**:
+
+1. **Given** a caller's timeout is 15s and the caller waits 10s for rate limiting, **When** the
+ rate limiter releases, **Then** the remaining budget for connection creation is 5s.
+2. **Given** a caller's timeout expires while waiting for the rate limiter, **When** the timeout
+ fires, **Then** the caller receives a timeout error and is removed from the limiter queue.
+
+---
+
+### User Story 5 — Rate Limiter Built on System.Threading.RateLimiting (P3)
+
+The pool uses `System.Threading.RateLimiting.RateLimiter` as the base abstraction and
+`ConcurrencyLimiter` as the initial implementation. No custom rate limiting primitives are
+defined.
+
+**Acceptance Scenarios**:
+
+1. **Given** the pool is configured with the default `ConcurrencyLimiter`, **When** connections
+ are created, **Then** the limiter throttles concurrent creation to the configured maximum.
+2. **Given** a different `RateLimiter` implementation is substituted, **When** connections are
+ created, **Then** the pool delegates throttling to the substituted implementation without
+ code changes to pool logic.
+
+---
+
+## Functional Requirements
+
+- **FR-001**: The pool MUST limit the number of concurrent physical connection creation attempts
+ to a configurable maximum.
+- **FR-002**: Callers that cannot immediately create a connection due to rate limiting MUST wait
+ in FIFO order until capacity is available or their timeout expires.
+- **FR-003**: Time spent waiting for rate limiter capacity MUST count against the caller's
+ overall connection timeout budget.
+- **FR-004**: When a connection creation attempt completes (success or failure), the
+ `RateLimitLease` MUST be disposed so the next waiting caller can proceed.
+- **FR-005**: The pool MUST support three `PoolBlockingPeriod` modes: `Auto`, `AlwaysBlock`, and
+ `NeverBlock`.
+- **FR-006**: When the blocking period is enabled, the pool MUST enter an error state after a
+ creation failure and immediately fail subsequent requests with the cached error.
+- **FR-007**: When the blocking period is disabled, the pool MUST NOT enter an error state;
+ each request MUST independently attempt creation.
+- **FR-008**: While in error state, the backoff MUST use exponential growth starting at 5s,
+ doubling each attempt, capped at 60s.
+- **FR-009**: When an attempt succeeds, the pool MUST clear the error state and reset the
+ backoff to its initial value.
+- **FR-010**: The `ErrorOccurred` property MUST return `true` when in the error state and
+ `false` otherwise.
+- **FR-011**: `ClearPool` MUST clear the error state in addition to invalidating pooled
+ connections.
+- **FR-012**: The rate limiter MUST use `System.Threading.RateLimiting.RateLimiter` as the base
+ abstraction so that any `RateLimiter` implementation can be substituted without modifying
+ pool acquisition logic.
+- **FR-013**: The initial implementation MUST use
+ `System.Threading.RateLimiting.ConcurrencyLimiter` configured with the desired maximum number
+ of concurrent connection creation attempts.
diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft.Data.SqlClient.csproj b/src/Microsoft.Data.SqlClient/src/Microsoft.Data.SqlClient.csproj
index 06eaf9e914..92c648c283 100644
--- a/src/Microsoft.Data.SqlClient/src/Microsoft.Data.SqlClient.csproj
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft.Data.SqlClient.csproj
@@ -309,6 +309,7 @@
+
@@ -321,6 +322,7 @@
+
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..3bb0c09875 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,27 @@ internal static Timer UnsafeCreateTimer(TimerCallback callback, object state, Ti
}
}
+ internal static ITimer UnsafeCreateTimer(
+ TimeProvider timeProvider,
+ TimerCallback callback,
+ object state,
+ TimeSpan dueTime,
+ TimeSpan period)
+ {
+ // Don't capture the current ExecutionContext and its AsyncLocals onto
+ // a timer causing them to live forever. Honor the supplied TimeProvider
+ // so callers can inject a test double for deterministic scheduling.
+ if (ExecutionContext.IsFlowSuppressed())
+ {
+ return timeProvider.CreateTimer(callback, state, dueTime, period);
+ }
+
+ using (ExecutionContext.SuppressFlow())
+ {
+ return timeProvider.CreateTimer(callback, state, dueTime, period);
+ }
+ }
+
#region COM+ exceptions
internal static ArgumentException Argument(string error)
diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/BlockingPeriodErrorState.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/BlockingPeriodErrorState.cs
new file mode 100644
index 0000000000..935b3ccc44
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/BlockingPeriodErrorState.cs
@@ -0,0 +1,204 @@
+// 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.Threading;
+using Microsoft.Data.Common;
+using Microsoft.Data.SqlClient.Internal;
+
+#nullable enable
+
+namespace Microsoft.Data.SqlClient.ConnectionPool
+{
+ ///
+ /// Encapsulates a connection pool's blocking-period error state: cached exception, exponential
+ /// backoff timer, and synchronization. Kept as a separate class so the pool's
+ /// connection-acquisition path remains focused on capacity/queue concerns and stays
+ /// decoupled from the (independent) rate limiting policy.
+ ///
+ internal sealed class BlockingPeriodErrorState : IDisposable
+ {
+ // Mirrors the values used by WaitHandleDbConnectionPool (5s initial, 60s cap).
+ private static readonly TimeSpan InitialWait = TimeSpan.FromSeconds(5);
+ private static readonly TimeSpan MaxWait = TimeSpan.FromSeconds(60);
+
+ private readonly int _ownerPoolId;
+ private readonly Action? _onEnter;
+ private readonly Action? _onExit;
+ private readonly TimeProvider _timeProvider;
+ private readonly object _lock = new();
+ // Non-null while the pool is in the blocking period. Doubles as the "has error"
+ // flag, so callers don't need a separate bool. Volatile so other threads observe
+ // entry/exit transitions without acquiring _lock.
+ private volatile Exception? _cachedException;
+ private ITimer? _exitTimer;
+ private TimeSpan _nextWait = InitialWait;
+ private bool _disposed;
+
+ ///
+ /// Creates a new instance.
+ ///
+ /// Identifier of the owning pool, used in trace events.
+ /// Optional callback invoked (outside the internal lock) after the
+ /// state transitions into the blocking period. Used by the legacy wait-handle pool to
+ /// signal its error wait handle.
+ /// Optional callback invoked (outside the internal lock) after the
+ /// state transitions out of the blocking period via the exit timer or .
+ /// The time provider used to create the exit timer. Defaults to
+ /// . Inject a test double (e.g.
+ /// Microsoft.Extensions.TimeProvider.Testing.FakeTimeProvider) in unit tests to
+ /// control timer scheduling deterministically.
+ internal BlockingPeriodErrorState(int ownerPoolId, Action? onEnter = null, Action? onExit = null, TimeProvider? timeProvider = null)
+ {
+ _ownerPoolId = ownerPoolId;
+ _onEnter = onEnter;
+ _onExit = onExit;
+ _timeProvider = timeProvider ?? TimeProvider.System;
+ }
+
+ ///
+ /// True while the pool is in the blocking period. Subsequent acquisition attempts
+ /// should fast-fail with the cached exception.
+ ///
+ internal bool HasError => _cachedException is not null;
+
+ ///
+ /// Throws the cached error if the pool is currently in the blocking period.
+ ///
+ internal void ThrowIfActive()
+ {
+ Exception? cached = _cachedException;
+ if (cached is null)
+ {
+ return;
+ }
+
+ // Clone SqlExceptions so stack traces are not shared across callers; other
+ // exception types are rethrown as-is.
+ throw cached is SqlException sqlEx ? sqlEx.InternalClone() : cached;
+ }
+
+ ///
+ /// Enters the blocking period, caching the supplied exception and scheduling a timer
+ /// to exit the period after the current backoff interval. Subsequent failures double
+ /// the backoff up to .
+ ///
+ internal void Enter(Exception ex)
+ {
+ TimeSpan wait;
+ ITimer? oldTimer;
+ ITimer newTimer;
+
+ lock (_lock)
+ {
+ _cachedException = ex;
+ wait = _nextWait;
+
+ // Create the exit timer disarmed (infinite due time); it is armed below outside
+ // the lock. ADP.UnsafeCreateTimer suppresses execution-context flow so the timer
+ // doesn't capture and pin the current ExecutionContext and its AsyncLocals for its
+ // lifetime, while still honoring the injected TimeProvider for testability.
+ newTimer = ADP.UnsafeCreateTimer(
+ _timeProvider,
+ ExitCallback,
+ null,
+ Timeout.InfiniteTimeSpan,
+ Timeout.InfiniteTimeSpan);
+ oldTimer = _exitTimer;
+ _exitTimer = newTimer;
+
+ // Bump the backoff for the next failure, capped at MaxWait. FR-008.
+ TimeSpan doubled = _nextWait + _nextWait;
+ _nextWait = doubled >= MaxWait ? MaxWait : doubled;
+ }
+
+ oldTimer?.Dispose();
+ newTimer.Change(wait, Timeout.InfiniteTimeSpan);
+
+ _onEnter?.Invoke();
+
+ SqlClientEventSource.Log.TryPoolerTraceEvent(
+ " {0}, Entering blocking period for {1}ms.",
+ _ownerPoolId,
+ (int)wait.TotalMilliseconds);
+ }
+
+ ///
+ /// Clears the cached error state, disposes the exit timer, and resets the backoff to
+ /// its initial value.
+ ///
+ internal void Clear()
+ {
+ ITimer? oldTimer;
+ lock (_lock)
+ {
+ if (_cachedException is null && _exitTimer is null && _nextWait == InitialWait)
+ {
+ return;
+ }
+
+ _cachedException = null;
+ _nextWait = InitialWait;
+ oldTimer = _exitTimer;
+ _exitTimer = null;
+ }
+
+ oldTimer?.Dispose();
+
+ _onExit?.Invoke();
+
+ SqlClientEventSource.Log.TryPoolerTraceEvent(
+ " {0}, Error state cleared.", _ownerPoolId);
+ }
+
+ ///
+ /// Timer callback that exits the blocking period by clearing the cached exception,
+ /// allowing the next caller to attempt a fresh connection creation. The current
+ /// backoff is left intact so that, if the next attempt fails, the backoff continues
+ /// to grow rather than resetting. The backoff is reset only on a successful creation
+ /// or on .
+ ///
+ private void ExitCallback(object? state)
+ {
+ ITimer? oldTimer;
+ lock (_lock)
+ {
+ _cachedException = null;
+ oldTimer = _exitTimer;
+ _exitTimer = null;
+ }
+
+ oldTimer?.Dispose();
+
+ _onExit?.Invoke();
+
+ SqlClientEventSource.Log.TryPoolerTraceEvent(
+ " {0}, Exiting blocking period.", _ownerPoolId);
+ }
+
+ ///
+ /// Disposes the instance, releasing the exit timer if one is active. Clears the
+ /// error state so that any waiting callers do not observe a stale exception after
+ /// the owning pool is torn down.
+ ///
+ public void Dispose()
+ {
+ ITimer? timerToDispose;
+ lock (_lock)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ _cachedException = null;
+ timerToDispose = _exitTimer;
+ _exitTimer = null;
+ }
+
+ timerToDispose?.Dispose();
+ }
+ }
+}
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 9169e9717e..df70c3c1f3 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
@@ -9,6 +9,7 @@
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Channels;
+using System.Threading.RateLimiting;
using System.Threading.Tasks;
using System.Transactions;
using Microsoft.Data.Common;
@@ -92,6 +93,21 @@ internal sealed class ChannelDbConnectionPool : IDbConnectionPool, IDisposable
/// Must be updated using operations to ensure thread safety.
///
private volatile int _isClearing;
+
+ ///
+ /// Optional rate limiter that throttles the number of concurrent physical connection
+ /// creation attempts. When null, no rate limiting is applied. A non-null limiter is
+ /// supplied at pool construction time; there is no default. Callers fast-fail against
+ /// the limiter and fall back to the idle-channel wait when no permit is available.
+ ///
+ private readonly RateLimiter? _connectionCreationRateLimiter;
+
+ ///
+ /// Encapsulates the blocking-period error state for this pool: cached exception, exponential
+ /// backoff timer, and synchronization. Created only when blocking period is enabled for
+ /// this pool group. See .
+ ///
+ private readonly BlockingPeriodErrorState? _errorState;
#endregion
///
@@ -101,7 +117,8 @@ internal ChannelDbConnectionPool(
SqlConnectionFactory connectionFactory,
DbConnectionPoolGroup connectionPoolGroup,
DbConnectionPoolIdentity identity,
- DbConnectionPoolProviderInfo connectionPoolProviderInfo)
+ DbConnectionPoolProviderInfo connectionPoolProviderInfo,
+ RateLimiter? connectionCreationRateLimiter = null)
{
ConnectionFactory = connectionFactory;
PoolGroup = connectionPoolGroup;
@@ -111,9 +128,14 @@ internal ChannelDbConnectionPool(
AuthenticationContexts = new();
MaxPoolSize = Convert.ToUInt32(PoolGroupOptions.MaxPoolSize);
TransactedConnectionPool = new(this);
+ _connectionCreationRateLimiter = connectionCreationRateLimiter;
_connectionSlots = new(MaxPoolSize);
_idleChannel = new();
+ if (PoolGroup.IsBlockingPeriodEnabled())
+ {
+ _errorState = new BlockingPeriodErrorState(_instanceId);
+ }
// Pruning is only useful when the pool can grow beyond MinPoolSize.
// If min >= max, the pool is fixed-size and pruning would never activate.
@@ -141,8 +163,7 @@ public ConcurrentDictionary<
public int IdleCount => _idleChannel.Count;
///
- /// This will be implemented later when we add support for the pool blocking period after errors. For now, it always returns false.
- public bool ErrorOccurred => false;
+ public bool ErrorOccurred => _errorState?.HasError ?? false;
///
public int Id => _instanceId;
@@ -186,6 +207,10 @@ public void Clear()
SqlClientEventSource.Log.TryPoolerTraceEvent(
" {0}, Clearing.", Id);
+ // Clearing the pool implies the caller wants a clean slate, so abandon any cached
+ // error state. FR-011.
+ _errorState?.Clear();
+
Interlocked.Increment(ref _clearGeneration);
// If another thread is already draining, skip the drain. The generation counter has
@@ -286,6 +311,11 @@ public void Shutdown()
{
State = ShuttingDown;
Pruner?.Dispose();
+
+ // Dispose the error state so its exit timer is released. Otherwise a timer scheduled
+ // during the blocking period would keep this pool reachable and continue firing
+ // callbacks/logging after shutdown.
+ _errorState?.Dispose();
}
///
@@ -395,13 +425,16 @@ public bool TryGetConnection(
}
///
- /// Opens a new internal connection to the database.
+ /// Opens a new internal connection to the database, throttled by the pool's rate limiter.
///
/// The owning connection.
/// The cancellation token to cancel the operation.
/// The overall timeout budget. Passed through to the physical connection
/// so it uses the remaining budget rather than starting a fresh timeout.
- /// A task representing the asynchronous operation, with a result of the new internal connection.
+ /// The new internal connection, or null if the pool has no available slot or the
+ /// rate limiter is currently saturated. In the latter case the caller should fall back to
+ /// the idle-channel wait; the rate limiter will write a null to the idle channel when a
+ /// permit is released so the waiter can retry.
///
/// Thrown when the cancellation token is cancelled before the connection operation completes.
///
@@ -412,50 +445,120 @@ public bool TryGetConnection(
{
cancellationToken.ThrowIfCancellationRequested();
- // Opening a connection can be a slow operation and we don't want to hold a lock for the duration.
- // Instead, we reserve a connection slot prior to attempting to open a new connection and release the slot
- // in case of an exception.
-
- var result = _connectionSlots.Add(
- createCallback: () =>
- {
- // https://github.com/dotnet/SqlClient/issues/3459
- // TODO: This blocks the thread for several network calls!
- // When running async, the blocked thread is one allocated from the managed thread pool (due to
- // use of Task.Run in TryGetConnection). This is why it's critical for async callers to
- // pre-provision threads in the managed thread pool. Our options are limited because
- // DbConnectionInternal doesn't support an async open. It's better to block this thread and keep
- // throughput high than to queue all of our opens onto a single worker thread. Add an async path
- // when this support is added to DbConnectionInternal.
- // TODO: ultimately, the connection factory should also accept our cancellation token.
- var connection = ConnectionFactory.CreatePooledConnection(
- owningConnection,
- this,
- timeout);
+ // Fast-fail in the error state. FR-006.
+ _errorState?.ThrowIfActive();
- if (connection is not null)
+ try
+ {
+ // Reserve a pool slot up front so we don't pay the rate-limit cost only to
+ // discover the pool is full. Add() reserves synchronously and returns null
+ // immediately if no slot is available; the rate-limit check only happens inside
+ // the createCallback, which runs after the reservation succeeds.
+ DbConnectionInternal? connection = _connectionSlots.Add(
+ createCallback: () =>
{
- connection.ClearGeneration = _clearGeneration;
- }
+ // Fast-fail rate-limit attempt when a limiter is configured.
+ // AttemptAcquire returns synchronously and does not queue: if no permit
+ // is available right now, the lease comes back with IsAcquired == false.
+ // We deliberately do not block here so the caller can fall back to
+ // waiting on the idle channel, where it can be satisfied either by a
+ // returning connection or by a null poke from another caller releasing
+ // its rate-limit lease (see finally below). We prefer to recycle existing
+ // connections rather then queue on the rate limit. When no limiter is
+ // configured we substitute a no-op acquired lease.
+ // FR-001, FR-002, FR-003.
+
+ //TODO: some other options to consider:
+ // 1. fail immediately and surface an error to the caller
+ // 2. block on acquire (subject to overall timeout) - this would be the simplest
+ RateLimitLease lease = _connectionCreationRateLimiter?.AttemptAcquire(1) ?? NoOpAcquiredLease.Instance;
+ bool leaseAcquired = lease.IsAcquired;
+ try
+ {
+ if (!leaseAcquired)
+ {
+ // TODO: When we fail to acquire a lease, surface the lease metadata
+ // (e.g. RateLimitMetadataName.RetryAfter, ReasonPhrase) in the error
+ // path so the user can identify why the lease was denied.
+ return null;
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ // https://github.com/dotnet/SqlClient/issues/3459
+ // TODO: This blocks the thread for several network calls!
+ // When running async, the blocked thread is one allocated from the managed thread pool (due to
+ // use of Task.Run in TryGetConnection). This is why it's critical for async callers to
+ // pre-provision threads in the managed thread pool. Our options are limited because
+ // DbConnectionInternal doesn't support an async open. It's better to block this thread and keep
+ // throughput high than to queue all of our opens onto a single worker thread. Add an async path
+ // when this support is added to DbConnectionInternal.
+ // TODO: ultimately, the connection factory should also accept our cancellation token.
+ var newConnection = ConnectionFactory.CreatePooledConnection(
+ owningConnection,
+ this,
+ timeout);
+
+ if (newConnection is not null)
+ {
+ newConnection.ClearGeneration = _clearGeneration;
+ }
+
+ return newConnection;
+ }
+ finally
+ {
+ // Release the permit back to the limiter (no-op for the default lease)
+ // BEFORE signaling a waiter. Otherwise a woken waiter could consume the
+ // null poke and retry its acquire before the permit is actually returned,
+ // fail to acquire, and fall back to waiting with no subsequent signal -
+ // stalling connection creation even though the limiter has capacity.
+ lease.Dispose();
+
+ // After releasing, signal a waiter on the idle channel that they may now
+ // retry an open. We only poke when a limiter is configured (a waiter only
+ // falls back to the idle channel due to rate limiting in that case) and
+ // the pool can still grow; if we're at MaxPoolSize, only a connection
+ // return can satisfy a waiter. FR-004. This is best-effort; releasing a
+ // lease doesn't guarantee the rate limiter immediately has an available
+ // permit, but the waiter we wake will fall back to waiting again if not.
+ if (leaseAcquired &&
+ _connectionCreationRateLimiter is not null &&
+ _connectionSlots.ReservationCount < MaxPoolSize)
+ {
+ _idleChannel.TryWrite(null);
+ }
+ }
+ },
+ cleanupCallback: (newConnection) =>
+ {
+ // If we fail to open a connection, we need to write a null to the idle channel to
+ // wake up any waiters
+ _idleChannel.TryWrite(null);
+ newConnection?.Dispose();
+ });
- return connection;
- },
- cleanupCallback: (newConnection) =>
+ if (connection is not null)
{
- // If we fail to open a connection, we need to write a null to the idle channel to
- // wake up any waiters
- _idleChannel?.TryWrite(null);
- newConnection?.Dispose();
- });
+ // A new connection was added to the pool. If we've grown past MinPoolSize,
+ // start the pruning timer so idle connections can be reclaimed.
+ Pruner?.UpdateTimer();
- if (result is not null)
- {
- // A new connection was added to the pool. If we've grown past MinPoolSize,
- // start the pruning timer so idle connections can be reclaimed.
- Pruner?.UpdateTimer();
+ // A successful creation clears error/backoff state
+ // FR-009.
+ _errorState?.Clear();
+ }
+
+ return connection;
}
+ catch (Exception ex) when (ADP.IsCatchableExceptionType(ex))
+ {
+ // Enter the blocking period error state on creation failure if configured.
+ // FR-006, FR-007.
+ _errorState?.Enter(ex);
- return result;
+ throw;
+ }
}
///
@@ -585,7 +688,9 @@ private async Task GetInternalConnection(
connection ??= GetIdleConnection();
- // If we didn't find an idle connection, try to open a new one.
+ // If we didn't find an idle connection, try to open a new one. This may
+ // return null if the pool is full or the rate limiter is currently saturated;
+ // in either case the caller falls through to the idle-channel wait below.
connection ??= OpenNewInternalConnection(
owningConnection,
cancellationToken,
diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolGroup.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolGroup.cs
index 8f62787e30..c1f2b730cc 100644
--- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolGroup.cs
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/DbConnectionPoolGroup.cs
@@ -93,6 +93,26 @@ internal DbConnectionPoolGroupProviderInfo ProviderInfo
internal SqlMetaDataFactory MetaDataFactory { get; set; }
+ ///
+ /// Determines whether the blocking period is enabled for this pool group based on the
+ /// configured and the target data source.
+ ///
+ internal bool IsBlockingPeriodEnabled()
+ {
+ switch (_connectionOptions.PoolBlockingPeriod)
+ {
+ case PoolBlockingPeriod.Auto:
+ return !ADP.IsAzureSqlServerEndpoint(_connectionOptions.DataSource);
+ case PoolBlockingPeriod.AlwaysBlock:
+ return true;
+ case PoolBlockingPeriod.NeverBlock:
+ return false;
+ default:
+ Debug.Fail("Unknown PoolBlockingPeriod. Please specify explicit results in above switch case statement.");
+ return true;
+ }
+ }
+
internal int Clear()
{
// must be multi-thread safe with competing calls by Clear and Prune via background thread
diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/NoOpAcquiredLease.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/NoOpAcquiredLease.cs
new file mode 100644
index 0000000000..96785fe0e8
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/NoOpAcquiredLease.cs
@@ -0,0 +1,45 @@
+// 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.Collections.Generic;
+using System.Threading.RateLimiting;
+
+#nullable enable
+
+namespace Microsoft.Data.SqlClient.ConnectionPool
+{
+ ///
+ /// A no-op that is always acquired and performs no work on
+ /// dispose. Used as a stand-in when no rate limiter is configured so the open path can
+ /// treat the lease as unconditional. Stateless and safe to share across all callers; access
+ /// the singleton via .
+ ///
+ internal sealed class NoOpAcquiredLease : RateLimitLease
+ {
+ ///
+ /// The shared singleton instance.
+ ///
+ public static readonly NoOpAcquiredLease Instance = new();
+
+ private NoOpAcquiredLease()
+ {
+ }
+
+ public override bool IsAcquired => true;
+
+ public override IEnumerable MetadataNames => Array.Empty();
+
+ public override bool TryGetMetadata(string metadataName, out object? metadata)
+ {
+ metadata = null;
+ return false;
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ // No resources to release.
+ }
+ }
+}
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..86b23a5137 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
@@ -29,7 +29,7 @@ namespace Microsoft.Data.SqlClient.ConnectionPool
/// - Transaction-Aware Pooling: Tracks connections enlisted in using TransactedConnectionPool and TransactedConnectionList, ensuring proper context reuse.
/// - Concurrency and Synchronization: Uses wait handles and semaphores via PoolWaitHandles to coordinate safe multi-threaded access.
/// - Connection Lifecycle Management: Manages creation (CreateObject), deactivation (DeactivateObject), destruction (DestroyObject), and reclamation (ReclaimEmancipatedObjects) of internal connections.
- /// - Error Handling and Resilience: Implements retry and exponential backoff in TryGetConnection and handles transient errors using _errorWait.
+ /// - Error Handling and Resilience: Implements retry and exponential backoff in TryGetConnection and delegates blocking-period bookkeeping (cached exception, exit timer) to .
/// - Minimum Pool Size Enforcement: Maintains the MinPoolSize by spawning background tasks to create new connections when needed.
/// - Load Balancing Support: Honors LoadBalanceTimeout to clean up idle connections and distribute load evenly.
/// - Telemetry and Tracing: Uses SqlClientEventSource for extensive diagnostic tracing of connection lifecycle events.
@@ -165,8 +165,6 @@ public void Dispose()
private const int WAIT_ABANDONED = 0x80;
- private const int ERROR_WAIT_DEFAULT = 5 * 1000; // 5 seconds
-
// we do want a testable, repeatable set of generated random numbers
private static readonly Random s_random = new Random(5101977); // Value obtained from Dave Driver
@@ -194,11 +192,7 @@ public void Dispose()
private int _waitCount;
private readonly PoolWaitHandles _waitHandles;
- private Exception _resError;
- private volatile bool _errorOccurred;
-
- private int _errorWait;
- private Timer _errorTimer;
+ private readonly BlockingPeriodErrorState _errorState;
private Timer _cleanupTimer;
@@ -252,8 +246,12 @@ internal WaitHandleDbConnectionPool(
_waitHandles = new PoolWaitHandles();
- _errorWait = ERROR_WAIT_DEFAULT;
- _errorTimer = null; // No error yet.
+ // Hook the wait-handle event so any thread blocked in WaitAny over the pool's
+ // handles wakes up immediately when the blocking period is entered/exited.
+ _errorState = new BlockingPeriodErrorState(
+ Id,
+ onEnter: () => _waitHandles.ErrorEvent.Set(),
+ onExit: () => _waitHandles.ErrorEvent.Reset());
_objectList = new List(MaxPoolSize);
@@ -283,7 +281,7 @@ private int CreationTimeout
public SqlConnectionFactory ConnectionFactory => _connectionFactory;
- public bool ErrorOccurred => _errorOccurred;
+ public bool ErrorOccurred => _errorState.HasError;
private bool HasTransactionAffinity => PoolGroupOptions.HasTransactionAffinity;
@@ -515,39 +513,6 @@ private Timer CreateCleanupTimer() =>
_cleanupWait,
_cleanupWait);
- private bool IsBlockingPeriodEnabled()
- {
- var poolGroupConnectionOptions = _connectionPoolGroup.ConnectionOptions;
- if (poolGroupConnectionOptions == null)
- {
- return true;
- }
-
- var policy = poolGroupConnectionOptions.PoolBlockingPeriod;
-
- switch (policy)
- {
- case PoolBlockingPeriod.Auto:
- {
- return !ADP.IsAzureSqlServerEndpoint(poolGroupConnectionOptions.DataSource);
- }
- case PoolBlockingPeriod.AlwaysBlock:
- {
- return true; //Enabled
- }
- case PoolBlockingPeriod.NeverBlock:
- {
- return false; //Disabled
- }
- default:
- {
- //we should never get into this path.
- Debug.Fail("Unknown PoolBlockingPeriod. Please specify explicit results in above switch case statement.");
- return true;
- }
- }
- }
-
private DbConnectionInternal CreateObject(DbConnection owningObject, DbConnectionInternal oldConnection, TimeoutTimer timeout)
{
DbConnectionInternal newObj = null;
@@ -573,14 +538,14 @@ private DbConnectionInternal CreateObject(DbConnection owningObject, DbConnectio
SqlClientEventSource.Log.TryPoolerTraceEvent(" {0}, Connection {1}, Added to pool.", Id, newObj?.ObjectID);
- // Reset the error wait:
- _errorWait = ERROR_WAIT_DEFAULT;
+ // A successful creation clears any prior error state and resets backoff.
+ _errorState.Clear();
}
catch (Exception e) when (ADP.IsCatchableExceptionType(e))
{
ADP.TraceExceptionWithoutRethrow(e);
- if (!IsBlockingPeriodEnabled())
+ if (!_connectionPoolGroup.IsBlockingPeriodEnabled())
{
throw;
}
@@ -593,33 +558,10 @@ private DbConnectionInternal CreateObject(DbConnection owningObject, DbConnectio
newObj = null; // set to null, so we do not return bad new object
- // Failed to create instance
- _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);
-
- bool timerIsNotDisposed;
-
- _waitHandles.ErrorEvent.Set();
- _errorOccurred = true;
-
- // Enable the timer.
- // 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);
-
- Debug.Assert(timerIsNotDisposed, "ErrorCallback timer has been disposed");
+ // Enter the blocking period: caches the exception, schedules the exit timer,
+ // and signals the wait-handle error event via the onEnter callback.
+ _errorState.Enter(e);
- if (30000 < _errorWait)
- {
- _errorWait = 60000;
- }
- else
- {
- _errorWait *= 2;
- }
throw;
}
return newObj;
@@ -792,28 +734,6 @@ private void DestroyObject(DbConnectionInternal obj)
}
}
- private void ErrorCallback(object state)
- {
- SqlClientEventSource.Log.TryPoolerTraceEvent(" {0}, Resetting Error handling.", Id);
- _errorOccurred = false;
- _waitHandles.ErrorEvent.Reset();
-
- // the error state is cleaned, destroy the timer to avoid periodic invocation
- Timer t = _errorTimer;
- _errorTimer = null;
- if (t != null)
- {
- t.Dispose(); // Cancel timer request.
- }
- }
-
-
- private Exception TryCloneCachedException()
- // Cached exception can be of any type, so is not always cloneable.
- // This functions clones SqlException
- // OleDb and Odbc connections are not passing throw this code
- => _resError is SqlException sqlEx ? sqlEx.InternalClone() : _resError;
-
private void WaitForPendingOpen()
{
Debug.Assert(!Thread.CurrentThread.IsThreadPoolThread, "This thread may block for a long time. Threadpool threads should not be used.");
@@ -1037,7 +957,12 @@ private bool TryGetConnection(DbConnection owningObject, uint waitForMultipleObj
// Throw the error that PoolCreateRequest stashed.
SqlClientEventSource.Log.TryPoolerTraceEvent(" {0}, Errors are set.", Id);
Interlocked.Decrement(ref _waitCount);
- throw TryCloneCachedException();
+ _errorState.ThrowIfActive();
+ // Narrow race: error state cleared between WaitAny observing
+ // the signal and this check. Re-balance _waitCount and let the
+ // outer do/while loop retry.
+ Interlocked.Increment(ref _waitCount);
+ break;
case CREATION_HANDLE:
SqlClientEventSource.Log.TryPoolerTraceEvent(" {0}, Creating new connection.", Id);
@@ -1616,25 +1541,20 @@ private DbConnectionInternal UserCreateRequest(DbConnection owningObject, Timeou
// instead obtained creation mutex
DbConnectionInternal obj = null;
- if (ErrorOccurred)
- {
- throw TryCloneCachedException();
- }
- else
+ _errorState.ThrowIfActive();
+
+ if ((oldConnection != null) || (Count < MaxPoolSize) || (0 == MaxPoolSize))
{
- if ((oldConnection != null) || (Count < MaxPoolSize) || (0 == MaxPoolSize))
- {
- // If we have an odd number of total objects, reclaim any dead objects.
- // If we did not find any objects to reclaim, create a new one.
+ // If we have an odd number of total objects, reclaim any dead objects.
+ // If we did not find any objects to reclaim, create a new one.
- // TODO: Consider implement a control knob here; why do we only check for dead objects ever other time? why not every 10th time or every time?
- if ((oldConnection != null) || (Count & 0x1) == 0x1 || !ReclaimEmancipatedObjects())
- {
- obj = CreateObject(owningObject, oldConnection, timeout);
- }
+ // TODO: Consider implement a control knob here; why do we only check for dead objects ever other time? why not every 10th time or every time?
+ if ((oldConnection != null) || (Count & 0x1) == 0x1 || !ReclaimEmancipatedObjects())
+ {
+ obj = CreateObject(owningObject, oldConnection, timeout);
}
- return obj;
}
+ return obj;
}
}
}
diff --git a/src/Microsoft.Data.SqlClient/tests/ManualTests/Microsoft.Data.SqlClient.ManualTests.csproj b/src/Microsoft.Data.SqlClient/tests/ManualTests/Microsoft.Data.SqlClient.ManualTests.csproj
index 4b0dfb6ddd..efc319990f 100644
--- a/src/Microsoft.Data.SqlClient/tests/ManualTests/Microsoft.Data.SqlClient.ManualTests.csproj
+++ b/src/Microsoft.Data.SqlClient/tests/ManualTests/Microsoft.Data.SqlClient.ManualTests.csproj
@@ -98,6 +98,7 @@
+
@@ -124,6 +125,7 @@
+
diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/BlockingPeriodErrorStateTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/BlockingPeriodErrorStateTest.cs
new file mode 100644
index 0000000000..157249f5f9
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/BlockingPeriodErrorStateTest.cs
@@ -0,0 +1,688 @@
+// 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.Threading;
+using Microsoft.Extensions.Time.Testing;
+using Microsoft.Data.SqlClient.ConnectionPool;
+using Xunit;
+
+#nullable enable
+
+namespace Microsoft.Data.SqlClient.UnitTests.ConnectionPool
+{
+ ///
+ /// Comprehensive unit tests for covering:
+ /// - Initial state and error caching
+ /// - and exception handling
+ /// - and state reset
+ /// - Exponential backoff progression (verified with )
+ /// - Timer-driven exit behavior (verified with )
+ /// - implementation and timer cleanup
+ /// - Callback invocation and re-entrancy safety
+ ///
+ public class BlockingPeriodErrorStateTest
+ {
+ #region HasError / initial state
+
+ ///
+ /// Verifies that a newly constructed has
+ /// set to false.
+ ///
+ [Fact]
+ public void HasError_InitialState_IsFalse()
+ {
+ // Arrange
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1);
+
+ // Act & Assert
+ Assert.False(state.HasError);
+ }
+
+ ///
+ /// Verifies that does not throw
+ /// when called on a newly constructed instance with no cached error.
+ ///
+ [Fact]
+ public void ThrowIfActive_InitialState_DoesNotThrow()
+ {
+ // Arrange
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1);
+
+ // Act & Assert
+ state.ThrowIfActive(); // Should complete without throwing
+ }
+
+ #endregion
+
+ #region Enter
+
+ ///
+ /// Verifies that calling sets
+ /// to true.
+ ///
+ [Fact]
+ public void Enter_SetsHasErrorToTrue()
+ {
+ // Arrange
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1);
+
+ // Act
+ state.Enter(new InvalidOperationException("test"));
+
+ // Assert
+ Assert.True(state.HasError);
+ }
+
+ ///
+ /// Verifies that throws
+ /// the exact exception type that was cached by .
+ ///
+ [Fact]
+ public void Enter_ThrowIfActive_ThrowsCachedExceptionType()
+ {
+ // Arrange
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1);
+ var exception = new InvalidOperationException("boom");
+
+ // Act
+ state.Enter(exception);
+
+ // Assert
+ var ex = Assert.Throws(() => state.ThrowIfActive());
+ Assert.Equal("boom", ex.Message);
+ }
+
+ ///
+ /// Verifies that when a is cached,
+ /// throws a cloned instance rather than the original, to avoid sharing stack traces across callers.
+ ///
+ [Fact]
+ public void Enter_WithSqlException_ThrowsClonedInstance()
+ {
+ // Arrange
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1);
+ var original = SqlExceptionHelper.CreateSqlException("connection failed");
+
+ // Act
+ state.Enter(original);
+ var thrown = Assert.Throws(() => state.ThrowIfActive());
+
+ // Assert
+ Assert.NotSame(original, thrown);
+ Assert.Equal(original.Message, thrown.Message);
+ }
+
+ ///
+ /// Verifies that invokes the optional
+ /// onEnter callback after entering the blocking period.
+ ///
+ [Fact]
+ public void Enter_InvokesOnEnterCallback()
+ {
+ // Arrange
+ int callCount = 0;
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1, onEnter: () => callCount++);
+
+ // Act
+ state.Enter(new Exception());
+
+ // Assert
+ Assert.Equal(1, callCount);
+ }
+
+ ///
+ /// Verifies that calling a second time
+ /// replaces the cached exception, invokes the callback again, and the new exception is thrown.
+ ///
+ [Fact]
+ public void Enter_CalledTwice_ReplacesExceptionAndInvokesOnEnterAgain()
+ {
+ // Arrange
+ int enterCount = 0;
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1, onEnter: () => enterCount++);
+
+ // Act
+ state.Enter(new InvalidOperationException("first"));
+ state.Enter(new ArgumentException("second"));
+
+ // Assert
+ Assert.Equal(2, enterCount);
+ var ex = Assert.Throws(() => state.ThrowIfActive());
+ Assert.Equal("second", ex.Message);
+ }
+
+ ///
+ /// Verifies that does not invoke
+ /// the onExit callback (only the onEnter callback or the timer should trigger onExit).
+ ///
+ [Fact]
+ public void Enter_DoesNotInvokeOnExitCallback()
+ {
+ // Arrange
+ int exitCount = 0;
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1, onExit: () => exitCount++);
+
+ // Act
+ state.Enter(new Exception());
+
+ // Assert
+ Assert.Equal(0, exitCount);
+ }
+
+ #endregion
+
+ #region Clear
+
+ ///
+ /// Verifies that resets
+ /// to false.
+ ///
+ [Fact]
+ public void Clear_AfterEnter_ResetsHasError()
+ {
+ // Arrange
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1);
+ state.Enter(new Exception());
+
+ // Act
+ state.Clear();
+
+ // Assert
+ Assert.False(state.HasError);
+ }
+
+ ///
+ /// Verifies that after ,
+ /// does not throw because the cached error has been cleared.
+ ///
+ [Fact]
+ public void Clear_AfterEnter_ThrowIfActiveDoesNotThrow()
+ {
+ // Arrange
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1);
+ state.Enter(new Exception());
+
+ // Act
+ state.Clear();
+
+ // Assert
+ state.ThrowIfActive(); // Must not throw
+ }
+
+ ///
+ /// Verifies that invokes the optional
+ /// onExit callback after clearing the error state.
+ ///
+ [Fact]
+ public void Clear_AfterEnter_InvokesOnExitCallback()
+ {
+ // Arrange
+ int exitCount = 0;
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1, onExit: () => exitCount++);
+ state.Enter(new Exception());
+
+ // Act
+ state.Clear();
+
+ // Assert
+ Assert.Equal(1, exitCount);
+ }
+
+ ///
+ /// Verifies that on an initial (no-error) state
+ /// does not invoke the onExit callback because there is nothing to clear.
+ ///
+ [Fact]
+ public void Clear_OnInitialState_DoesNotInvokeOnExitCallback()
+ {
+ // Arrange
+ int exitCount = 0;
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1, onExit: () => exitCount++);
+
+ // Act
+ state.Clear();
+
+ // Assert
+ Assert.Equal(0, exitCount);
+ }
+
+ ///
+ /// Verifies that is idempotent:
+ /// calling it a second time does not invoke the onExit callback again.
+ ///
+ [Fact]
+ public void Clear_CalledTwice_OnExitInvokedOnce()
+ {
+ // Arrange
+ int exitCount = 0;
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1, onExit: () => exitCount++);
+ state.Enter(new Exception());
+
+ // Act
+ state.Clear();
+ state.Clear();
+
+ // Assert
+ Assert.Equal(1, exitCount);
+ }
+
+ ///
+ /// Verifies that resets the backoff
+ /// timeout to its initial value, so the next
+ /// uses the initial wait duration instead of the accumulated backoff.
+ ///
+ [Fact]
+ public void Clear_ResetsBackoffSoNextEnterUsesInitialWait()
+ {
+ // Arrange
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1);
+ state.Enter(new Exception("first"));
+
+ // Act
+ state.Clear();
+ state.Enter(new Exception("second"));
+
+ // Assert
+ Assert.True(state.HasError);
+ }
+
+ #endregion
+
+ #region Backoff progression
+
+ ///
+ /// Verifies that the initial enter schedules the timer with the 5-second initial wait.
+ /// The error state should persist until the timer fires, after which it clears automatically.
+ ///
+ [Fact]
+ public void Enter_FirstEntry_SchedulesInitialWaitTimer()
+ {
+ // Arrange
+ var fakeTime = new FakeTimeProvider();
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1, timeProvider: fakeTime);
+ state.Enter(new Exception());
+
+ // Act: advance just under the initial 5s wait
+ fakeTime.Advance(TimeSpan.FromSeconds(4));
+
+ // Assert: timer has not fired yet
+ Assert.True(state.HasError);
+
+ // Act: advance past the due time
+ fakeTime.Advance(TimeSpan.FromSeconds(1));
+
+ // Assert: timer has fired, error cleared
+ Assert.False(state.HasError);
+ }
+
+ ///
+ /// Verifies that successive timer-driven exits double the backoff each time:
+ /// 5 s → 10 s → 20 s → 40 s → 60 s (capped at MaxWait).
+ /// Each Enter schedules the timer for the current accumulated wait and the error
+ /// persists until exactly that duration elapses.
+ ///
+ [Fact]
+ public void Enter_BackoffDoublesOnSuccessiveTimerFiredEntries()
+ {
+ // Arrange
+ var fakeTime = new FakeTimeProvider();
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1, timeProvider: fakeTime);
+
+ // (expectedWaitSeconds, _) — the wait used by Enter for that iteration
+ int[] expectedWaits = [5, 10, 20, 40, 60, 60];
+
+ // Act & Assert
+ foreach (int wait in expectedWaits)
+ {
+ state.Enter(new Exception($"attempt at wait={wait}s"));
+
+ // One second before due time: error still active
+ fakeTime.Advance(TimeSpan.FromSeconds(wait - 1));
+ Assert.True(state.HasError, $"HasError should be true after {wait - 1}s (scheduled wait={wait}s)");
+
+ // Final second: timer fires, error clears
+ fakeTime.Advance(TimeSpan.FromSeconds(1));
+ Assert.False(state.HasError, $"HasError should be false after {wait}s (scheduled wait={wait}s)");
+ }
+ }
+
+ ///
+ /// Verifies that a timer-driven exit does NOT reset the backoff. The accumulated
+ /// backoff is preserved so the next failure uses the doubled wait, reflecting
+ /// continued instability. Only resets
+ /// the backoff to the initial value. In this way, we only reset the backoff when
+ /// a connection is successfully established.
+ ///
+ [Fact]
+ public void Enter_WhenTimerFires_DoesNotResetBackoff()
+ {
+ // Arrange
+ var fakeTime = new FakeTimeProvider();
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1, timeProvider: fakeTime);
+
+ // First enter: uses 5s wait; _nextWait advances to 10s
+ state.Enter(new Exception("first"));
+ fakeTime.Advance(TimeSpan.FromSeconds(5)); // timer fires
+ Assert.False(state.HasError);
+
+ // Act: enter again — should use 10s, not the initial 5s
+ state.Enter(new Exception("second"));
+
+ // Assert: not cleared after 9s
+ fakeTime.Advance(TimeSpan.FromSeconds(9));
+ Assert.True(state.HasError);
+
+ // Assert: cleared after the full 10s
+ fakeTime.Advance(TimeSpan.FromSeconds(1));
+ Assert.False(state.HasError);
+ }
+
+ ///
+ /// Verifies that resets the backoff
+ /// to the initial 5-second wait even after the timer has doubled it, so the next
+ /// enter cycle starts fresh.
+ ///
+ [Fact]
+ public void Clear_AfterTimerFiredEntry_ResetsBackoffToInitialWait()
+ {
+ // Arrange
+ var fakeTime = new FakeTimeProvider();
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1, timeProvider: fakeTime);
+
+ // First enter/timer-exit: _nextWait advances from 5s to 10s
+ state.Enter(new Exception("first"));
+ fakeTime.Advance(TimeSpan.FromSeconds(5));
+ Assert.False(state.HasError);
+
+ // Second enter to accumulate more backoff; then Clear resets it
+ state.Enter(new Exception("second")); // _nextWait advances to 20s
+ state.Clear(); // _nextWait resets to 5s
+
+ // Act: enter again — should use the initial 5s wait
+ state.Enter(new Exception("third"));
+
+ // Assert: not cleared after 4s
+ fakeTime.Advance(TimeSpan.FromSeconds(4));
+ Assert.True(state.HasError);
+
+ // Assert: cleared after the initial 5s
+ fakeTime.Advance(TimeSpan.FromSeconds(1));
+ Assert.False(state.HasError);
+ }
+
+ #endregion
+
+ #region Timer behavior
+
+ ///
+ /// Verifies that the timer-driven exit invokes the onExit callback, the same
+ /// callback path used by .
+ ///
+ [Fact]
+ public void Enter_WhenTimerFires_InvokesOnExitCallback()
+ {
+ // Arrange
+ var fakeTime = new FakeTimeProvider();
+ int exitCount = 0;
+ using var state = new BlockingPeriodErrorState(
+ ownerPoolId: 1,
+ onExit: () => exitCount++,
+ timeProvider: fakeTime);
+ state.Enter(new Exception());
+
+ // Act
+ fakeTime.Advance(TimeSpan.FromSeconds(5));
+
+ // Assert
+ Assert.Equal(1, exitCount);
+ Assert.False(state.HasError);
+ }
+
+ ///
+ /// Verifies that the timer does not fire before its due time, confirming the
+ /// scheduled wait is respected and not fired early.
+ ///
+ [Fact]
+ public void Enter_TimerDoesNotFireBeforeDueTime()
+ {
+ // Arrange
+ var fakeTime = new FakeTimeProvider();
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 1, timeProvider: fakeTime);
+ state.Enter(new Exception());
+
+ // Act: advance to 1ms before the 5s due time
+ fakeTime.Advance(TimeSpan.FromMilliseconds(4999));
+
+ // Assert
+ Assert.True(state.HasError);
+ }
+
+ #endregion
+
+ #region IDisposable
+
+ ///
+ /// Verifies that on an initial state
+ /// does not throw and completes successfully.
+ ///
+ [Fact]
+ public void Dispose_OnInitialState_DoesNotThrow()
+ {
+ // Arrange & Act
+ var state = new BlockingPeriodErrorState(ownerPoolId: 1);
+
+ // Assert
+ state.Dispose(); // Should not throw
+ }
+
+ ///
+ /// Verifies that clears the cached
+ /// error state so is false after disposal.
+ ///
+ [Fact]
+ public void Dispose_AfterEnter_ClearsHasError()
+ {
+ // Arrange
+ var state = new BlockingPeriodErrorState(ownerPoolId: 1);
+ state.Enter(new Exception());
+
+ // Act
+ state.Dispose();
+
+ // Assert
+ Assert.False(state.HasError);
+ }
+
+ ///
+ /// Verifies that is idempotent:
+ /// calling it multiple times does not throw and completes successfully.
+ ///
+ [Fact]
+ public void Dispose_CalledMultipleTimes_DoesNotThrow()
+ {
+ // Arrange & Act
+ var state = new BlockingPeriodErrorState(ownerPoolId: 1);
+ state.Dispose();
+
+ // Assert
+ state.Dispose(); // Must be idempotent and not throw
+ }
+
+ ///
+ /// Verifies that does not invoke
+ /// the onExit callback because disposal is a resource-cleanup path, not a logical
+ /// "exit blocking period" event.
+ ///
+ [Fact]
+ public void Dispose_DoesNotInvokeOnExitCallback()
+ {
+ // Arrange
+ int exitCount = 0;
+ var state = new BlockingPeriodErrorState(ownerPoolId: 1, onExit: () => exitCount++);
+ state.Enter(new Exception());
+
+ // Act
+ state.Dispose();
+
+ // Assert
+ Assert.Equal(0, exitCount);
+ }
+
+ ///
+ /// Verifies that properly releases
+ /// and cancels the internal exit timer, preventing stale callbacks from firing after disposal.
+ /// Uses to advance time deterministically past the timer's due
+ /// time without relying on real-time sleeps.
+ ///
+ [Fact]
+ public void Dispose_ReleasesTimer_NoCallbackAfterDispose()
+ {
+ // Arrange
+ var fakeTime = new FakeTimeProvider();
+ int exitCount = 0;
+ var state = new BlockingPeriodErrorState(
+ ownerPoolId: 1,
+ onExit: () => Interlocked.Increment(ref exitCount),
+ timeProvider: fakeTime);
+ state.Enter(new Exception());
+
+ // Act: dispose cancels the pending timer
+ state.Dispose();
+
+ // Advance well past the 5s due time — the cancelled timer must not fire
+ fakeTime.Advance(TimeSpan.FromSeconds(60));
+
+ // Assert
+ Assert.Equal(0, exitCount);
+ }
+
+ ///
+ /// Verifies that works correctly in a standard
+ /// using statement, with no exceptions thrown during disposal.
+ ///
+ [Fact]
+ public void Dispose_WithUsingStatement_DoesNotThrow()
+ {
+ // Arrange & Act
+ using (var state = new BlockingPeriodErrorState(ownerPoolId: 1))
+ {
+ state.Enter(new Exception());
+ Assert.True(state.HasError);
+ }
+
+ // Assert
+ // No exception expected when the using block exits
+ }
+
+ #endregion
+
+ #region Callback behaviour
+
+ ///
+ /// Verifies that both onEnter and onExit callbacks are optional (nullable)
+ /// and the instance works correctly when neither is provided.
+ ///
+ [Fact]
+ public void Callbacks_AreNotRequiredAndDefaultToNull()
+ {
+ // Arrange & Act
+ using var state = new BlockingPeriodErrorState(ownerPoolId: 42);
+ state.Enter(new Exception());
+
+ // Assert
+ state.Clear(); // Should work without callbacks
+ }
+
+ ///
+ /// Verifies that the onEnter callback is invoked after the internal lock is released.
+ /// The callback reads and calls
+ /// — operations that are safe only
+ /// when the lock is not held. If the implementation were changed to hold the lock during
+ /// the callback invocation, any re-entrant call from the callback that tries to acquire the
+ /// same lock (on a non-re-entrant lock) would deadlock.
+ ///
+ [Fact]
+ public void OnEnter_CalledOutsideLock_CanCallBackIntoState()
+ {
+ // Arrange
+ bool hasErrorObservedInCallback = false;
+ BlockingPeriodErrorState? state = null;
+ using (state = new BlockingPeriodErrorState(
+ ownerPoolId: 1,
+ onEnter: () =>
+ {
+ // Observe state from inside the callback.
+ // HasError must already be true at this point.
+ hasErrorObservedInCallback = state!.HasError;
+
+ // Calling ThrowIfActive from the callback must not deadlock.
+ Assert.Throws(() => state.ThrowIfActive());
+ }))
+ {
+
+ // Act
+ state.Enter(new InvalidOperationException("test"));
+ }
+
+ // Assert
+ Assert.True(hasErrorObservedInCallback);
+ }
+
+ ///
+ /// Verifies that the onExit callback is invoked after the internal lock is released.
+ /// The callback reads — confirming it
+ /// observes the cleared state — and calls
+ /// without deadlocking. If the implementation were changed to hold the lock during the
+ /// callback, any re-entrant call from the callback would deadlock on a non-re-entrant lock.
+ ///
+ [Fact]
+ public void OnExit_CalledOutsideLock_CanCallBackIntoState()
+ {
+ // Arrange
+ bool hasErrorObservedInCallback = true; // initialized to true; must be false after Clear
+ BlockingPeriodErrorState? state = null;
+ using (state = new BlockingPeriodErrorState(
+ ownerPoolId: 1,
+ onExit: () =>
+ {
+ // HasError must already be false when onExit is called.
+ hasErrorObservedInCallback = state!.HasError;
+
+ // Calling ThrowIfActive from the callback must not deadlock.
+ state.ThrowIfActive(); // must not throw
+ }))
+ {
+
+ // Act
+ state.Enter(new Exception());
+ state.Clear();
+ }
+
+ // Assert
+ Assert.False(hasErrorObservedInCallback);
+ }
+
+ #endregion
+ }
+
+ ///
+ /// 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/ChannelDbConnectionPoolTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs
index b79baefbd5..d8b6d49297 100644
--- a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs
@@ -3,9 +3,11 @@
// See the LICENSE file in the project root for more information.
using System;
+using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Data.Common;
using System.Threading;
+using System.Threading.RateLimiting;
using System.Threading.Tasks;
using System.Transactions;
using Microsoft.Data.Common;
@@ -18,16 +20,32 @@
namespace Microsoft.Data.SqlClient.UnitTests.ConnectionPool
{
+ ///
+ /// Unit tests for covering connection acquisition,
+ /// timeouts, reuse, pool clearing, blocking-period behavior, and timeout-budget propagation.
+ ///
public class ChannelDbConnectionPoolTest
{
private static readonly SqlConnectionFactory SuccessfulConnectionFactory = new SuccessfulSqlConnectionFactory();
private static readonly SqlConnectionFactory TimeoutConnectionFactory = new TimeoutSqlConnectionFactory();
+ ///
+ /// Creates a with configurable test dependencies so
+ /// individual tests can focus on the behavior under test without repeating setup logic.
+ ///
+ /// The factory used to create physical connections.
+ /// Optional pool identity override.
+ /// Optional pool group override.
+ /// Optional pool options override.
+ /// Optional provider info override.
+ /// Optional rate limiter controlling physical connection creation.
+ /// A configured instance for testing.
private ChannelDbConnectionPool ConstructPool(SqlConnectionFactory connectionFactory,
DbConnectionPoolIdentity? identity = null,
DbConnectionPoolGroup? dbConnectionPoolGroup = null,
DbConnectionPoolGroupOptions? poolGroupOptions = null,
- DbConnectionPoolProviderInfo? connectionPoolProviderInfo = null)
+ DbConnectionPoolProviderInfo? connectionPoolProviderInfo = null,
+ RateLimiter? connectionCreationRateLimiter = null)
{
poolGroupOptions ??= new DbConnectionPoolGroupOptions(
poolByIdentity: false,
@@ -47,10 +65,15 @@ private ChannelDbConnectionPool ConstructPool(SqlConnectionFactory connectionFac
connectionFactory,
dbConnectionPoolGroup,
identity ?? DbConnectionPoolIdentity.NoIdentity,
- connectionPoolProviderInfo ?? new DbConnectionPoolProviderInfo()
+ connectionPoolProviderInfo ?? new DbConnectionPoolProviderInfo(),
+ connectionCreationRateLimiter
);
}
+ ///
+ /// Verifies that requesting connections from an empty pool causes the pool to create new
+ /// physical connections until the requested count is reached.
+ ///
[Theory]
[InlineData(1)]
[InlineData(5)]
@@ -75,11 +98,14 @@ out DbConnectionInternal? internalConnection
Assert.NotNull(internalConnection);
}
-
// Assert
Assert.Equal(numConnections, pool.Count);
}
+ ///
+ /// Verifies that asynchronous requests against an empty pool create new physical
+ /// connections and complete through the provided task completion source.
+ ///
[Theory]
[InlineData(1)]
[InlineData(5)]
@@ -106,11 +132,14 @@ out DbConnectionInternal? internalConnection
Assert.NotNull(await tcs.Task);
}
-
// Assert
Assert.Equal(numConnections, pool.Count);
}
+ ///
+ /// Verifies that a synchronous request against an exhausted pool fails with the pooled-open
+ /// timeout once the caller's timeout budget has already expired.
+ ///
[Fact]
public void GetConnectionMaxPoolSize_ShouldTimeoutAfterPeriod()
{
@@ -126,6 +155,7 @@ public void GetConnectionMaxPoolSize_ShouldTimeoutAfterPeriod()
out DbConnectionInternal? internalConnection
);
+ // Assert
Assert.True(completed);
Assert.NotNull(internalConnection);
}
@@ -154,6 +184,10 @@ out DbConnectionInternal? internalConnection
Assert.Equal(pool.PoolGroupOptions.MaxPoolSize, pool.Count);
}
+ ///
+ /// Verifies that an asynchronous request against an exhausted pool completes with the
+ /// pooled-open timeout once the caller's timeout budget has already expired.
+ ///
[Fact]
public async Task GetConnectionAsyncMaxPoolSize_ShouldTimeoutAfterPeriod()
{
@@ -169,6 +203,7 @@ public async Task GetConnectionAsyncMaxPoolSize_ShouldTimeoutAfterPeriod()
out DbConnectionInternal? internalConnection
);
+ // Assert
Assert.True(completed);
Assert.NotNull(internalConnection);
}
@@ -195,6 +230,10 @@ out DbConnectionInternal? internalConnection
Assert.Equal(pool.PoolGroupOptions.MaxPoolSize, pool.Count);
}
+ ///
+ /// Verifies that a waiting synchronous caller reuses a connection that is returned to an
+ /// exhausted pool instead of creating a new physical connection.
+ ///
[Fact]
public async Task GetConnectionMaxPoolSize_ShouldReuseAfterConnectionReleased()
{
@@ -218,16 +257,15 @@ out DbConnectionInternal? firstConnection
out DbConnectionInternal? internalConnection
);
+ // Assert
Assert.True(completed);
Assert.NotNull(internalConnection);
}
- TaskCompletionSource tcs = new();
-
// Act
var task = Task.Run(() =>
{
- var exceeded = pool.TryGetConnection(
+ pool.TryGetConnection(
new SqlConnection(""),
taskCompletionSource: null,
TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
@@ -242,6 +280,10 @@ out DbConnectionInternal? extraConnection
Assert.Equal(firstConnection, extraConnection);
}
+ ///
+ /// Verifies that a waiting asynchronous caller reuses a connection that is returned to an
+ /// exhausted pool instead of creating a new physical connection.
+ ///
[Fact]
public async Task GetConnectionAsyncMaxPoolSize_ShouldReuseAfterConnectionReleased()
{
@@ -265,6 +307,7 @@ out DbConnectionInternal? firstConnection
out DbConnectionInternal? internalConnection
);
+ // Assert
Assert.True(completed);
Assert.NotNull(internalConnection);
}
@@ -272,7 +315,7 @@ out DbConnectionInternal? internalConnection
TaskCompletionSource taskCompletionSource = new();
// Act
- var exceeded = pool.TryGetConnection(
+ pool.TryGetConnection(
new SqlConnection(""),
taskCompletionSource,
TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
@@ -285,6 +328,10 @@ out DbConnectionInternal? recycledConnection
Assert.Equal(firstConnection, recycledConnection);
}
+ ///
+ /// Verifies that synchronous waiters are served in request order when the pool is full,
+ /// ensuring the first queued request receives the next returned connection.
+ ///
[Fact]
[ActiveIssue("https://github.com/dotnet/SqlClient/issues/3730")]
public async Task GetConnectionMaxPoolSize_ShouldRespectOrderOfRequest()
@@ -309,6 +356,7 @@ out DbConnectionInternal? firstConnection
out DbConnectionInternal? internalConnection
);
+ // Assert
Assert.True(completed);
Assert.NotNull(internalConnection);
}
@@ -354,6 +402,10 @@ out DbConnectionInternal? failedConnection
await Assert.ThrowsAsync(async () => await failedTask);
}
+ ///
+ /// Verifies that asynchronous waiters are served in request order when the pool is full,
+ /// ensuring the first queued request receives the next returned connection.
+ ///
[Fact]
[ActiveIssue("https://github.com/dotnet/SqlClient/issues/3730")]
public async Task GetConnectionAsyncMaxPoolSize_ShouldRespectOrderOfRequest()
@@ -378,6 +430,7 @@ out DbConnectionInternal? firstConnection
out DbConnectionInternal? internalConnection
);
+ // Assert
Assert.True(completed);
Assert.NotNull(internalConnection);
}
@@ -386,7 +439,7 @@ out DbConnectionInternal? internalConnection
TaskCompletionSource failedCompletionSource = new();
// Act
- var exceeded = pool.TryGetConnection(
+ pool.TryGetConnection(
new SqlConnection(""),
recycledTaskCompletionSource,
TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
@@ -396,7 +449,7 @@ out DbConnectionInternal? recycledConnection
// Gives time for the recycled connection to be queued before the failed request is initiated.
await Task.Delay(1000);
- var exceeded2 = pool.TryGetConnection(
+ pool.TryGetConnection(
new SqlConnection("Timeout=1"),
failedCompletionSource,
TimeoutTimer.StartNew(TimeSpan.FromSeconds(1)),
@@ -411,6 +464,10 @@ out DbConnectionInternal? failedConnection
await Assert.ThrowsAsync(async () => failedConnection = await failedCompletionSource.Task);
}
+ ///
+ /// Verifies that a connection returned to the idle channel is reused by a subsequent
+ /// request instead of allocating a new internal connection.
+ ///
[Fact]
public void ConnectionsAreReused()
{
@@ -447,6 +504,10 @@ out DbConnectionInternal? internalConnection2
Assert.Same(internalConnection1, internalConnection2);
}
+ ///
+ /// Verifies that synchronous connection creation failures propagate the pooled-open timeout
+ /// exception from the connection factory.
+ ///
[Fact]
public void GetConnectionTimeout_ShouldThrowTimeoutException()
{
@@ -469,6 +530,10 @@ out DbConnectionInternal? internalConnection
Assert.Equal(ADP.PooledOpenTimeout().Message, ex.Message);
}
+ ///
+ /// Verifies that asynchronous connection creation failures propagate the pooled-open timeout
+ /// exception through the caller's task completion source.
+ ///
[Fact]
public async Task GetConnectionAsyncTimeout_ShouldThrowTimeoutException()
{
@@ -494,13 +559,18 @@ out DbConnectionInternal? internalConnection
Assert.Equal(ADP.PooledOpenTimeout().Message, ex.Message);
}
+ ///
+ /// Verifies under concurrent synchronous load that the pool never grows beyond its
+ /// configured maximum size and continues to serve requests safely.
+ ///
[Fact]
public void StressTest()
{
- //Arrange
+ // Arrange
var pool = ConstructPool(SuccessfulConnectionFactory);
ConcurrentBag tasks = new();
+ // Act
for (int i = 1; i < pool.PoolGroupOptions.MaxPoolSize * 3; i++)
{
var t = Task.Run(() =>
@@ -524,16 +594,23 @@ out DbConnectionInternal? internalConnection
}
Task.WaitAll(tasks.ToArray());
+
+ // Assert
Assert.True(pool.Count <= pool.PoolGroupOptions.MaxPoolSize, "Pool size exceeded max pool size after stress test.");
}
+ ///
+ /// Verifies under concurrent asynchronous load that the pool never grows beyond its
+ /// configured maximum size and continues to serve requests safely.
+ ///
[Fact]
public void StressTestAsync()
{
- //Arrange
+ // Arrange
var pool = ConstructPool(SuccessfulConnectionFactory);
ConcurrentBag tasks = new();
+ // Act
for (int i = 1; i < pool.PoolGroupOptions.MaxPoolSize * 3; i++)
{
var t = Task.Run(async () =>
@@ -555,58 +632,102 @@ out DbConnectionInternal? internalConnection
}
Task.WaitAll(tasks.ToArray());
+
+ // Assert
Assert.True(pool.Count <= pool.PoolGroupOptions.MaxPoolSize, "Pool size exceeded max pool size after stress test.");
}
#region Property Tests
+ ///
+ /// Verifies that the pool exposes the instance it was
+ /// constructed with.
+ ///
[Fact]
public void TestConnectionFactory()
{
+ // Arrange
var pool = ConstructPool(SuccessfulConnectionFactory);
+
+ // Act & Assert
Assert.Equal(SuccessfulConnectionFactory, pool.ConnectionFactory);
}
+ ///
+ /// Verifies that a newly constructed pool starts with zero tracked connections.
+ ///
[Fact]
public void TestCount()
{
+ // Arrange
var pool = ConstructPool(SuccessfulConnectionFactory);
+
+ // Act & Assert
Assert.Equal(0, pool.Count);
}
+ ///
+ /// Verifies that a newly constructed pool reports no blocking-period error by default.
+ ///
[Fact]
public void TestErrorOccurred()
{
+ // Arrange
var pool = ConstructPool(SuccessfulConnectionFactory);
+
+ // Act & Assert
Assert.False(pool.ErrorOccurred);
}
+ ///
+ /// Verifies that the pool assigns a positive instance identifier at construction time.
+ ///
[Fact]
public void TestId()
{
+ // Arrange
var pool = ConstructPool(SuccessfulConnectionFactory);
+
+ // Act & Assert
Assert.True(pool.Id >= 1);
}
+ ///
+ /// Verifies that the pool exposes the identity object it was constructed with.
+ ///
[Fact]
public void TestIdentity()
{
+ // Arrange
var identity = DbConnectionPoolIdentity.GetCurrent();
var pool = ConstructPool(SuccessfulConnectionFactory, identity);
+
+ // Act & Assert
Assert.Equal(identity, pool.Identity);
}
+ ///
+ /// Verifies that a newly constructed pool begins in the running state.
+ ///
[Fact]
public void TestIsRunning()
{
+ // Arrange
var pool = ConstructPool(SuccessfulConnectionFactory);
+
+ // Act & Assert
Assert.True(pool.IsRunning);
}
+ ///
+ /// Verifies that the pool exposes the configured load-balance timeout from its pool group
+ /// options.
+ ///
[Fact]
public void TestLoadBalanceTimeout()
{
+ // Arrange
var poolGroupOptions = new DbConnectionPoolGroupOptions(
poolByIdentity: false,
minPoolSize: 0,
@@ -617,12 +738,19 @@ public void TestLoadBalanceTimeout()
idleTimeout: 0
);
var pool = ConstructPool(SuccessfulConnectionFactory, poolGroupOptions: poolGroupOptions);
+
+ // Act & Assert
Assert.Equal(poolGroupOptions.LoadBalanceTimeout, pool.LoadBalanceTimeout);
}
+ ///
+ /// Verifies that the pool exposes the exact instance it
+ /// was constructed with.
+ ///
[Fact]
public void TestPoolGroup()
{
+ // Arrange
var dbConnectionPoolGroup = new DbConnectionPoolGroup(
new SqlConnectionOptions("Data Source=localhost;"),
new ConnectionPoolKey("TestDataSource", credential: null, accessToken: null, accessTokenCallback: null, sspiContextProvider: null),
@@ -635,12 +763,19 @@ public void TestPoolGroup()
hasTransactionAffinity: true,
idleTimeout: 0));
var pool = ConstructPool(SuccessfulConnectionFactory, dbConnectionPoolGroup: dbConnectionPoolGroup);
+
+ // Act & Assert
Assert.Equal(dbConnectionPoolGroup, pool.PoolGroup);
}
+ ///
+ /// Verifies that the pool exposes the exact
+ /// instance it was constructed with.
+ ///
[Fact]
public void TestPoolGroupOptions()
{
+ // Arrange
var poolGroupOptions = new DbConnectionPoolGroupOptions(
poolByIdentity: false,
minPoolSize: 0,
@@ -650,34 +785,61 @@ public void TestPoolGroupOptions()
hasTransactionAffinity: true,
idleTimeout: 0);
var pool = ConstructPool(SuccessfulConnectionFactory, poolGroupOptions: poolGroupOptions);
+
+ // Act & Assert
Assert.Equal(poolGroupOptions, pool.PoolGroupOptions);
}
+ ///
+ /// Verifies that the pool exposes the provider info object it was constructed with.
+ ///
[Fact]
public void TestProviderInfo()
{
+ // Arrange
var connectionPoolProviderInfo = new DbConnectionPoolProviderInfo();
var pool = ConstructPool(SuccessfulConnectionFactory, connectionPoolProviderInfo: connectionPoolProviderInfo);
+
+ // Act & Assert
Assert.Equal(connectionPoolProviderInfo, pool.ProviderInfo);
}
+ ///
+ /// Verifies that the pool state getter reports
+ /// immediately after construction.
+ ///
[Fact]
public void TestStateGetter()
{
+ // Arrange
var pool = ConstructPool(SuccessfulConnectionFactory);
+
+ // Act & Assert
Assert.Equal(DbConnectionPoolState.Running, pool.State);
}
+ ///
+ /// Verifies that the pool state remains after
+ /// construction when no shutdown has been requested.
+ ///
[Fact]
public void TestStateSetter()
{
+ // Arrange
var pool = ConstructPool(SuccessfulConnectionFactory);
+
+ // Act & Assert
Assert.Equal(DbConnectionPoolState.Running, pool.State);
}
+ ///
+ /// Verifies that the pool exposes whether load balancing is enabled based on its configured
+ /// pool group options.
+ ///
[Fact]
public void TestUseLoadBalancing()
{
+ // Arrange
var poolGroupOptions = new DbConnectionPoolGroupOptions(
poolByIdentity: false,
minPoolSize: 0,
@@ -687,6 +849,8 @@ public void TestUseLoadBalancing()
hasTransactionAffinity: true,
idleTimeout: 0);
var pool = ConstructPool(SuccessfulConnectionFactory, poolGroupOptions: poolGroupOptions);
+
+ // Act & Assert
Assert.Equal(poolGroupOptions.UseLoadBalancing, pool.UseLoadBalancing);
}
@@ -694,41 +858,71 @@ public void TestUseLoadBalancing()
#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 TestReplaceConnection()
{
+ // Arrange
var pool = ConstructPool(SuccessfulConnectionFactory);
+
+ // Act & Assert
Assert.Throws(() => pool.ReplaceConnection(null!, null!, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15))));
}
+ ///
+ /// 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
+ ///
+ /// Verifies that clearing an empty pool is a no-op and leaves the pool in a valid state.
+ ///
[Fact]
public void Clear_EmptyPool_DoesNotThrow()
{
// Arrange
var pool = ConstructPool(SuccessfulConnectionFactory);
- // Act & Assert - Should complete without error
+ // Act
pool.Clear();
+
+ // Assert
Assert.Equal(0, pool.Count);
}
+ ///
+ /// Verifies that clearing a pool with only idle connections destroys them immediately and
+ /// leaves the pool empty.
+ ///
[Fact]
public void Clear_MultipleIdleConnections_AllAreDestroyed()
{
@@ -763,6 +957,10 @@ out internalConnections[i]
Assert.Equal(0, pool.Count);
}
+ ///
+ /// Verifies that clearing the pool does not immediately destroy a connection that is still
+ /// checked out by a caller.
+ ///
[Fact]
public void Clear_BusyConnection_NotDestroyedImmediately()
{
@@ -787,6 +985,10 @@ out DbConnectionInternal? busyConnection
Assert.Equal(0, busyConnection.ClearGeneration);
}
+ ///
+ /// Verifies that a busy connection checked out during
+ /// is destroyed when it is later returned because its generation is stale.
+ ///
[Fact]
public void Clear_BusyConnectionReturned_IsDestroyed()
{
@@ -816,6 +1018,10 @@ out DbConnectionInternal? busyConnection
Assert.Equal(0, pool.Count);
}
+ ///
+ /// Verifies that clearing a pool with both busy and idle connections destroys only the idle
+ /// connections immediately and defers busy-connection cleanup until return.
+ ///
[Fact]
public void Clear_MixedBusyAndIdle_OnlyIdleDestroyedImmediately()
{
@@ -856,6 +1062,10 @@ out DbConnectionInternal? idleConnection
Assert.Equal(0, pool.Count);
}
+ ///
+ /// Verifies that connections created after a clear are stamped with the new generation and
+ /// are pooled and reused normally.
+ ///
[Fact]
public void Clear_NewConnectionsAfterClear_ArePooledNormally()
{
@@ -905,6 +1115,10 @@ out DbConnectionInternal? reusedConnection
Assert.Equal(1, reusedConnection!.ClearGeneration);
}
+ ///
+ /// Verifies that repeated clear operations do not corrupt pool state and that each clear
+ /// increments the pool generation as expected.
+ ///
[Fact]
public void Clear_MultipleClearCalls_DoNotCorruptState()
{
@@ -1157,10 +1371,22 @@ private static void BackdateReturnedTime(DbConnectionInternal connection, TimeSp
#endregion
#region Test classes
+
+ ///
+ /// Test connection factory that always succeeds and captures the timeout budget passed in by
+ /// the pool so timeout propagation can be asserted.
+ ///
internal class SuccessfulSqlConnectionFactory : SqlConnectionFactory
{
+ ///
+ /// Gets the last timeout budget passed through by the pool to the factory.
+ ///
internal TimeoutTimer? CapturedTimeout { get; private set; }
+ ///
+ /// Creates a successful stub internal connection and records the timeout budget used for
+ /// the creation attempt.
+ ///
protected override DbConnectionInternal CreateConnection(
SqlConnectionOptions options,
ConnectionPoolKey poolKey,
@@ -1174,8 +1400,16 @@ protected override DbConnectionInternal CreateConnection(
}
}
+ ///
+ /// Test connection factory that always throws the pooled-open timeout to exercise failure
+ /// paths in the pool.
+ ///
internal class TimeoutSqlConnectionFactory : SqlConnectionFactory
{
+ ///
+ /// Throws the pooled-open timeout exception to simulate a failed physical connection
+ /// creation.
+ ///
protected override DbConnectionInternal CreateConnection(
SqlConnectionOptions options,
ConnectionPoolKey poolKey,
@@ -1188,6 +1422,10 @@ protected override DbConnectionInternal CreateConnection(
}
}
+ ///
+ /// Minimal test double used by the pool tests to avoid
+ /// involving a real provider-specific connection implementation.
+ ///
internal class StubDbConnectionInternal : DbConnectionInternal
{
#region Not Implemented Members
@@ -1221,6 +1459,10 @@ internal override void ResetConnection()
}
#endregion
+ ///
+ /// Verifies that constructing the pool with a zero max pool size fails with the expected
+ /// capacity validation error.
+ ///
[Fact]
public void Constructor_WithZeroMaxPoolSize_ThrowsArgumentOutOfRangeException()
{
@@ -1240,7 +1482,7 @@ public void Constructor_WithZeroMaxPoolSize_ThrowsArgumentOutOfRangeException()
poolGroupOptions
);
- // Act & Assert
+ // Act
var exception = Assert.Throws(() =>
new ChannelDbConnectionPool(
SuccessfulConnectionFactory,
@@ -1248,15 +1490,20 @@ public void Constructor_WithZeroMaxPoolSize_ThrowsArgumentOutOfRangeException()
DbConnectionPoolIdentity.NoIdentity,
new DbConnectionPoolProviderInfo()
));
-
+
+ // Assert
Assert.Equal("fixedCapacity", exception.ParamName);
Assert.Contains("Capacity must be greater than zero", exception.Message);
}
+ ///
+ /// Verifies that large but valid max pool sizes pass capacity validation and either succeed
+ /// or fail only due to memory pressure rather than argument validation.
+ ///
[Fact]
public void Constructor_WithLargeMaxPoolSize()
{
- // Arrange - Test that Int32.MaxValue is accepted as a valid pool size
+ // Arrange
var poolGroupOptions = new DbConnectionPoolGroupOptions(
poolByIdentity: false,
minPoolSize: 0,
@@ -1274,7 +1521,7 @@ public void Constructor_WithLargeMaxPoolSize()
try
{
- // Act & Assert - This should not throw ArgumentOutOfRangeException, but may throw OutOfMemoryException
+ // Act
var pool = new ChannelDbConnectionPool(
SuccessfulConnectionFactory,
dbConnectionPoolGroup,
@@ -1282,6 +1529,7 @@ public void Constructor_WithLargeMaxPoolSize()
new DbConnectionPoolProviderInfo()
);
+ // Assert
Assert.NotNull(pool);
Assert.Equal(0, pool.Count);
}
@@ -1293,12 +1541,14 @@ public void Constructor_WithLargeMaxPoolSize()
}
}
+ ///
+ /// Verifies that small valid max pool sizes construct successfully and produce usable pool
+ /// instances.
+ ///
[Fact]
public void Constructor_WithValidSmallPoolSizes_WorksCorrectly()
{
- // Arrange - Test various small pool sizes that should work correctly
-
- // Test with pool size of 1
+ // Arrange
var poolGroupOptions1 = new DbConnectionPoolGroupOptions(
poolByIdentity: false,
minPoolSize: 0,
@@ -1314,7 +1564,7 @@ public void Constructor_WithValidSmallPoolSizes_WorksCorrectly()
poolGroupOptions1
);
- // Act & Assert - Pool size of 1 should work
+ // Act
var pool1 = new ChannelDbConnectionPool(
SuccessfulConnectionFactory,
dbConnectionPoolGroup1,
@@ -1322,10 +1572,11 @@ public void Constructor_WithValidSmallPoolSizes_WorksCorrectly()
new DbConnectionPoolProviderInfo()
);
+ // Assert
Assert.NotNull(pool1);
Assert.Equal(0, pool1.Count);
- // Test with pool size of 2
+ // Arrange
var poolGroupOptions2 = new DbConnectionPoolGroupOptions(
poolByIdentity: false,
minPoolSize: 0,
@@ -1341,6 +1592,7 @@ public void Constructor_WithValidSmallPoolSizes_WorksCorrectly()
poolGroupOptions2
);
+ // Act
var pool2 = new ChannelDbConnectionPool(
SuccessfulConnectionFactory,
dbConnectionPoolGroup2,
@@ -1348,10 +1600,564 @@ public void Constructor_WithValidSmallPoolSizes_WorksCorrectly()
new DbConnectionPoolProviderInfo()
);
+ // Assert
Assert.NotNull(pool2);
Assert.Equal(0, pool2.Count);
}
+ #region Rate Limiting And Blocking Period Tests
+
+ ///
+ /// Verifies that a connection creation failure enters the blocking-period error state when
+ /// blocking is enabled for the pool.
+ ///
+ [Fact]
+ public void ErrorOccurred_FailureWithBlockingEnabled_BecomesTrue()
+ {
+ // Arrange
+ // Default PoolBlockingPeriod is Auto; localhost is non-Azure so blocking is enabled.
+ var dbConnectionPoolGroup = new DbConnectionPoolGroup(
+ new SqlConnectionOptions("Data Source=localhost;"),
+ new ConnectionPoolKey("TestDataSource", credential: null, accessToken: null, accessTokenCallback: null, sspiContextProvider: null),
+ new DbConnectionPoolGroupOptions(
+ poolByIdentity: false,
+ minPoolSize: 0,
+ maxPoolSize: 4,
+ creationTimeout: 15,
+ loadBalanceTimeout: 0,
+ hasTransactionAffinity: true));
+ var pool = ConstructPool(TimeoutConnectionFactory, dbConnectionPoolGroup: dbConnectionPoolGroup);
+
+ // Act
+ Assert.False(pool.ErrorOccurred);
+
+ Assert.Throws(() =>
+ pool.TryGetConnection(new SqlConnection(), taskCompletionSource: null, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out _));
+
+ // Assert
+ Assert.True(pool.ErrorOccurred);
+ }
+
+ ///
+ /// Verifies that a connection creation failure does not enter the blocking-period error state
+ /// when the connection string disables blocking with NeverBlock.
+ ///
+ [Fact]
+ public void ErrorOccurred_FailureWithNeverBlock_StaysFalse()
+ {
+ // Arrange
+ var dbConnectionPoolGroup = new DbConnectionPoolGroup(
+ new SqlConnectionOptions("Data Source=localhost;Pool Blocking Period=NeverBlock;"),
+ new ConnectionPoolKey("TestDataSource", credential: null, accessToken: null, accessTokenCallback: null, sspiContextProvider: null),
+ new DbConnectionPoolGroupOptions(
+ poolByIdentity: false,
+ minPoolSize: 0,
+ maxPoolSize: 4,
+ creationTimeout: 15,
+ loadBalanceTimeout: 0,
+ hasTransactionAffinity: true));
+ var pool = ConstructPool(TimeoutConnectionFactory, dbConnectionPoolGroup: dbConnectionPoolGroup);
+
+ // Act
+ Assert.Throws(() =>
+ pool.TryGetConnection(new SqlConnection(), taskCompletionSource: null, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out _));
+
+ // Assert - FR-007: NeverBlock must not enter the error state.
+ Assert.False(pool.ErrorOccurred);
+ }
+
+ ///
+ /// Verifies that a connection creation failure enters the blocking-period error state when
+ /// the connection string explicitly enables AlwaysBlock.
+ ///
+ [Fact]
+ public void ErrorOccurred_FailureWithAlwaysBlock_BecomesTrue()
+ {
+ // Arrange
+ var dbConnectionPoolGroup = new DbConnectionPoolGroup(
+ new SqlConnectionOptions("Data Source=localhost;Pool Blocking Period=AlwaysBlock;"),
+ new ConnectionPoolKey("TestDataSource", credential: null, accessToken: null, accessTokenCallback: null, sspiContextProvider: null),
+ new DbConnectionPoolGroupOptions(
+ poolByIdentity: false,
+ minPoolSize: 0,
+ maxPoolSize: 4,
+ creationTimeout: 15,
+ loadBalanceTimeout: 0,
+ hasTransactionAffinity: true));
+ var pool = ConstructPool(TimeoutConnectionFactory, dbConnectionPoolGroup: dbConnectionPoolGroup);
+
+ // Act
+ Assert.Throws(() =>
+ pool.TryGetConnection(new SqlConnection(), taskCompletionSource: null, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out _));
+
+ // Assert
+ Assert.True(pool.ErrorOccurred);
+ }
+
+ ///
+ /// Verifies that once the pool enters the blocking period, subsequent synchronous requests
+ /// fail fast with the cached exception without attempting another physical open.
+ ///
+ [Fact]
+ public void ErrorOccurred_BlockingEnabled_SubsequentRequestFastFails()
+ {
+ // Arrange
+ var factory = new CountingTimeoutConnectionFactory();
+ var dbConnectionPoolGroup = new DbConnectionPoolGroup(
+ new SqlConnectionOptions("Data Source=localhost;"),
+ new ConnectionPoolKey("TestDataSource", credential: null, accessToken: null, accessTokenCallback: null, sspiContextProvider: null),
+ new DbConnectionPoolGroupOptions(
+ poolByIdentity: false,
+ minPoolSize: 0,
+ maxPoolSize: 4,
+ creationTimeout: 15,
+ loadBalanceTimeout: 0,
+ hasTransactionAffinity: true));
+ var pool = ConstructPool(factory, dbConnectionPoolGroup: dbConnectionPoolGroup);
+
+ // Act
+ var first = Assert.Throws(() =>
+ pool.TryGetConnection(new SqlConnection(), taskCompletionSource: null, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out _));
+ Assert.True(pool.ErrorOccurred);
+ Assert.Equal(1, factory.CreateCount);
+
+ // FR-006: subsequent requests inside the blocking window must fail fast with the
+ // cached exception without attempting another physical open.
+ var second = Assert.Throws(() =>
+ pool.TryGetConnection(new SqlConnection(), taskCompletionSource: null, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out _));
+
+ // Assert - the second request reused the cached exception and did not invoke
+ // CreateConnection again while the pool remained in the error state.
+ Assert.Equal(first.Message, second.Message);
+ Assert.True(pool.ErrorOccurred);
+ Assert.Equal(1, factory.CreateCount);
+ }
+
+ ///
+ /// Verifies that clearing the pool while in the blocking-period error state resets the
+ /// externally visible error indicator.
+ ///
+ [Fact]
+ public void Clear_InErrorState_ResetsErrorOccurred()
+ {
+ // Arrange
+ var dbConnectionPoolGroup = new DbConnectionPoolGroup(
+ new SqlConnectionOptions("Data Source=localhost;"),
+ new ConnectionPoolKey("TestDataSource", credential: null, accessToken: null, accessTokenCallback: null, sspiContextProvider: null),
+ new DbConnectionPoolGroupOptions(
+ poolByIdentity: false,
+ minPoolSize: 0,
+ maxPoolSize: 4,
+ creationTimeout: 15,
+ loadBalanceTimeout: 0,
+ hasTransactionAffinity: true));
+ var pool = ConstructPool(TimeoutConnectionFactory, dbConnectionPoolGroup: dbConnectionPoolGroup);
+
+ Assert.Throws(() =>
+ pool.TryGetConnection(new SqlConnection(), taskCompletionSource: null, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out _));
+ Assert.True(pool.ErrorOccurred);
+
+ // Act - FR-011: Clear must reset the error state.
+ pool.Clear();
+
+ // Assert
+ Assert.False(pool.ErrorOccurred);
+ }
+
+ ///
+ /// Verifies that a successful connection creation after a prior failure leaves the pool out
+ /// of the blocking-period error state.
+ ///
+ [Fact]
+ public void SuccessfulCreate_AfterFailure_ClearsErrorState()
+ {
+ // Arrange
+ var factory = new ToggleFailureConnectionFactory();
+ var dbConnectionPoolGroup = new DbConnectionPoolGroup(
+ new SqlConnectionOptions("Data Source=localhost;"),
+ new ConnectionPoolKey("TestDataSource", credential: null, accessToken: null, accessTokenCallback: null, sspiContextProvider: null),
+ new DbConnectionPoolGroupOptions(
+ poolByIdentity: false,
+ minPoolSize: 0,
+ maxPoolSize: 4,
+ creationTimeout: 15,
+ loadBalanceTimeout: 0,
+ hasTransactionAffinity: true));
+ var pool = ConstructPool(factory, dbConnectionPoolGroup: dbConnectionPoolGroup);
+
+ // First call fails and enters the error state.
+ factory.FailNextCreate = true;
+ Assert.Throws(() =>
+ pool.TryGetConnection(new SqlConnection(), taskCompletionSource: null, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out _));
+ Assert.True(pool.ErrorOccurred);
+
+ // Manually clear the error flag (simulating the backoff timer firing) and then
+ // verify that a subsequent successful create clears the cached error state. FR-009.
+ pool.Clear();
+ Assert.False(pool.ErrorOccurred);
+
+ factory.FailNextCreate = false;
+
+ // Act
+ var completed = pool.TryGetConnection(new SqlConnection(), taskCompletionSource: null, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out var conn);
+
+ // Assert
+ Assert.True(completed);
+ Assert.NotNull(conn);
+ Assert.False(pool.ErrorOccurred);
+ }
+
+ ///
+ /// Verifies that an available rate-limiter permit allows the pool to create a physical
+ /// connection immediately and that the permit is released after the open completes.
+ ///
+ [Fact]
+ public void RateLimiter_PermitAvailable_CreatesPhysicalConnection()
+ {
+ // Arrange
+ var factory = new CountingSuccessfulConnectionFactory();
+ var rateLimiter = new TestRateLimiter();
+ var pool = ConstructPool(
+ factory,
+ connectionCreationRateLimiter: rateLimiter);
+
+ // Act
+ bool completed = pool.TryGetConnection(
+ new SqlConnection(),
+ taskCompletionSource: null,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
+ out DbConnectionInternal? connection);
+
+ // Assert
+ Assert.True(completed);
+ Assert.NotNull(connection);
+ Assert.Equal(1, factory.CreateCount);
+ Assert.Equal(1, rateLimiter.AttemptAcquireCount);
+ Assert.Equal(0, rateLimiter.OutstandingPermitCount);
+ }
+
+ ///
+ /// Verifies that when the rate limiter denies a new physical open, the caller falls back
+ /// to waiting for an existing connection to be returned instead of forcing a second create.
+ ///
+ [Fact]
+ public async Task RateLimiter_PermitDenied_ReusesReturnedConnection()
+ {
+ // Arrange
+ var factory = new CountingSuccessfulConnectionFactory();
+ var rateLimiter = new TestRateLimiter(true, false);
+ var poolGroupOptions = new DbConnectionPoolGroupOptions(
+ poolByIdentity: false,
+ minPoolSize: 0,
+ maxPoolSize: 2,
+ creationTimeout: 15,
+ loadBalanceTimeout: 0,
+ hasTransactionAffinity: true);
+ var pool = ConstructPool(
+ factory,
+ poolGroupOptions: poolGroupOptions,
+ connectionCreationRateLimiter: rateLimiter);
+ SqlConnection firstOwner = new();
+
+ pool.TryGetConnection(
+ firstOwner,
+ taskCompletionSource: null,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
+ out DbConnectionInternal? firstConnection);
+
+ // Act
+ Task waitingRequest = Task.Run(() =>
+ {
+ pool.TryGetConnection(
+ new SqlConnection(),
+ taskCompletionSource: null,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
+ out DbConnectionInternal? queuedConnection);
+ return queuedConnection;
+ });
+
+ Assert.True(
+ SpinWait.SpinUntil(() => rateLimiter.AttemptAcquireCount == 2, TimeSpan.FromSeconds(5)),
+ "Timed out waiting for the second request to reach the rate limiter.");
+
+ pool.ReturnInternalConnection(firstConnection!, firstOwner);
+ DbConnectionInternal? reusedConnection = await waitingRequest;
+
+ // Assert
+ Assert.Same(firstConnection, reusedConnection);
+ Assert.Equal(1, factory.CreateCount);
+ Assert.Equal(2, rateLimiter.AttemptAcquireCount);
+ Assert.Equal(0, rateLimiter.OutstandingPermitCount);
+ }
+
+ ///
+ /// Verifies that failed connection attempts release any acquired rate-limiter lease so the
+ /// pool does not starve future callers after repeated failures.
+ ///
+ [Fact]
+ public async Task RateLimiter_LeaseDisposedOnFailure_DoesNotStarvePool()
+ {
+ // Arrange
+ // If the rate limiter lease were not disposed on failure, after N failures (where N is
+ // the limiter's permit count) every subsequent request would deadlock. Verify that we
+ // can keep getting failures back without ever blocking the thread pool.
+ var rateLimiter = new TestRateLimiter();
+ var dbConnectionPoolGroup = new DbConnectionPoolGroup(
+ new SqlConnectionOptions("Data Source=localhost;Pool Blocking Period=NeverBlock;"),
+ new ConnectionPoolKey("TestDataSource", credential: null, accessToken: null, accessTokenCallback: null, sspiContextProvider: null),
+ new DbConnectionPoolGroupOptions(
+ poolByIdentity: false,
+ minPoolSize: 0,
+ maxPoolSize: 4,
+ creationTimeout: 15,
+ loadBalanceTimeout: 0,
+ hasTransactionAffinity: true));
+ var pool = ConstructPool(
+ TimeoutConnectionFactory,
+ dbConnectionPoolGroup: dbConnectionPoolGroup,
+ connectionCreationRateLimiter: rateLimiter);
+
+ // Act & Assert
+ for (int i = 0; i < 8; i++)
+ {
+ await Assert.ThrowsAsync(async () =>
+ {
+ var tcs = new TaskCompletionSource();
+ pool.TryGetConnection(new SqlConnection(), tcs, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out _);
+ await tcs.Task;
+ });
+ }
+
+ Assert.Equal(8, rateLimiter.AttemptAcquireCount);
+ Assert.Equal(0, rateLimiter.OutstandingPermitCount);
+ }
+
+ ///
+ /// Test connection factory that can be toggled between failure and success to exercise pool
+ /// recovery behavior after blocking-period entry.
+ ///
+ internal class ToggleFailureConnectionFactory : SqlConnectionFactory
+ {
+ ///
+ /// Gets or sets whether the next connection creation attempt should fail.
+ ///
+ public bool FailNextCreate { get; set; }
+
+ ///
+ /// Creates a stub connection or throws the pooled-open timeout based on
+ /// .
+ ///
+ protected override DbConnectionInternal CreateConnection(
+ SqlConnectionOptions options,
+ ConnectionPoolKey poolKey,
+ DbConnectionPoolGroupProviderInfo poolGroupProviderInfo,
+ IDbConnectionPool pool,
+ DbConnection owningConnection,
+ TimeoutTimer timeout)
+ {
+ if (FailNextCreate)
+ {
+ throw ADP.PooledOpenTimeout();
+ }
+
+ return new StubDbConnectionInternal();
+ }
+ }
+
+ ///
+ /// Test connection factory that always throws the pooled-open timeout and records how many
+ /// physical connection creations the pool attempted, so blocking-period tests can assert
+ /// that subsequent requests fail fast without invoking another open.
+ ///
+ internal sealed class CountingTimeoutConnectionFactory : SqlConnectionFactory
+ {
+ ///
+ /// Gets the number of times the pool asked the factory to create a physical connection.
+ ///
+ internal int CreateCount { get; private set; }
+
+ ///
+ /// Increments the creation counter and throws the pooled-open timeout exception to
+ /// simulate a failed physical connection creation.
+ ///
+ protected override DbConnectionInternal CreateConnection(
+ SqlConnectionOptions options,
+ ConnectionPoolKey poolKey,
+ DbConnectionPoolGroupProviderInfo poolGroupProviderInfo,
+ IDbConnectionPool pool,
+ DbConnection owningConnection,
+ TimeoutTimer timeout)
+ {
+ CreateCount++;
+ throw ADP.PooledOpenTimeout();
+ }
+ }
+
+ ///
+ {
+ ///
+ /// Gets the number of times the pool asked the factory to create a physical connection.
+ ///
+ internal int CreateCount { get; private set; }
+
+ ///
+ /// Creates a successful stub internal connection and increments the creation counter.
+ ///
+ protected override DbConnectionInternal CreateConnection(
+ SqlConnectionOptions options,
+ ConnectionPoolKey poolKey,
+ DbConnectionPoolGroupProviderInfo poolGroupProviderInfo,
+ IDbConnectionPool pool,
+ DbConnection owningConnection,
+ TimeoutTimer timeout)
+ {
+ CreateCount++;
+ return new StubDbConnectionInternal();
+ }
+ }
+
+ ///
+ /// Minimal rate limiter test double that supports scripted allow/deny results while also
+ /// enforcing a single outstanding permit so lease disposal behavior can be verified.
+ ///
+ internal sealed class TestRateLimiter : RateLimiter
+ {
+ private readonly Queue _scriptedResults;
+ private int _outstandingPermitCount;
+
+ ///
+ /// Initializes the limiter with an optional script of acquisition outcomes.
+ ///
+ /// Ordered allow/deny results for successive acquire attempts.
+ internal TestRateLimiter(params bool[] scriptedResults)
+ {
+ _scriptedResults = new Queue(scriptedResults);
+ }
+
+ ///
+ /// Gets the number of synchronous acquire attempts made by the pool.
+ ///
+ internal int AttemptAcquireCount { get; private set; }
+
+ ///
+ /// Gets the number of permits currently held by undisposed leases.
+ ///
+ internal int OutstandingPermitCount => Volatile.Read(ref _outstandingPermitCount);
+
+ ///
+ /// Gets the idle duration for the test limiter. This limiter never tracks idle time.
+ ///
+ public override TimeSpan? IdleDuration => null;
+
+ ///
+ /// Attempts to acquire a permit synchronously according to the scripted outcome and
+ /// current outstanding lease count.
+ ///
+ /// The requested permit count.
+ /// An acquired or denied lease for the requested permit.
+ protected override RateLimitLease AttemptAcquireCore(int permitCount)
+ {
+ AttemptAcquireCount++;
+
+ if (permitCount != 1)
+ {
+ throw new NotSupportedException("Tests only support single-permit acquisition.");
+ }
+
+ if (OutstandingPermitCount > 0)
+ {
+ return DeniedTestLease.Instance;
+ }
+
+ if (_scriptedResults.Count > 0 && !_scriptedResults.Dequeue())
+ {
+ return DeniedTestLease.Instance;
+ }
+
+ Interlocked.Increment(ref _outstandingPermitCount);
+ return new AcquiredTestLease(this);
+ }
+
+ ///
+ /// Attempts to acquire a permit asynchronously by delegating to the synchronous test
+ /// behavior.
+ ///
+ /// The requested permit count.
+ /// Ignored because this test limiter never queues.
+ /// A completed task containing the acquired or denied lease.
+ protected override ValueTask AcquireAsyncCore(int permitCount, CancellationToken cancellationToken)
+ => new(AttemptAcquireCore(permitCount));
+
+ ///
+ /// Gets statistics for the test limiter. Tests assert behavior through explicit counters
+ /// instead of the framework statistics object.
+ ///
+ /// because this test limiter does not expose framework statistics.
+ public override RateLimiterStatistics? GetStatistics() => null;
+
+ private void ReleasePermit()
+ {
+ Interlocked.Decrement(ref _outstandingPermitCount);
+ }
+
+ ///
+ /// Lease representing a successful single-permit acquisition.
+ ///
+ private sealed class AcquiredTestLease : RateLimitLease
+ {
+ private readonly TestRateLimiter _owner;
+ private int _disposed;
+
+ internal AcquiredTestLease(TestRateLimiter owner)
+ {
+ _owner = owner;
+ }
+
+ public override bool IsAcquired => true;
+
+ public override IEnumerable MetadataNames => Array.Empty();
+
+ public override bool TryGetMetadata(string metadataName, out object? metadata)
+ {
+ metadata = null;
+ return false;
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing && Interlocked.Exchange(ref _disposed, 1) == 0)
+ {
+ _owner.ReleasePermit();
+ }
+ }
+ }
+
+ ///
+ /// Shared denied lease used when the test limiter refuses an acquisition.
+ ///
+ private sealed class DeniedTestLease : RateLimitLease
+ {
+ internal static readonly DeniedTestLease Instance = new();
+
+ public override bool IsAcquired => false;
+
+ public override IEnumerable MetadataNames => Array.Empty();
+
+ public override bool TryGetMetadata(string metadataName, out object? metadata)
+ {
+ metadata = null;
+ return false;
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ // No resources to release.
+ }
+ }
+ }
+
+ #endregion
+
#region Connection Timeout Awareness Tests
///
@@ -1480,3 +2286,4 @@ public void GetConnection_TimeoutTimerReflectsPoolWaitTime()
#endregion
}
}
+
diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft.Data.SqlClient.UnitTests.csproj b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft.Data.SqlClient.UnitTests.csproj
index e09e49c967..eefa88ac4a 100644
--- a/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft.Data.SqlClient.UnitTests.csproj
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/Microsoft.Data.SqlClient.UnitTests.csproj
@@ -100,6 +100,7 @@
+
@@ -116,6 +117,7 @@
+