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 @@ +