Skip to content

Background pool warmup and replenishment for ChannelDbConnectionPool#4452

Merged
mdaigle merged 19 commits into
mainfrom
dev/mdaigle/pool-warmup
Jul 24, 2026
Merged

Background pool warmup and replenishment for ChannelDbConnectionPool#4452
mdaigle merged 19 commits into
mainfrom
dev/mdaigle/pool-warmup

Conversation

@mdaigle

@mdaigle mdaigle commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements background pool warmup and replenishment for ChannelDbConnectionPool, per the new specs/003-pool-warmup/spec.md. Stacks on the pool rate-limiting work (base: dev/mdaigle/pool-channel-rate-limiting).

On Startup(), the pool asynchronously pre-creates connections up to Min Pool Size, serially (one at a time) through the shared rate limiter used by user requests. Whenever the pool drops below Min Pool Size for any reason (destroy-on-return, idle-timeout eviction, pruning), it automatically replenishes through the same path.

Implementation

  • RequestWarmup() — the single, coalesced entry point for every trigger. No-op when MinPoolSize == 0, not running, shutting down, or already at/above the minimum. A _warmupLoopRunning CAS guard ensures only one warmup loop runs at a time; a request that arrives while a loop is running is simply dropped, because that loop re-reads Count each iteration and drives the pool to the minimum regardless. It launches the loop via a fire-and-forget Task.Run and releases the guard if scheduling throws.
  • RunWarmupLoopAsync() — the background loop. Creates connections serially up to MinPoolSize; a null return means the shared rate limiter is saturated, so it ends the current pass rather than bypassing or spinning on the limiter (convergence to the minimum is handled by the concurrent user creations that saturated it and by re-triggering on the next below-minimum event); publishes fresh connections to the idle channel; and releases the single-loop guard in a finally on every exit path. All exceptions are absorbed so nothing escapes onto the thread pool.
  • Error-state parity with the WaitHandle pool. Warmup creates through the same OpenNewInternalConnection path as user requests, with no isWarmup exemption: a genuine open failure enters the blocking-period error state and a successful open clears it, exactly like an on-demand creation. The warmup-specific behavior is narrow: the loop absorbs the rethrown failure (so it never surfaces as an unhandled exception) and stands down while the pool is blocking (its loop condition checks ErrorOccurred), rather than piling more doomed opens onto a struggling server. Consequently, a user request during the blocking window fast-fails with the cached exception, matching the WaitHandle pool.
  • Triggers wired: Startup() → warmup; RemoveConnection() → replenish (the single choke point for all below-minimum events); Shutdown() cancels + disposes the warmup CTS. The loop reads the CTS token once under a guard and is resilient to disposal (Cancel always precedes Dispose, so post-capture reads observe cancellation, not disposal).
  • The loop never blocks the caller and uses no sync-over-async. The physical connection open itself is currently synchronous (executed on the background task); the connection factory does not yet expose an async open.

Testing

  • Tests added — ChannelDbConnectionPoolWarmupTest.cs, 14 tests covering Stories 1–5: Min Pool Size 0/1/N, serial creation, shared rate limiter, warmup-failure resilience (absorbed, enters the blocking-period error state, user fast-fails during the window), standing down while the pool is blocking, shutdown cancellation, and all replenishment triggers with no overshoot at the minimum.
  • Public API changes: none
  • No breaking changes

Full ConnectionPool unit suite: 263/263 pass. Warmup tests stable across repeated runs.

Implements specs/003-pool-warmup/spec.md.

Copilot AI review requested due to automatic review settings July 16, 2026 19:28
@github-project-automation github-project-automation Bot moved this to To triage in SqlClient Board Jul 16, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements background warmup and automatic replenishment for ChannelDbConnectionPool so pools proactively create connections up to MinPoolSize and restore back to the minimum after below-minimum events, without blocking callers.

