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..f05951d61d
--- /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["ConcurrencyLimiter
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..d373c7d681
--- /dev/null
+++ b/specs/006-pool-rate-limiting/spec.md
@@ -0,0 +1,152 @@
+# 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 do not queue on the limiter; they fall back to waiting on
+the idle channel, where they are satisfied either by a returned connection or by a best-effort
+wake when another caller releases its permit. The idle channel preserves FIFO order for returned
+connections, but rate-limit retries are best-effort rather than strictly ordered. All waiting is
+subject to the caller's `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 Limiting Built on a Concurrency Limiter (P3)
+
+The pool supports an optional `System.Threading.RateLimiting.ConcurrencyLimiter` to throttle
+concurrent physical connection creation. This is the only limiter type the pool currently needs
+(pooling against on-prem SQL Server), so the pool takes a concrete `ConcurrencyLimiter?` rather
+than the abstract `RateLimiter` base. Support for other limiter types can be added later if a
+concrete need arises. When no limiter is supplied (`null`), no rate limiting is applied.
+
+**Acceptance Scenarios**:
+
+1. **Given** the pool is configured with a `ConcurrencyLimiter`, **When** connections
+ are created, **Then** the limiter throttles concurrent creation to the configured maximum.
+2. **Given** no limiter is supplied (`null`), **When** connections are created, **Then** the
+ pool applies no rate limiting.
+
+---
+
+## 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 fall
+ back to waiting on the idle channel until capacity becomes available (via a returned connection
+ or a best-effort wake when a permit is released) or their timeout expires. Rate-limit retries are
+ best-effort and not strictly FIFO-ordered.
+- **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 be an optional `System.Threading.RateLimiting.ConcurrencyLimiter`.
+ When no limiter is supplied (`null`), the pool MUST apply no rate limiting. Support for other
+ `RateLimiter` types is intentionally out of scope for now and may be added later if needed.
+- **FR-013**: When a limiter is supplied, it MUST be a
+ `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 f5d758ebb7..86eaf609b8 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
@@ -8,6 +8,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;
@@ -98,6 +99,24 @@ internal sealed class ChannelDbConnectionPool : IDbConnectionPool, IDisposable
/// .
///
private int _shutdownInitiated;
+
+ ///
+ /// Optional concurrency 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.
+ /// Lifetime note: the pool does not own this limiter and never disposes it. The caller that
+ /// constructs the limiter owns its lifetime, since a single limiter may be shared across
+ /// pools or outlive any one pool.
+ ///
+ private readonly ConcurrencyLimiter? _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
///
@@ -107,7 +126,8 @@ internal ChannelDbConnectionPool(
SqlConnectionFactory connectionFactory,
DbConnectionPoolGroup connectionPoolGroup,
DbConnectionPoolIdentity identity,
- DbConnectionPoolProviderInfo connectionPoolProviderInfo)
+ DbConnectionPoolProviderInfo connectionPoolProviderInfo,
+ ConcurrencyLimiter? connectionCreationRateLimiter = null)
{
ConnectionFactory = connectionFactory;
PoolGroup = connectionPoolGroup;
@@ -117,9 +137,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.
@@ -147,8 +172,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;
@@ -192,6 +216,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
@@ -242,7 +270,90 @@ public DbConnectionInternal ReplaceConnection(
DbConnectionInternal oldConnection,
TimeoutTimer timeout)
{
- throw new NotImplementedException();
+ SqlClientEventSource.Log.TryPoolerTraceEvent(
+ " {0}, replacing connection.", Id);
+
+ // Two invariants drive the design and keep the two branches from
+ // collapsing into a single shared path:
+ //
+ // 1. Progress under saturation. Releasing old's slot and re-reserving could let another thread
+ // steal the freed slot and stall the reconnect until timeout.
+ //
+ // 2. the outer reconnect loop retries assuming oldConnection is not disposed; it may be retired only
+ // after the replacement is fully activated and we know we won't fail.
+ //
+ // Reuse branch: the idle connection already owns a slot, so it goes
+ // through PrepareConnection like a normal checkout -- if activation throws,
+ // PrepareConnection safely returns it to the pool. We then free old's slot, so the
+ // net slot count drops.
+ //
+ // Create branch: we open a new connection directly, bypassing
+ // the reserve-a-slot path, and leave it UNSLOTTED until TryReplace swaps it into old's
+ // slot *after* activation succeeds. PrepareConnection returns a failed connection to the idle channel,
+ // but one that never took a slot would be published untracked -- letting another caller
+ // vend a connection the pool isn't counting (over max / accounting skew). Staying
+ // unslotted keeps failure trivial: dispose only the new connection and leave old intact.
+
+ DbConnectionInternal? newConnection = GetIdleConnection();
+
+ if (newConnection is not null)
+ {
+ // TODO: Full transaction enlistment support (Story 2).
+ PrepareConnection(owningObject, newConnection, oldConnection.EnlistedTransaction);
+ oldConnection.DeactivateConnection();
+ RemoveConnection(oldConnection);
+ }
+ else
+ {
+ // Honor the blocking period before opening a new connection, mirroring
+ // OpenNewInternalConnection. Idle reuse above is intentionally exempt.
+ _errorState?.ThrowIfActive();
+
+ newConnection = ConnectionFactory.CreatePooledConnection(owningObject, this, timeout);
+
+ try
+ {
+ newConnection.ClearGeneration = _clearGeneration;
+
+ lock (newConnection)
+ {
+ // PostPop requires a lock on the connection.
+ newConnection.PostPop(owningObject);
+ }
+
+ // TODO: Full transaction enlistment support (Story 2).
+ newConnection.ActivateConnection(oldConnection.EnlistedTransaction);
+
+ bool replaced = _connectionSlots.TryReplace(oldConnection, newConnection);
+
+ if (!replaced)
+ {
+ // Should never happen (oldConnection is checked out, so its slot is stable),
+ // but guard against vending a connection the pool isn't tracking.
+ // TODO: error types and localization
+ throw new InvalidOperationException("Connection is no longer in the pool and cannot be replaced.");
+ }
+ }
+ catch
+ {
+ newConnection.DeactivateConnection();
+ newConnection.Dispose();
+ throw;
+ }
+
+ // A successful open clears the blocking period, mirroring OpenNewInternalConnection.
+ _errorState?.Clear();
+
+ oldConnection.DeactivateConnection();
+ oldConnection.Dispose();
+ }
+
+ SqlClientDiagnostics.Metrics.SoftConnectRequest();
+
+ SqlClientEventSource.Log.TryPoolerTraceEvent(
+ " {0}, connection replaced successfully.", Id);
+
+ return newConnection;
}
///
@@ -324,6 +435,19 @@ public void Shutdown()
" {0}, Pruner.Dispose threw, continuing shutdown: {1}", Id, ex);
}
+ // 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.
+ try
+ {
+ _errorState?.Dispose();
+ }
+ catch (Exception ex)
+ {
+ SqlClientEventSource.Log.TryPoolerTraceEvent(
+ " {0}, _errorState.Dispose threw, continuing shutdown: {1}", Id, ex);
+ }
+
// Complete the channel writer so:
// - no further idle connections can be enqueued (TryWrite returns false), and
// - in-flight / future async waiters on ReadAsync fault with ChannelClosedException.
@@ -496,13 +620,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.
///
@@ -513,50 +640,127 @@ 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 than queue on the rate limit. When no limiter is
+ // configured we substitute a no-op acquired lease.
+ // FR-001, FR-002, FR-003.
+
+ RateLimitLease lease = _connectionCreationRateLimiter?.AttemptAcquire(1) ?? NoOpAcquiredLease.Instance;
+ bool faulted = true;
+ try
+ {
+ if (!lease.IsAcquired)
+ {
+ // 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.
+ faulted = false;
+ 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;
+ }
+
+ faulted = false;
+ 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 on non-faulted completion: on exception paths
+ // the cleanupCallback below already writes a wake, so poking here too would
+ // produce a redundant double wake. We also 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 (!faulted &&
+ lease.IsAcquired &&
+ _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) && ex is not OperationCanceledException)
+ {
+ // Enter the blocking period error state on creation failure if configured.
+ // We deliberately exclude OperationCanceledException: that is thrown when the
+ // caller's own timeout/cancellation budget expires while waiting, which is
+ // client-side contention rather than a physical connection creation failure and
+ // must not poison the pool into fast-fail/backoff for other callers.
+ // FR-006, FR-007.
+ _errorState?.Enter(ex);
- return result;
+ throw;
+ }
}
///
@@ -686,7 +890,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,
@@ -765,10 +971,11 @@ private async Task GetInternalConnection(
///
/// The owning DbConnection instance.
/// The DbConnectionInternal to be activated.
+ /// The transaction to enlist the connection in, or null to activate cleanly.
///
/// Thrown when any exception occurs during connection activation.
///
- private void PrepareConnection(DbConnection owningObject, DbConnectionInternal connection)
+ private void PrepareConnection(DbConnection owningObject, DbConnectionInternal connection, Transaction? transaction = null)
{
lock (connection)
{
@@ -778,8 +985,7 @@ private void PrepareConnection(DbConnection owningObject, DbConnectionInternal c
try
{
- //TODO: pass through transaction
- connection.ActivateConnection(null);
+ connection.ActivateConnection(transaction);
}
catch
{
diff --git a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ConnectionPoolSlots.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ConnectionPoolSlots.cs
index 55eb88f02c..c9d268fd29 100644
--- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ConnectionPoolSlots.cs
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ConnectionPoolSlots.cs
@@ -170,6 +170,26 @@ internal bool TryRemove(DbConnectionInternal connection)
return false;
}
+ ///
+ /// Atomically replaces an existing connection with a new one in the same slot.
+ /// The reservation count is unchanged because the slot is reused.
+ ///
+ /// The connection currently occupying the slot.
+ /// The connection to place into the slot.
+ /// True if the old connection was found and replaced; otherwise, false.
+ internal bool TryReplace(DbConnectionInternal oldConnection, DbConnectionInternal newConnection)
+ {
+ for (int i = 0; i < _connections.Length; i++)
+ {
+ if (Interlocked.CompareExchange(ref _connections[i], newConnection, oldConnection) == oldConnection)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
///
/// Attempts to reserve a spot in the collection.
///
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/SqlConnection.cs b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs
index d63571bd55..57c372122e 100644
--- a/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs
+++ b/src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs
@@ -1616,7 +1616,7 @@ public void Open(SqlConnectionOverrides overrides)
{
statistics = SqlStatistics.StartTimer(Statistics);
- if (!(IsProviderRetriable ? TryOpenWithRetry(null, false, overrides) : TryOpen(null, false, overrides)))
+ if (!(IsProviderRetriable ? TryOpenWithRetry(null, forceNewConnection: false, overrides) : TryOpen(null, forceNewConnection: false, overrides)))
{
throw ADP.InternalError(ADP.InternalErrorCode.SynchronousConnectReturnedPending);
}
@@ -1923,6 +1923,7 @@ public Task OpenAsync(SqlConnectionOverrides overrides, CancellationToken cancel
private Task InternalOpenWithRetryAsync(SqlConnectionOverrides overrides, bool forceNewConnection, CancellationToken cancellationToken)
=> RetryLogicProvider.ExecuteAsync(this, () => InternalOpenAsync(overrides, forceNewConnection, cancellationToken), cancellationToken);
+
private Task InternalOpenAsync(SqlConnectionOverrides overrides, bool forceNewConnection, CancellationToken cancellationToken)
{
long scopeID = SqlClientEventSource.Log.TryPoolerScopeEnterEvent("SqlConnection.InternalOpenAsync | API | Object Id {0}", ObjectID);
@@ -2258,7 +2259,7 @@ private bool TryOpen(TaskCompletionSource retry, bool forc
/// The inner connection is snapshotted after the open call so downstream parser access uses a single observed
/// instance and does not rely on a second racy read of .
///
- /// forceNewConnection may only be true when the connection is already open (or was open) and needs to be replaced. If the connection has never
+ /// forceNewConnection may only be true when the connection is already open and needs to be replaced. If the connection has never
/// been opened, passing true will result in an exception. It may only be false when the connection has never been opened or is
/// currently disconnected. If the connection is currently open, passing false will result in an exception. See SqlConnection state
/// transitions and subclasses for more details.
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 f3e4b61b45..be691606ce 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/ChannelDbConnectionPoolReplaceConnectionTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolReplaceConnectionTest.cs
new file mode 100644
index 0000000000..3a62863684
--- /dev/null
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolReplaceConnectionTest.cs
@@ -0,0 +1,686 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Data.Common;
+using System.Transactions;
+using Microsoft.Data.Common;
+using Microsoft.Data.Common.ConnectionString;
+using Microsoft.Data.ProviderBase;
+using Microsoft.Data.SqlClient.ConnectionPool;
+using Xunit;
+
+namespace Microsoft.Data.SqlClient.UnitTests.ConnectionPool
+{
+ ///
+ /// Unit tests for ,
+ /// covering idle reuse, new-connection creation, pool-slot accounting at and below capacity, and the
+ /// failure paths that keep the old connection available for the caller's reconnect retry loop.
+ ///
+ public class ChannelDbConnectionPoolReplaceConnectionTest
+ {
+ private static readonly SqlConnectionFactory SuccessfulConnectionFactory = new SuccessfulSqlConnectionFactory();
+
+ private ChannelDbConnectionPool ConstructPool(
+ SqlConnectionFactory connectionFactory,
+ DbConnectionPoolGroupOptions? poolGroupOptions = null)
+ {
+ poolGroupOptions ??= new DbConnectionPoolGroupOptions(
+ poolByIdentity: false,
+ minPoolSize: 0,
+ maxPoolSize: 50,
+ creationTimeout: 15,
+ loadBalanceTimeout: 0,
+ hasTransactionAffinity: true,
+ idleTimeout: 0
+ );
+ var dbConnectionPoolGroup = new DbConnectionPoolGroup(
+ new SqlConnectionOptions("Data Source=localhost;"),
+ new ConnectionPoolKey("TestDataSource", credential: null, accessToken: null, accessTokenCallback: null, sspiContextProvider: null),
+ poolGroupOptions
+ );
+ return new ChannelDbConnectionPool(
+ connectionFactory,
+ dbConnectionPoolGroup,
+ DbConnectionPoolIdentity.NoIdentity,
+ new DbConnectionPoolProviderInfo()
+ );
+ }
+
+ #region Story 1 — Transparent Replacement
+
+ ///
+ /// Verifies that returns a
+ /// non-null connection that is a different instance from the one being replaced.
+ ///
+ [Fact]
+ public void ReplaceConnection_ReturnsNewConnection()
+ {
+ // Arrange
+ var pool = ConstructPool(SuccessfulConnectionFactory);
+ SqlConnection owner = new();
+
+ pool.TryGetConnection(
+ owner,
+ taskCompletionSource: null,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
+ out DbConnectionInternal? oldConnection);
+
+ Assert.NotNull(oldConnection);
+
+ // Act
+ var newConnection = pool.ReplaceConnection(
+ owner,
+ oldConnection,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)));
+
+ // Assert
+ Assert.NotNull(newConnection);
+ Assert.NotSame(oldConnection, newConnection);
+ }
+
+ ///
+ /// Verifies that after a replacement the old connection is disposed and can no longer
+ /// be pooled.
+ ///
+ [Fact]
+ public void ReplaceConnection_OldConnectionIsDisposed()
+ {
+ // Arrange
+ var pool = ConstructPool(SuccessfulConnectionFactory);
+ SqlConnection owner = new();
+
+ pool.TryGetConnection(
+ owner,
+ taskCompletionSource: null,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
+ out DbConnectionInternal? oldConnection);
+
+ Assert.NotNull(oldConnection);
+
+ // Act
+ pool.ReplaceConnection(
+ owner,
+ oldConnection,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)));
+
+ // Assert — the old connection should be disposed (not poolable)
+ Assert.False(oldConnection.CanBePooled);
+ }
+
+ #endregion
+
+ #region Story 3 — Pool Capacity Preservation (new physical connection path)
+
+ ///
+ /// Verifies that replacing a connection when no idle connections are available reuses
+ /// the old connection's slot so the pool's total count remains unchanged.
+ ///
+ [Fact]
+ public void ReplaceConnection_NewPhysicalConnection_PoolCountUnchanged()
+ {
+ // Arrange — single connection, no idle connections available
+ var pool = ConstructPool(SuccessfulConnectionFactory);
+ SqlConnection owner = new();
+
+ pool.TryGetConnection(
+ owner,
+ taskCompletionSource: null,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
+ out DbConnectionInternal? oldConnection);
+
+ Assert.NotNull(oldConnection);
+ Assert.Equal(0, pool.IdleCount);
+ int countBefore = pool.Count;
+
+ // Act
+ pool.ReplaceConnection(
+ owner,
+ oldConnection,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)));
+
+ // Assert — slot was reused, count unchanged
+ Assert.Equal(countBefore, pool.Count);
+ }
+
+ ///
+ /// Verifies that replacing a connection in a pool that is already filled to its maximum
+ /// capacity succeeds without exceeding the maximum pool size.
+ ///
+ [Fact]
+ public void ReplaceConnection_AtMaxCapacity_PoolCountUnchanged()
+ {
+ // Arrange — fill pool to max capacity, no idle connections
+ var poolGroupOptions = new DbConnectionPoolGroupOptions(
+ poolByIdentity: false,
+ minPoolSize: 0,
+ maxPoolSize: 3,
+ creationTimeout: 15,
+ loadBalanceTimeout: 0,
+ hasTransactionAffinity: true,
+ idleTimeout: 0
+ );
+ var pool = ConstructPool(SuccessfulConnectionFactory, poolGroupOptions);
+
+ SqlConnection owner1 = new();
+ SqlConnection owner2 = new();
+ SqlConnection owner3 = new();
+
+ pool.TryGetConnection(owner1, null, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out DbConnectionInternal? conn1);
+ pool.TryGetConnection(owner2, null, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out DbConnectionInternal? conn2);
+ pool.TryGetConnection(owner3, null, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out DbConnectionInternal? conn3);
+
+ Assert.Equal(3, pool.Count);
+
+ // Act — replace connection in a full pool
+ var newConnection = pool.ReplaceConnection(
+ owner1,
+ conn1!,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)));
+
+ // Assert — pool count must not exceed max
+ Assert.NotNull(newConnection);
+ Assert.Equal(3, pool.Count);
+ }
+
+ #endregion
+
+ #region Story 4 — Replacement Failure Propagation
+
+ ///
+ /// Verifies that when creating the replacement connection fails, the exception thrown by
+ /// the connection factory is propagated to the caller.
+ ///
+ [Fact]
+ public void ReplaceConnection_CreationFails_ExceptionPropagated()
+ {
+ // Arrange — use a factory that succeeds initially then fails
+ var switchableFactory = new SwitchableSqlConnectionFactory();
+ var pool = ConstructPool(switchableFactory);
+ SqlConnection owner = new();
+
+ pool.TryGetConnection(
+ owner,
+ taskCompletionSource: null,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
+ out DbConnectionInternal? oldConnection);
+
+ Assert.NotNull(oldConnection);
+
+ // Switch to failing mode
+ switchableFactory.ShouldFail = true;
+
+ // Act & Assert — exception from factory is propagated
+ Assert.Throws(() =>
+ pool.ReplaceConnection(
+ owner,
+ oldConnection,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15))));
+ }
+
+ ///
+ /// Verifies that when creating the replacement connection fails, the old connection is left fully
+ /// intact - it keeps its pool slot and stays poolable - so the caller's reconnect retry loop can reuse
+ /// it on a subsequent attempt. The pool count is unchanged and the pool is not left in an error state.
+ ///
+ [Fact]
+ public void ReplaceConnection_CreationFails_OldConnectionRetainedForRetry()
+ {
+ // Arrange — fill the pool to capacity so a leaked or prematurely released slot would be observable.
+ var poolGroupOptions = new DbConnectionPoolGroupOptions(
+ poolByIdentity: false,
+ minPoolSize: 0,
+ maxPoolSize: 2,
+ creationTimeout: 15,
+ loadBalanceTimeout: 0,
+ hasTransactionAffinity: true,
+ idleTimeout: 0
+ );
+ var switchableFactory = new SwitchableSqlConnectionFactory();
+ var pool = ConstructPool(switchableFactory, poolGroupOptions);
+
+ SqlConnection owner1 = new();
+ SqlConnection owner2 = new();
+ pool.TryGetConnection(owner1, null, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out DbConnectionInternal? oldConnection);
+ pool.TryGetConnection(owner2, null, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out DbConnectionInternal? otherConnection);
+
+ Assert.NotNull(oldConnection);
+ Assert.Equal(2, pool.Count);
+
+ // Switch to failing mode so the replacement creation throws.
+ switchableFactory.ShouldFail = true;
+
+ // Act — replacement fails
+ Assert.Throws(() =>
+ pool.ReplaceConnection(
+ owner1,
+ oldConnection!,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15))));
+
+ // Assert — the old connection is left intact so the caller can retry with it: its slot is retained
+ // (no premature release) ...
+ Assert.Equal(2, pool.Count);
+ // ... it is not doomed, so it remains usable for the retry ...
+ Assert.False(oldConnection!.IsConnectionDoomed);
+ // ... it is still owned by the same caller (not released back to the pool) ...
+ Assert.Same(owner1, oldConnection!.Owner);
+ // ... it keeps its reference to the pool, which is what enables the caller's retry ...
+ Assert.Same(pool, oldConnection!.Pool);
+ // ... and the pool is not left in an error state.
+ Assert.False(pool.ErrorOccurred);
+
+ // The reconnect retry loop reuses the SAME old connection: a subsequent successful replacement
+ // succeeds, reusing the retained slot and keeping the pool count unchanged.
+ switchableFactory.ShouldFail = false;
+ var newConnection = pool.ReplaceConnection(
+ owner1,
+ oldConnection!,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)));
+ Assert.NotNull(newConnection);
+ Assert.NotSame(oldConnection, newConnection);
+ Assert.Equal(2, pool.Count);
+ }
+
+ #endregion
+
+ #region Story 5 — Activation Failure Rollback
+
+ ///
+ /// Verifies that when activating the replacement connection fails, the exception is
+ /// propagated to the caller.
+ ///
+ [Fact]
+ public void ReplaceConnection_ActivationFails_ExceptionPropagated()
+ {
+ // Arrange
+ var factory = new ActivationFailSqlConnectionFactory();
+ var pool = ConstructPool(factory);
+ SqlConnection owner = new();
+
+ factory.FailOnActivate = false;
+ pool.TryGetConnection(
+ owner,
+ taskCompletionSource: null,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
+ out DbConnectionInternal? oldConnection);
+
+ Assert.NotNull(oldConnection);
+
+ // Now make activation fail for the replacement
+ factory.FailOnActivate = true;
+
+ // Act & Assert
+ Assert.Throws(() =>
+ pool.ReplaceConnection(
+ owner,
+ oldConnection,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15))));
+ }
+
+ ///
+ /// Verifies that when activating the replacement connection fails, the newly created
+ /// connection is returned to the pool rather than leaked, keeping the pool count stable.
+ ///
+ [Fact]
+ public void ReplaceConnection_ActivationFails_NewConnectionReturnedToPool()
+ {
+ // Arrange
+ var factory = new ActivationFailSqlConnectionFactory();
+ var pool = ConstructPool(factory);
+ SqlConnection owner = new();
+
+ factory.FailOnActivate = false;
+ pool.TryGetConnection(
+ owner,
+ taskCompletionSource: null,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
+ out DbConnectionInternal? oldConnection);
+
+ Assert.NotNull(oldConnection);
+ int countBefore = pool.Count;
+
+ // Make activation fail
+ factory.FailOnActivate = true;
+
+ // Act
+ Assert.Throws(() =>
+ pool.ReplaceConnection(
+ owner,
+ oldConnection,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15))));
+
+ // Assert — the new connection was returned to pool (not leaked).
+ // Pool count stays same because the new connection replaced the old one's slot
+ // and was then returned to idle.
+ Assert.Equal(countBefore, pool.Count);
+ }
+
+ #endregion
+
+ #region Story 6 — Prefer Idle Connection Reuse
+
+ ///
+ /// Verifies that when a live idle connection is available, replacement reuses it instead of
+ /// establishing a new physical connection. The reused connection keeps its own pool slot and
+ /// the replaced connection's slot is freed, so the pool's physical connection count drops by
+ /// one and never exceeds the maximum.
+ ///
+ [Fact]
+ public void ReplaceConnection_PrefersIdleOverNewConnection()
+ {
+ // Arrange — open two connections, then return one so it becomes an idle connection.
+ var pool = ConstructPool(SuccessfulConnectionFactory);
+ SqlConnection owner1 = new();
+ SqlConnection owner2 = new();
+
+ pool.TryGetConnection(owner1, null, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out DbConnectionInternal? conn1);
+ pool.TryGetConnection(owner2, null, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out DbConnectionInternal? conn2);
+ Assert.NotNull(conn1);
+ Assert.NotNull(conn2);
+
+ pool.ReturnInternalConnection(conn2!, owner2);
+ Assert.Equal(1, pool.IdleCount);
+ Assert.Equal(2, pool.Count);
+
+ // Act — replace conn1. The idle conn2 should be reused rather than creating a new connection.
+ var newConnection = pool.ReplaceConnection(
+ owner1,
+ conn1!,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)));
+
+ // Assert — the replacement is the previously idle connection ...
+ Assert.Same(conn2, newConnection);
+ // ... the idle channel was drained ...
+ Assert.Equal(0, pool.IdleCount);
+ // ... the replaced connection was disposed ...
+ Assert.False(conn1!.CanBePooled);
+ // ... and its slot was freed, so the pool now holds a single physical connection.
+ Assert.Equal(1, pool.Count);
+ }
+
+ ///
+ /// Verifies that reusing an idle connection while the pool is at maximum capacity succeeds and
+ /// frees the replaced connection's slot, so the pool count never exceeds the maximum.
+ ///
+ [Fact]
+ public void ReplaceConnection_IdleReuse_AtMaxCapacity_FreesOldSlot()
+ {
+ // Arrange — fill the pool to max capacity, then return one connection so it is idle.
+ var poolGroupOptions = new DbConnectionPoolGroupOptions(
+ poolByIdentity: false,
+ minPoolSize: 0,
+ maxPoolSize: 3,
+ creationTimeout: 15,
+ loadBalanceTimeout: 0,
+ hasTransactionAffinity: true,
+ idleTimeout: 0
+ );
+ var pool = ConstructPool(SuccessfulConnectionFactory, poolGroupOptions);
+
+ SqlConnection owner1 = new();
+ SqlConnection owner2 = new();
+ SqlConnection owner3 = new();
+
+ pool.TryGetConnection(owner1, null, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out DbConnectionInternal? conn1);
+ pool.TryGetConnection(owner2, null, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out DbConnectionInternal? conn2);
+ pool.TryGetConnection(owner3, null, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out DbConnectionInternal? conn3);
+ Assert.Equal(3, pool.Count);
+
+ pool.ReturnInternalConnection(conn3!, owner3);
+ Assert.Equal(1, pool.IdleCount);
+ Assert.Equal(3, pool.Count);
+
+ // Act — replace conn1 while at max capacity; the idle conn3 should be reused.
+ var newConnection = pool.ReplaceConnection(
+ owner1,
+ conn1!,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)));
+
+ // Assert — the idle connection was reused and conn1's slot was freed, dropping below max.
+ Assert.Same(conn3, newConnection);
+ Assert.Equal(0, pool.IdleCount);
+ Assert.Equal(2, pool.Count);
+ }
+
+ ///
+ /// Verifies that when activating a reused idle connection fails, the connection is returned to
+ /// the pool (not leaked or discarded) and the connection being replaced is left untouched, so
+ /// the caller's reconnect retry loop can try again.
+ ///
+ [Fact]
+ public void ReplaceConnection_IdleReuse_ActivationFails_ReturnedToPool()
+ {
+ // Arrange — open two connections, then return one so it becomes an idle connection.
+ var factory = new ActivationFailSqlConnectionFactory();
+ var pool = ConstructPool(factory);
+ SqlConnection owner1 = new();
+ SqlConnection owner2 = new();
+
+ factory.FailOnActivate = false;
+ pool.TryGetConnection(owner1, null, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out DbConnectionInternal? conn1);
+ pool.TryGetConnection(owner2, null, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out DbConnectionInternal? conn2);
+ Assert.NotNull(conn1);
+ Assert.NotNull(conn2);
+
+ pool.ReturnInternalConnection(conn2!, owner2);
+ Assert.Equal(1, pool.IdleCount);
+ Assert.Equal(2, pool.Count);
+
+ // Make the idle-reuse activation fail.
+ factory.FailOnActivate = true;
+
+ // Act — ReplaceConnection pulls the idle conn2 and fails to activate it.
+ Assert.Throws(() =>
+ pool.ReplaceConnection(
+ owner1,
+ conn1!,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15))));
+
+ // Assert — the reused connection was returned to the idle pool (not leaked or discarded) ...
+ Assert.Equal(1, pool.IdleCount);
+ // ... nothing was removed, so both connections still hold their slots ...
+ Assert.Equal(2, pool.Count);
+ // ... and the connection being replaced was left untouched and still healthy.
+ Assert.False(conn1!.IsConnectionDoomed);
+ }
+
+ #endregion
+
+ #region Story 7 — New Physical Connection Fallback
+
+ ///
+ /// Verifies that when no idle connection is available, replacement creates a new
+ /// physical connection distinct from the one being replaced.
+ ///
+ [Fact]
+ public void ReplaceConnection_NoIdleConnection_CreatesNew()
+ {
+ // Arrange
+ var pool = ConstructPool(SuccessfulConnectionFactory);
+ SqlConnection owner = new();
+
+ pool.TryGetConnection(owner, null, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out DbConnectionInternal? conn1);
+ Assert.NotNull(conn1);
+ Assert.Equal(0, pool.IdleCount);
+
+ // Act — no idle connections available, should create new
+ var newConnection = pool.ReplaceConnection(
+ owner,
+ conn1,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)));
+
+ // Assert
+ Assert.NotNull(newConnection);
+ Assert.NotSame(conn1, newConnection);
+ Assert.Equal(1, pool.Count);
+ }
+
+ #endregion
+
+ #region Blocking Period
+
+ ///
+ /// Verifies that the new-physical-connection branch of
+ /// respects the pool's blocking period:
+ /// while the pool is in the blocking-period error state it fast-fails with the cached exception
+ /// instead of opening another physical connection, and it leaves the old connection intact for
+ /// the caller's reconnect retry. Idle reuse is intentionally exempt, matching the normal acquire path.
+ ///
+ [Fact]
+ public void ReplaceConnection_NewPhysicalConnection_RespectsBlockingPeriod()
+ {
+ // Arrange — localhost is non-Azure, so the pool's blocking period is enabled by default.
+ var factory = new SwitchableSqlConnectionFactory();
+ var pool = ConstructPool(factory);
+ SqlConnection owner = new();
+
+ // Check out a connection to later replace (creation succeeds).
+ factory.ShouldFail = false;
+ pool.TryGetConnection(owner, null, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out DbConnectionInternal? oldConnection);
+ Assert.NotNull(oldConnection);
+
+ // Drive the pool into the blocking-period error state with a failed physical create.
+ factory.ShouldFail = true;
+ Assert.Throws(() =>
+ pool.TryGetConnection(new SqlConnection(), null, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out _));
+ Assert.True(pool.ErrorOccurred);
+
+ // Act & Assert — a replacement that must open a new physical connection (no idle available)
+ // fast-fails during the blocking period rather than hammering the unhealthy server.
+ Assert.Throws(() =>
+ pool.ReplaceConnection(owner, oldConnection!, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15))));
+
+ // The pool is still blocking and the old connection is untouched, so the caller can retry with it.
+ Assert.True(pool.ErrorOccurred);
+ Assert.False(oldConnection!.IsConnectionDoomed);
+ Assert.Same(pool, oldConnection!.Pool);
+ }
+
+ #endregion
+
+ #region Test Helper Classes
+
+ internal class SuccessfulSqlConnectionFactory : SqlConnectionFactory
+ {
+ protected override DbConnectionInternal CreateConnection(
+ SqlConnectionOptions options,
+ ConnectionPoolKey poolKey,
+ DbConnectionPoolGroupProviderInfo poolGroupProviderInfo,
+ IDbConnectionPool pool,
+ DbConnection owningConnection,
+ TimeoutTimer timeout)
+ {
+ return new StubDbConnectionInternal();
+ }
+ }
+
+ internal class SwitchableSqlConnectionFactory : SqlConnectionFactory
+ {
+ internal bool ShouldFail { get; set; }
+
+ protected override DbConnectionInternal CreateConnection(
+ SqlConnectionOptions options,
+ ConnectionPoolKey poolKey,
+ DbConnectionPoolGroupProviderInfo poolGroupProviderInfo,
+ IDbConnectionPool pool,
+ DbConnection owningConnection,
+ TimeoutTimer timeout)
+ {
+ if (ShouldFail)
+ {
+ throw new InvalidOperationException("Simulated connection failure");
+ }
+ return new StubDbConnectionInternal();
+ }
+ }
+
+ internal class ActivationFailSqlConnectionFactory : SqlConnectionFactory
+ {
+ internal bool FailOnActivate { get; set; }
+
+ protected override DbConnectionInternal CreateConnection(
+ SqlConnectionOptions options,
+ ConnectionPoolKey poolKey,
+ DbConnectionPoolGroupProviderInfo poolGroupProviderInfo,
+ IDbConnectionPool pool,
+ DbConnection owningConnection,
+ TimeoutTimer timeout)
+ {
+ return new ActivationFailDbConnectionInternal(this);
+ }
+ }
+
+ internal class StubDbConnectionInternal : DbConnectionInternal
+ {
+ public override string ServerVersion => throw new NotImplementedException();
+
+ public override DbTransaction BeginTransaction(System.Data.IsolationLevel il)
+ {
+ throw new NotImplementedException();
+ }
+
+ public override void EnlistTransaction(Transaction transaction)
+ {
+ return;
+ }
+
+ protected override void Activate(Transaction transaction)
+ {
+ return;
+ }
+
+ protected override void Deactivate()
+ {
+ return;
+ }
+
+ internal override void ResetConnection()
+ {
+ return;
+ }
+ }
+
+ internal class ActivationFailDbConnectionInternal : DbConnectionInternal
+ {
+ private readonly ActivationFailSqlConnectionFactory _factory;
+
+ internal ActivationFailDbConnectionInternal(ActivationFailSqlConnectionFactory factory)
+ {
+ _factory = factory;
+ }
+
+ public override string ServerVersion => throw new NotImplementedException();
+
+ public override DbTransaction BeginTransaction(System.Data.IsolationLevel il)
+ {
+ throw new NotImplementedException();
+ }
+
+ public override void EnlistTransaction(Transaction transaction)
+ {
+ return;
+ }
+
+ protected override void Activate(Transaction transaction)
+ {
+ if (_factory.FailOnActivate)
+ {
+ throw new InvalidOperationException("Simulated activation failure");
+ }
+ }
+
+ protected override void Deactivate()
+ {
+ return;
+ }
+
+ internal override void ResetConnection()
+ {
+ return;
+ }
+ }
+
+ #endregion
+ }
+}
diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs
index 914e63019a..34f6236a6d 100644
--- a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs
@@ -6,6 +6,7 @@
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 +19,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 concurrency 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,
+ ConcurrencyLimiter? connectionCreationRateLimiter = null)
{
poolGroupOptions ??= new DbConnectionPoolGroupOptions(
poolByIdentity: false,
@@ -47,10 +64,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 +97,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 +131,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 +154,7 @@ public void GetConnectionMaxPoolSize_ShouldTimeoutAfterPeriod()
out DbConnectionInternal? internalConnection
);
+ // Assert
Assert.True(completed);
Assert.NotNull(internalConnection);
}
@@ -154,6 +183,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 +202,7 @@ public async Task GetConnectionAsyncMaxPoolSize_ShouldTimeoutAfterPeriod()
out DbConnectionInternal? internalConnection
);
+ // Assert
Assert.True(completed);
Assert.NotNull(internalConnection);
}
@@ -195,6 +229,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 +256,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 +279,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 +306,7 @@ out DbConnectionInternal? firstConnection
out DbConnectionInternal? internalConnection
);
+ // Assert
Assert.True(completed);
Assert.NotNull(internalConnection);
}
@@ -272,7 +314,7 @@ out DbConnectionInternal? internalConnection
TaskCompletionSource taskCompletionSource = new();
// Act
- var exceeded = pool.TryGetConnection(
+ pool.TryGetConnection(
new SqlConnection(""),
taskCompletionSource,
TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
@@ -285,6 +327,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 +355,7 @@ out DbConnectionInternal? firstConnection
out DbConnectionInternal? internalConnection
);
+ // Assert
Assert.True(completed);
Assert.NotNull(internalConnection);
}
@@ -354,6 +401,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 +429,7 @@ out DbConnectionInternal? firstConnection
out DbConnectionInternal? internalConnection
);
+ // Assert
Assert.True(completed);
Assert.NotNull(internalConnection);
}
@@ -386,7 +438,7 @@ out DbConnectionInternal? internalConnection
TaskCompletionSource failedCompletionSource = new();
// Act
- var exceeded = pool.TryGetConnection(
+ pool.TryGetConnection(
new SqlConnection(""),
recycledTaskCompletionSource,
TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
@@ -396,7 +448,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 +463,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 +503,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 +529,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 +558,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 +593,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 +631,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 +737,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 +762,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 +784,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,48 +848,94 @@ public void TestUseLoadBalancing()
hasTransactionAffinity: true,
idleTimeout: 0);
var pool = ConstructPool(SuccessfulConnectionFactory, poolGroupOptions: poolGroupOptions);
+
+ // Act & Assert
Assert.Equal(poolGroupOptions.UseLoadBalancing, pool.UseLoadBalancing);
}
#endregion
- #region Not Implemented Method Tests
+ #region Replace Connection Tests
+ ///
+ /// Verifies that
+ /// replaces a checked-out connection with a new, distinct connection instance.
+ ///
[Fact]
- public void TestPutObjectFromTransactedPool()
+ public void TestReplaceConnection()
{
+ // Arrange
var pool = ConstructPool(SuccessfulConnectionFactory);
- Assert.Throws(() => pool.PutObjectFromTransactedPool(null!));
+ SqlConnection owner = new();
+
+ pool.TryGetConnection(
+ owner,
+ taskCompletionSource: null,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
+ out DbConnectionInternal? oldConnection);
+
+ Assert.NotNull(oldConnection);
+
+ var newConnection = pool.ReplaceConnection(owner, oldConnection, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)));
+ Assert.NotNull(newConnection);
+ Assert.NotSame(oldConnection, newConnection);
}
+ #endregion
+
+ #region Not Implemented Method Tests
+
+ ///
+ /// Verifies that remains
+ /// unimplemented and throws .
+ ///
[Fact]
- public void TestReplaceConnection()
+ public void TestPutObjectFromTransactedPool()
{
+ // Arrange
var pool = ConstructPool(SuccessfulConnectionFactory);
- Assert.Throws(() => pool.ReplaceConnection(null!, null!, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15))));
+
+ // Act & Assert
+ Assert.Throws(() => pool.PutObjectFromTransactedPool(null!));
}
+ ///
+ /// Verifies that
+ /// remains unimplemented and throws .
+ ///
[Fact]
public void TestTransactionEnded()
{
+ // Arrange
var pool = ConstructPool(SuccessfulConnectionFactory);
+
+ // Act & Assert
Assert.Throws(() => pool.TransactionEnded(null!, null!));
}
#endregion
#region Pool Clear Tests
+ ///
+ /// 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 +970,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 +998,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 +1031,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 +1075,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 +1128,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 +1384,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 +1413,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 +1435,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
@@ -1223,6 +1474,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()
{
@@ -1242,7 +1497,7 @@ public void Constructor_WithZeroMaxPoolSize_ThrowsArgumentOutOfRangeException()
poolGroupOptions
);
- // Act & Assert
+ // Act
var exception = Assert.Throws(() =>
new ChannelDbConnectionPool(
SuccessfulConnectionFactory,
@@ -1250,15 +1505,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,
@@ -1276,7 +1536,7 @@ public void Constructor_WithLargeMaxPoolSize()
try
{
- // Act & Assert - This should not throw ArgumentOutOfRangeException, but may throw OutOfMemoryException
+ // Act
var pool = new ChannelDbConnectionPool(
SuccessfulConnectionFactory,
dbConnectionPoolGroup,
@@ -1284,6 +1544,7 @@ public void Constructor_WithLargeMaxPoolSize()
new DbConnectionPoolProviderInfo()
);
+ // Assert
Assert.NotNull(pool);
Assert.Equal(0, pool.Count);
}
@@ -1295,12 +1556,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,
@@ -1316,7 +1579,7 @@ public void Constructor_WithValidSmallPoolSizes_WorksCorrectly()
poolGroupOptions1
);
- // Act & Assert - Pool size of 1 should work
+ // Act
var pool1 = new ChannelDbConnectionPool(
SuccessfulConnectionFactory,
dbConnectionPoolGroup1,
@@ -1324,10 +1587,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,
@@ -1343,6 +1607,7 @@ public void Constructor_WithValidSmallPoolSizes_WorksCorrectly()
poolGroupOptions2
);
+ // Act
var pool2 = new ChannelDbConnectionPool(
SuccessfulConnectionFactory,
dbConnectionPoolGroup2,
@@ -1350,71 +1615,700 @@ public void Constructor_WithValidSmallPoolSizes_WorksCorrectly()
new DbConnectionPoolProviderInfo()
);
+ // Assert
Assert.NotNull(pool2);
Assert.Equal(0, pool2.Count);
}
- #region Connection Timeout Awareness Tests
+ #region Rate Limiting And Blocking Period Tests
///
- /// Verifies that two concurrent callers waiting for the same exhausted
- /// pool observe their own per-caller deadlines
- /// independently: the caller with the shorter timeout fails with the
- /// pool-timeout error while the caller with the longer timeout continues
- /// to wait and eventually succeeds when a connection is returned.
+ /// Verifies that a connection creation failure enters the blocking-period error state when
+ /// blocking is enabled for the pool.
///
- ///
- /// Both callers share a single so that
- /// advancing virtual time deterministically expires only the short-timeout
- /// caller's CTS without consuming any wall-clock time.
- ///
[Fact]
- public async Task ConcurrentCallers_ShouldTimeoutIndependently()
+ public void ErrorOccurred_FailureWithBlockingEnabled_BecomesTrue()
{
- // Arrange: pool at max capacity so both callers must wait
- var poolGroupOptions = new DbConnectionPoolGroupOptions(
- poolByIdentity: false,
- minPoolSize: 0,
- maxPoolSize: 1,
- creationTimeout: 15,
- loadBalanceTimeout: 0,
- hasTransactionAffinity: true,
- idleTimeout: 0
- );
- var pool = ConstructPool(SuccessfulConnectionFactory, poolGroupOptions: poolGroupOptions);
+ // 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);
- SqlConnection firstOwner = new();
- pool.TryGetConnection(firstOwner, taskCompletionSource: null, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out DbConnectionInternal? firstConnection);
- Assert.NotNull(firstConnection);
+ // Act
+ Assert.False(pool.ErrorOccurred);
- // Use a single fake time provider shared by both callers so we can independently
- // expire each caller's timeout via virtual time without any wall-clock waits.
- // Build the timers up-front so they are anchored at virtual time t=0.
- var fakeTime = new FakeTimeProvider();
- TimeoutTimer timerA = TimeoutTimer.StartNew(TimeSpan.FromSeconds(1), fakeTime);
- TimeoutTimer timerB = TimeoutTimer.StartNew(TimeSpan.FromSeconds(10), fakeTime);
+ Assert.Throws(() =>
+ pool.TryGetConnection(new SqlConnection(), taskCompletionSource: null, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out _));
- // Caller A: 1s virtual timeout, Caller B: 10s virtual timeout. Both run in
- // background tasks so the sync pool path can block on the channel as in production.
- var callerATask = Task.Run(() =>
- {
- pool.TryGetConnection(
- new SqlConnection(),
- taskCompletionSource: null,
- timerA,
- out DbConnectionInternal? connectionA);
- return connectionA;
- });
+ // Assert
+ Assert.True(pool.ErrorOccurred);
+ }
- var callerBTask = Task.Run(() =>
- {
- pool.TryGetConnection(
- new SqlConnection(),
- taskCompletionSource: null,
- timerB,
- out DbConnectionInternal? connectionB);
- return connectionB;
- });
+ ///
+ /// 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();
+ using var rateLimiter = new ConcurrencyLimiter(
+ new ConcurrencyLimiterOptions { PermitLimit = 1, QueueLimit = 0 });
+ 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);
+ // The single permit was acquired for the open and released afterwards, so it is
+ // available again once the connection has been handed back to the caller.
+ Assert.Equal(1, rateLimiter.GetStatistics()!.CurrentAvailablePermits);
+ }
+
+ ///
+ /// Verifies that a successful physical open releases its rate-limiter lease so that a
+ /// subsequent open can acquire the same permit. With a single-permit limiter, a leaked
+ /// lease would deny the second open and force connection reuse, leaving CreateCount at 1.
+ ///
+ [Fact]
+ public void RateLimiter_SuccessfulCreate_ReleasesLeaseForNextCreate()
+ {
+ // Arrange
+ var factory = new CountingSuccessfulConnectionFactory();
+ using var rateLimiter = new ConcurrencyLimiter(
+ new ConcurrencyLimiterOptions { PermitLimit = 1, QueueLimit = 0 });
+ 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);
+
+ // Act
+ // Two distinct owners so neither open can be satisfied by reusing the other's
+ // connection - each must acquire a fresh permit and create a physical connection.
+ bool firstCompleted = pool.TryGetConnection(
+ new SqlConnection(),
+ taskCompletionSource: null,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
+ out DbConnectionInternal? firstConnection);
+ bool secondCompleted = pool.TryGetConnection(
+ new SqlConnection(),
+ taskCompletionSource: null,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
+ out DbConnectionInternal? secondConnection);
+
+ // Assert
+ Assert.True(firstCompleted);
+ Assert.True(secondCompleted);
+ Assert.NotNull(firstConnection);
+ Assert.NotNull(secondConnection);
+ Assert.NotSame(firstConnection, secondConnection);
+ // The second create only succeeds if the first release returned the single permit.
+ Assert.Equal(2, factory.CreateCount);
+ Assert.Equal(1, rateLimiter.GetStatistics()!.CurrentAvailablePermits);
+ }
+
+ ///
+ /// Verifies the FR-004 wake path: a caller blocked purely because the rate limiter denied
+ /// its permit is woken when a different caller releases its lease, and then creates its own
+ /// physical connection (rather than reusing one, since the permit holder never returns its
+ /// connection). Exercises both the sync and async idle-channel wait mechanisms.
+ ///
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task RateLimiter_LeaseReleaseWakesRateLimitedWaiter_CreatesPhysicalConnection(bool async)
+ {
+ // Arrange
+ using var createGate = new ManualResetEventSlim(initialState: false);
+ var factory = new GatedSuccessfulConnectionFactory(createGate);
+ using var rateLimiter = new ConcurrencyLimiter(
+ new ConcurrencyLimiterOptions { PermitLimit = 1, QueueLimit = 0 });
+ var poolGroupOptions = new DbConnectionPoolGroupOptions(
+ poolByIdentity: false,
+ minPoolSize: 0,
+ // Room to grow so the release actually pokes a waiter (ReservationCount < MaxPoolSize).
+ maxPoolSize: 2,
+ creationTimeout: 15,
+ loadBalanceTimeout: 0,
+ hasTransactionAffinity: true,
+ idleTimeout: 0);
+ var pool = ConstructPool(
+ factory,
+ poolGroupOptions: poolGroupOptions,
+ connectionCreationRateLimiter: rateLimiter);
+
+ Task Open(SqlConnection owner)
+ {
+ if (async)
+ {
+ // The async path dispatches the open onto the thread pool and completes the TCS,
+ // so this returns immediately while creation proceeds on another thread.
+ var tcs = new TaskCompletionSource();
+ pool.TryGetConnection(owner, tcs, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out _);
+ return tcs.Task!;
+ }
+
+ return Task.Run(() =>
+ {
+ pool.TryGetConnection(
+ owner,
+ taskCompletionSource: null,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
+ out DbConnectionInternal? c);
+ return c;
+ });
+ }
+
+ // Act
+ // Caller A acquires the only permit and blocks inside creation, holding the permit.
+ SqlConnection ownerA = new();
+ Task requestA = Open(ownerA);
+ Assert.True(
+ factory.FirstCreateStarted.Wait(TimeSpan.FromSeconds(5)),
+ "Timed out waiting for the first open to begin physical creation.");
+
+ // Caller B is denied a permit (A holds it) and must fall back to the idle-channel wait.
+ long failedLeasesBefore = rateLimiter.GetStatistics()!.TotalFailedLeases;
+ Task requestB = Open(new SqlConnection());
+ Assert.True(
+ SpinWait.SpinUntil(
+ () => rateLimiter.GetStatistics()!.TotalFailedLeases > failedLeasesBefore,
+ TimeSpan.FromSeconds(5)),
+ "Timed out waiting for the second request to be denied by the rate limiter.");
+
+ // Releasing A's create lets it finish and dispose its lease, which pokes the idle
+ // channel to wake B. B then finds the permit available and creates its own connection.
+ createGate.Set();
+
+ DbConnectionInternal? connectionA = await requestA;
+ DbConnectionInternal? connectionB = await requestB;
+
+ // Assert
+ Assert.NotNull(connectionA);
+ Assert.NotNull(connectionB);
+ // B was woken by the lease-release poke and created a fresh connection; A never returned
+ // its connection, so this cannot be reuse.
+ Assert.NotSame(connectionA, connectionB);
+ Assert.Equal(2, factory.CreateCount);
+ Assert.Equal(1, rateLimiter.GetStatistics()!.CurrentAvailablePermits);
+ }
+
+ ///
+ /// 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();
+ using var rateLimiter = new ConcurrencyLimiter(
+ new ConcurrencyLimiterOptions { PermitLimit = 1, QueueLimit = 0 });
+ 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();
+
+ // The first open acquires and releases the single permit, creating a physical
+ // connection that the second request can later reuse.
+ pool.TryGetConnection(
+ firstOwner,
+ taskCompletionSource: null,
+ TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)),
+ out DbConnectionInternal? firstConnection);
+ Assert.NotNull(firstConnection);
+ Assert.Equal(1, factory.CreateCount);
+
+ // Externally hold the only permit so the pool's next AttemptAcquire is denied and the
+ // waiting request must fall back to waiting for a returned connection.
+ using RateLimitLease heldLease = rateLimiter.AttemptAcquire(1);
+ Assert.True(heldLease.IsAcquired);
+ long failedLeasesBefore = rateLimiter.GetStatistics()!.TotalFailedLeases;
+
+ // 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.GetStatistics()!.TotalFailedLeases > failedLeasesBefore,
+ TimeSpan.FromSeconds(5)),
+ "Timed out waiting for the second request to be denied by the rate limiter.");
+
+ pool.ReturnInternalConnection(firstConnection!, firstOwner);
+ DbConnectionInternal? reusedConnection = await waitingRequest;
+
+ // Assert
+ Assert.Same(firstConnection, reusedConnection);
+ Assert.Equal(1, factory.CreateCount);
+ }
+
+ ///
+ /// 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.
+ using var rateLimiter = new ConcurrencyLimiter(
+ new ConcurrencyLimiterOptions { PermitLimit = 4, QueueLimit = 0 });
+ 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;
+ });
+ }
+
+ // Every failed open must have released its permit; otherwise the pool would starve.
+ Assert.True(
+ SpinWait.SpinUntil(
+ () => rateLimiter.GetStatistics()!.CurrentAvailablePermits == 4,
+ TimeSpan.FromSeconds(5)),
+ "Rate limiter did not release all permits after failed opens.");
+ }
+
+ ///
+ /// 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();
+ }
+ }
+
+ ///
+ /// Test connection factory that blocks inside its first physical creation until an external
+ /// gate is released, so a test can hold a rate-limiter permit in-flight while orchestrating
+ /// a second caller. Counts creations and signals when the first creation begins.
+ ///
+ internal sealed class GatedSuccessfulConnectionFactory : SqlConnectionFactory
+ {
+ private readonly ManualResetEventSlim _createGate;
+ private int _createCount;
+
+ internal GatedSuccessfulConnectionFactory(ManualResetEventSlim createGate)
+ {
+ _createGate = createGate;
+ }
+
+ ///
+ /// Gets the number of times the pool asked the factory to create a physical connection.
+ ///
+ internal int CreateCount => Volatile.Read(ref _createCount);
+
+ ///
+ /// Signaled when the first physical creation begins, before it blocks on the gate.
+ ///
+ internal ManualResetEventSlim FirstCreateStarted { get; } = new(initialState: false);
+
+ ///
+ /// Creates a successful stub connection. The first creation signals that it has started
+ /// and then blocks on the gate, holding whatever rate-limiter permit it acquired until
+ /// the test releases it; subsequent creations complete immediately.
+ ///
+ protected override DbConnectionInternal CreateConnection(
+ SqlConnectionOptions options,
+ ConnectionPoolKey poolKey,
+ DbConnectionPoolGroupProviderInfo poolGroupProviderInfo,
+ IDbConnectionPool pool,
+ DbConnection owningConnection,
+ TimeoutTimer timeout)
+ {
+ if (Interlocked.Increment(ref _createCount) == 1)
+ {
+ FirstCreateStarted.Set();
+ _createGate.Wait();
+ }
+
+ return new StubDbConnectionInternal();
+ }
+ }
+
+ #endregion
+
+ #region Connection Timeout Awareness Tests
+
+ ///
+ /// Verifies that two concurrent callers waiting for the same exhausted
+ /// pool observe their own per-caller deadlines
+ /// independently: the caller with the shorter timeout fails with the
+ /// pool-timeout error while the caller with the longer timeout continues
+ /// to wait and eventually succeeds when a connection is returned.
+ ///
+ ///
+ /// Both callers share a single so that
+ /// advancing virtual time deterministically expires only the short-timeout
+ /// caller's CTS without consuming any wall-clock time.
+ ///
+ [Fact]
+ public async Task ConcurrentCallers_ShouldTimeoutIndependently()
+ {
+ // Arrange: pool at max capacity so both callers must wait
+ var poolGroupOptions = new DbConnectionPoolGroupOptions(
+ poolByIdentity: false,
+ minPoolSize: 0,
+ maxPoolSize: 1,
+ creationTimeout: 15,
+ loadBalanceTimeout: 0,
+ hasTransactionAffinity: true,
+ idleTimeout: 0
+ );
+ var pool = ConstructPool(SuccessfulConnectionFactory, poolGroupOptions: poolGroupOptions);
+
+ SqlConnection firstOwner = new();
+ pool.TryGetConnection(firstOwner, taskCompletionSource: null, TimeoutTimer.StartNew(TimeSpan.FromSeconds(15)), out DbConnectionInternal? firstConnection);
+ Assert.NotNull(firstConnection);
+
+ // Use a single fake time provider shared by both callers so we can independently
+ // expire each caller's timeout via virtual time without any wall-clock waits.
+ // Build the timers up-front so they are anchored at virtual time t=0.
+ var fakeTime = new FakeTimeProvider();
+ TimeoutTimer timerA = TimeoutTimer.StartNew(TimeSpan.FromSeconds(1), fakeTime);
+ TimeoutTimer timerB = TimeoutTimer.StartNew(TimeSpan.FromSeconds(10), fakeTime);
+
+ // Caller A: 1s virtual timeout, Caller B: 10s virtual timeout. Both run in
+ // background tasks so the sync pool path can block on the channel as in production.
+ var callerATask = Task.Run(() =>
+ {
+ pool.TryGetConnection(
+ new SqlConnection(),
+ taskCompletionSource: null,
+ timerA,
+ out DbConnectionInternal? connectionA);
+ return connectionA;
+ });
+
+ var callerBTask = Task.Run(() =>
+ {
+ pool.TryGetConnection(
+ new SqlConnection(),
+ taskCompletionSource: null,
+ timerB,
+ out DbConnectionInternal? connectionB);
+ return connectionB;
+ });
// Act: advance virtual time past A's 1s timeout but well within B's 10s timeout.
// A's CancellationTokenSource fires (cancelling its channel wait), B's does not.
@@ -1482,3 +2376,4 @@ public void GetConnection_TimeoutTimerReflectsPoolWaitTime()
#endregion
}
}
+
diff --git a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ConnectionPoolSlotsTest.cs b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ConnectionPoolSlotsTest.cs
index 28e59e7a5d..011533f773 100644
--- a/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ConnectionPoolSlotsTest.cs
+++ b/src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ConnectionPoolSlotsTest.cs
@@ -483,5 +483,156 @@ public void Constructor_EdgeCase_CapacityOfOne_WorksCorrectly()
Assert.Null(connection2);
Assert.Equal(1, poolSlots.ReservationCount);
}
+
+ ///
+ /// Verifies that replacing an existing connection returns and leaves
+ /// the reservation count unchanged, since the replacement reuses the same slot.
+ ///
+ [Fact]
+ public void TryReplace_ExistingConnection_ReturnsTrueAndKeepsReservationCount()
+ {
+ // Arrange
+ var poolSlots = new ConnectionPoolSlots(5);
+ var oldConnection = poolSlots.Add(
+ createCallback: () => new MockDbConnectionInternal(),
+ cleanupCallback: (conn) => { });
+ var newConnection = new MockDbConnectionInternal();
+ var reservationCountBeforeReplace = poolSlots.ReservationCount;
+
+ // Act
+ var replaced = poolSlots.TryReplace(oldConnection!, newConnection);
+
+ // Assert - the slot is reused, so the reservation count is unchanged
+ Assert.True(replaced);
+ Assert.Equal(1, reservationCountBeforeReplace);
+ Assert.Equal(1, poolSlots.ReservationCount);
+ }
+
+ ///
+ /// Verifies that after a successful replace, the new connection occupies the slot (and can
+ /// be removed) while the old connection is no longer present in the collection.
+ ///
+ [Fact]
+ public void TryReplace_ExistingConnection_NewConnectionOccupiesSlot()
+ {
+ // Arrange
+ var poolSlots = new ConnectionPoolSlots(5);
+ var oldConnection = poolSlots.Add(
+ createCallback: () => new MockDbConnectionInternal(),
+ cleanupCallback: (conn) => { });
+ var newConnection = new MockDbConnectionInternal();
+
+ // Act
+ poolSlots.TryReplace(oldConnection!, newConnection);
+
+ // Assert - the new connection now occupies the slot and can be removed,
+ // while the old connection is no longer present.
+ Assert.False(poolSlots.TryRemove(oldConnection!));
+ Assert.True(poolSlots.TryRemove(newConnection));
+ Assert.Equal(0, poolSlots.ReservationCount);
+ }
+
+ ///
+ /// Verifies that attempting to replace a connection that is not in the collection returns
+ /// , does not change the reservation count, and does not insert the
+ /// new connection.
+ ///
+ [Fact]
+ public void TryReplace_NonExistentConnection_ReturnsFalseAndDoesNotAddNewConnection()
+ {
+ // Arrange
+ var poolSlots = new ConnectionPoolSlots(5);
+ var existingConnection = poolSlots.Add(
+ createCallback: () => new MockDbConnectionInternal(),
+ cleanupCallback: (conn) => { });
+ var missingConnection = new MockDbConnectionInternal();
+ var newConnection = new MockDbConnectionInternal();
+ var reservationCountBeforeReplace = poolSlots.ReservationCount;
+
+ // Act
+ var replaced = poolSlots.TryReplace(missingConnection, newConnection);
+
+ // Assert - nothing was replaced and the new connection was not inserted
+ Assert.False(replaced);
+ Assert.Equal(1, reservationCountBeforeReplace);
+ Assert.Equal(1, poolSlots.ReservationCount);
+ Assert.False(poolSlots.TryRemove(newConnection));
+ }
+
+ ///
+ /// Verifies that replacing a connection in an empty collection returns
+ /// and leaves the reservation count at zero.
+ ///
+ [Fact]
+ public void TryReplace_EmptyCollection_ReturnsFalse()
+ {
+ // Arrange
+ var poolSlots = new ConnectionPoolSlots(5);
+ var oldConnection = new MockDbConnectionInternal();
+ var newConnection = new MockDbConnectionInternal();
+
+ // Act
+ var replaced = poolSlots.TryReplace(oldConnection, newConnection);
+
+ // Assert
+ Assert.False(replaced);
+ Assert.Equal(0, poolSlots.ReservationCount);
+ }
+
+ ///
+ /// Verifies that when multiple connections are present, replace swaps only the targeted
+ /// connection and leaves the others untouched.
+ ///
+ [Fact]
+ public void TryReplace_MultipleConnections_ReplacesOnlyTargetConnection()
+ {
+ // Arrange
+ var poolSlots = new ConnectionPoolSlots(5);
+ var connection1 = poolSlots.Add(
+ createCallback: () => new MockDbConnectionInternal(),
+ cleanupCallback: (conn) => { });
+ var connection2 = poolSlots.Add(
+ createCallback: () => new MockDbConnectionInternal(),
+ cleanupCallback: (conn) => { });
+ var newConnection = new MockDbConnectionInternal();
+
+ // Act - replace only connection2
+ var replaced = poolSlots.TryReplace(connection2!, newConnection);
+
+ // Assert - the untouched connection remains, the target was swapped out
+ Assert.True(replaced);
+ Assert.Equal(2, poolSlots.ReservationCount);
+ Assert.True(poolSlots.TryRemove(connection1!));
+ Assert.False(poolSlots.TryRemove(connection2!));
+ Assert.True(poolSlots.TryRemove(newConnection));
+ Assert.Equal(0, poolSlots.ReservationCount);
+ }
+
+ ///
+ /// Verifies that replacing the same connection twice succeeds on the first attempt but
+ /// fails on the second, because the original connection is no longer in the slot.
+ ///
+ [Fact]
+ public void TryReplace_SameConnectionTwice_ReturnsFalseOnSecondAttempt()
+ {
+ // Arrange
+ var poolSlots = new ConnectionPoolSlots(5);
+ var oldConnection = poolSlots.Add(
+ createCallback: () => new MockDbConnectionInternal(),
+ cleanupCallback: (conn) => { });
+ var newConnection = new MockDbConnectionInternal();
+ var newerConnection = new MockDbConnectionInternal();
+
+ // Act
+ var firstReplace = poolSlots.TryReplace(oldConnection!, newConnection);
+ var secondReplace = poolSlots.TryReplace(oldConnection!, newerConnection);
+
+ // Assert - the old connection is gone after the first replace, so the second fails
+ Assert.True(firstReplace);
+ Assert.False(secondReplace);
+ Assert.Equal(1, poolSlots.ReservationCount);
+ Assert.True(poolSlots.TryRemove(newConnection));
+ Assert.False(poolSlots.TryRemove(newerConnection));
+ }
}
}
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 d3405ed113..04afcf3910 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
@@ -101,6 +101,7 @@
+
@@ -118,6 +119,7 @@
+