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/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/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/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/ChannelDbConnectionPoolTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs
index b79baefbd5..c2da26c887 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,577 @@ 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,
+ idleTimeout: 0));
+ 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,
+ idleTimeout: 0));
+ 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,
+ idleTimeout: 0));
+ 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,
+ idleTimeout: 0));
+ 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,
+ idleTimeout: 0));
+ 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,
+ idleTimeout: 0));
+ 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,
+ idleTimeout: 0);
+ 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,
+ idleTimeout: 0));
+ 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();
+ }
+ }
+
+ ///
+ /// Test connection factory that returns a successful stub connection each time and records
+ /// how many physical connection creations the pool attempted, so rate-limiting tests can
+ /// assert how often the pool actually opened a connection.
+ ///
+ internal sealed class CountingSuccessfulConnectionFactory : SqlConnectionFactory
+ {
+ ///
+ /// 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 +2299,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 @@
+