Changes:

  • Add a coalesced, serial warmup/replenishment loop driven by Startup() and RemoveConnection() triggers, using the shared connection-creation rate limiter.
  • Extend OpenNewInternalConnection with an isWarmup mode that avoids blocking-period error-state interaction and absorbs warmup failures.
  • Add a new unit test suite (ChannelDbConnectionPoolWarmupTest) and a feature spec document (specs/003-pool-warmup/spec.md).

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs Adds warmup/replenishment loop plumbing, shutdown cancellation, and warmup-specific connection creation behavior.
src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolWarmupTest.cs New unit tests covering warmup, shared rate limiter behavior, failure resilience, shutdown cancellation, and replenishment triggers.
specs/003-pool-warmup/spec.md New spec documenting scenarios and acceptance criteria for the warmup feature.

Base automatically changed from dev/mdaigle/pool-channel-rate-limiting to main July 18, 2026 00:27
mdaigle and others added 2 commits July 20, 2026 10:03
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ctionPool

Adds serial, rate-limited background warmup that pre-creates connections up
to Min Pool Size on Startup and replenishes whenever the pool drops below the
minimum (destroy-on-return, idle-timeout eviction, pruning). Warmup failures
are absorbed without triggering the pool error state, warmup is coalesced to a
single loop, cancels promptly on shutdown, and stops on generation change.

Implements specs/003-pool-warmup/spec.md.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 20, 2026 17:04
@mdaigle
mdaigle force-pushed the dev/mdaigle/pool-warmup branch from f086ede to 2edbdbb Compare July 20, 2026 17:04
…m failure

- RequestWarmup() now returns early when Count >= MinPoolSize, so hot-path
  callers (e.g. every RemoveConnection) don't schedule a thread-pool work item
  just to run a pass that immediately exits.
- OpenNewInternalConnection(isWarmup: true) now rethrows genuine creation
  failures (still without entering the blocking-period error state) instead of
  absorbing them as null. A null return now unambiguously means the rate limiter
  is saturated. WarmupPassAsync catches real failures, traces, and stops the
  pass rather than retrying on a 50ms cadence, avoiding a noisy spin when opens
  persistently fail.
- Strengthen the failure test to assert warmup does not spin on persistent
  creation failures.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:1071

  • RequestWarmup can start a warmup loop even when the pool is already at/above MinPoolSize (e.g., any RemoveConnection call). The loop will immediately no-op, but it still incurs Task.Run scheduling overhead and makes the behavior differ from the spec’s “only when below minimum” trigger. Add a fast-path Count >= MinPoolSize check here to keep warmup truly demand-driven.
            // No-op when there is nothing to pre-create (MinPoolSize == 0), the pool is not running,
            // or shutdown has cancelled background activity.
            if (MinPoolSize == 0 || State != Running || _warmupCts.IsCancellationRequested)
            {
                return;

Copilot AI review requested due to automatic review settings July 20, 2026 17:10
…uest contract

- Shutdown now treats ObjectDisposedException from _warmupCts.Cancel()/Dispose()
  as an expected no-op instead of tracing it as a failure, so it never emits
  misleading trace noise. (Shutdown is already guarded idempotent via the
  _shutdownInitiated CAS, but this hardens the per-step cleanup.)
- Warmup_RateLimiterSaturated_UserSharesSameLimiter now asserts the queued async
  TryGetConnection call returns false with no inline connection, making the
  async-request contract explicit.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

Copilot AI review requested due to automatic review settings July 20, 2026 17:17
…ncellation docs

- SqlConnectionFactory.CreateConnection: guard the User Instance path's write to
  sqlOwningConnection._applyTransientFaultHandling with a null check. Warmup
  creates connections with a null owning connection, which would otherwise
  NullReferenceException for 'User Instance=true' connection strings. Matches the
  null-safe reads already present earlier in the method.
- Correct the _warmupCts field and Shutdown comments: the token stops the warmup
  loop at its await points / pre-create checks, but does not abort an in-progress
  synchronous physical open (the factory does not yet accept a cancellation
  token), so the docs no longer claim behavior the implementation doesn't provide.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@mdaigle mdaigle added this to the 7.1.0-preview3 milestone Jul 20, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (3)

src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:135

  • The _warmupCts summary claims the token is passed down into physical connection creation so a blocked open can be unwound. However, OpenNewInternalConnection only checks the token before calling ConnectionFactory.CreatePooledConnection(...), and the factory path itself does not accept a CancellationToken (see the nearby TODO). This comment is misleading about the cancellation behavior.
        /// <summary>
        /// Cancels in-flight background warmup/replenishment when the pool shuts down so no new
        /// connections are created after shutdown begins (Story 4). The token is observed at the
        /// warmup loop's await points and before each creation attempt, so it stops the loop and
        /// prevents further attempts promptly. It cannot abort an already in-progress physical open:

src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:377

  • Shutdown comment says cancelling _warmupCts “unwinds a create that is currently blocked”, but the connection factory does not take a cancellation token (only a timeout budget). Cancellation here prevents additional warmup attempts and lets the loop exit promptly once the in-flight create completes; it does not abort a synchronous create already inside CreatePooledConnection.
            // routes returning connections to RemoveConnection.
            State = ShuttingDown;

            // Cancel any in-flight background warmup/replenishment so no new connections are

src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:1205

  • WarmupPassAsync calls OpenNewInternalConnection(owningConnection: null, ...). That can break certain connection-string paths that assume a non-null SqlConnection during physical open; for example, SqlConnectionFactory.CreateConnection dereferences sqlOwningConnection in the UserInstance initialization path when the pool is empty. Consider using a dummy SqlConnection as the owning connection when UserInstance is enabled, while keeping null for the normal warmup case.

                DbConnectionInternal? connection;
                try
                {
                    // owningConnection is null: warmup connections are created unattached and enter

Copilot AI review requested due to automatic review settings July 20, 2026 17:25

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

Comment thread specs/003-pool-warmup/spec.md Outdated
Comment thread specs/003-pool-warmup/spec.md
Warmup runs on a background task (never blocking callers, no sync-over-async in
the loop), but the physical connection open is still synchronous because the
factory does not yet expose an async open. Reword the implementation note and
acceptance criterion so the spec no longer claims 'async I/O throughout'.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 20, 2026 17:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Copilot AI review requested due to automatic review settings July 20, 2026 18:18
Copilot AI review requested due to automatic review settings July 22, 2026 21:49
@mdaigle
mdaigle force-pushed the dev/mdaigle/pool-warmup branch from 1ff6632 to 143fb35 Compare July 22, 2026 21:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

cheenamalhotra
cheenamalhotra previously approved these changes Jul 22, 2026
Replace the flaky SpinWait polling in three ChannelDbConnectionPool
rate-limiter tests with deterministic invariants, eliminating the CI
timeouts (e.g. 'Timed out waiting for the second request to be denied
by the rate limiter'). No production code changes.

- RateLimiter_PermitDenied_ReusesReturnedConnection: rely on the held
  permit - while it is held every AttemptAcquire is denied, so the
  waiting request can never create and must reuse the returned
  connection. Drop the SpinWait on TotalFailedLeases.

- RateLimiter_LeaseReleaseWakesRateLimitedWaiter_CreatesPhysicalConnection:
  bump maxPoolSize 2->3 so the lease-release poke (gated on
  ReservationCount < MaxPoolSize) always fires even while the denied
  waiter still holds its transient slot reservation; the unbounded idle
  channel buffers the poke so it is never lost. Drop the SpinWait.

- RateLimiter_LeaseDisposedOnFailure_DoesNotStarvePool: the lease is
  disposed in the createCallback finally before the failure completes
  the awaited request, so after all opens throw the permits are already
  back. Replace the SpinWait with a direct assert.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 22, 2026 23:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/ConnectionPool/ChannelDbConnectionPool.cs:1229

  • The warmup loop’s outer catch (Exception) currently swallows non-catchable exceptions (e.g., OutOfMemoryException / NullReferenceException), which can hide serious corruption bugs. It’s safer to only absorb exceptions that ADP.IsCatchableExceptionType deems catchable and let the rest propagate (the finally still releases the warmup guard).
            catch (Exception ex)
            {
                // Defense in depth: the loop must never throw onto the thread pool.
                SqlClientEventSource.Log.TryPoolerTraceEvent(
                    "<prov.DbConnectionPool.RunWarmupLoopAsync|RES|CPOOL> {0}, Warmup loop failed, absorbing: {1}", Id, ex);

The deterministic rewrites weakened some guarantees these tests' names
and summaries claimed, so update them to match what is now verified.

- Rename RateLimiter_LeaseReleaseWakesRateLimitedWaiter_CreatesPhysicalConnection
  to RateLimiter_LeaseReleaseAllowsRateLimitedWaiterToCreatePhysicalConnection:
  the parked-and-woken path is no longer guaranteed every run, so drop the
  'Wakes' claim and rewrite the summary to describe the interleaving-robust
  invariant (the single permit serializes the opens; B creates its own
  connection once A releases its lease).

- RateLimiter_PermitDenied_ReusesReturnedConnection: reword the summary to
  drop 'falls back to waiting' (a park is no longer guaranteed) and state the
  held-permit invariant that makes reuse the only possible outcome.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 22, 2026 23:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

- ChannelDbConnectionPool: filter both warmup catch-all blocks with
  ADP.IsCatchableExceptionType so non-catchable exceptions (e.g.
  OutOfMemoryException) propagate instead of being absorbed into a pool
  that keeps running in a corrupted state; the single-loop guard is still
  released on every path.
- WarmupTest.AwaitWarmupToMinimum: wait briefly instead of re-requesting in
  a tight loop when no warmup task is published yet, so the bounded wait
  never busy-spins a CPU core on loaded CI.
- WarmupTest: wrap the three gated tests in try/finally so createGate is
  always released even if an assertion throws, so warmup's parked creation
  in the gated factory never strands a thread-pool thread.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 23, 2026 00:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Route the ChannelDbConnectionPool idle-timeout stamp (ReturnInternalConnection)
and expiry check (IsLiveConnection) through a single injected TimeProvider,
defaulting to TimeProvider.System so production behavior is unchanged. Add a
SetReturnedTime(DateTime) overload on DbConnectionInternal so the pool can
source the return stamp from that provider.

Rewrite Replenish_AfterIdleTimeoutEviction_RefillsToMinimum to inject a
FakeTimeProvider and Advance() the clock past the timeout between returning the
two connections, so exactly one connection expires. This removes the wall-clock
race that could evict both connections under a slow agent/CI and produce an
extra create, making the create-count assertion deterministic.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 23, 2026 16:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

src/Microsoft.Data.SqlClient/tests/UnitTests/ConnectionPool/ChannelDbConnectionPoolTest.cs:1931

  • This test no longer synchronizes on B actually being denied by the rate limiter while A holds the only permit. In some interleavings (especially the async path), B may start after A releases the lease, so the assertions can pass without exercising the intended “rate-limited waiter / lease-release poke” behavior.
            // Caller B is denied a permit (A holds it) and falls back to the idle-channel wait.
            Task<DbConnectionInternal?> requestB = Open(new SqlConnection());

@cheenamalhotra cheenamalhotra moved this from Waiting for customer to In review in SqlClient Board Jul 23, 2026
@mdaigle
mdaigle enabled auto-merge (squash) July 24, 2026 17:36
@mdaigle
mdaigle merged commit 3d18ee0 into main Jul 24, 2026
359 checks passed
@mdaigle
mdaigle deleted the dev/mdaigle/pool-warmup branch July 24, 2026 17:38
@github-project-automation github-project-automation Bot moved this from In review to Done in SqlClient Board Jul 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

5 participants