Background pool warmup and replenishment for ChannelDbConnectionPool#4452
Conversation
There was a problem hiding this comment.
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()andRemoveConnection()triggers, using the shared connection-creation rate limiter. - Extend
OpenNewInternalConnectionwith anisWarmupmode 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. |
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>
f086ede to
2edbdbb
Compare
…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>
There was a problem hiding this comment.
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;
…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>
…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>
There was a problem hiding this comment.
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
_warmupCtssummary claims the token is passed down into physical connection creation so a blocked open can be unwound. However,OpenNewInternalConnectiononly checks the token before callingConnectionFactory.CreatePooledConnection(...), and the factory path itself does not accept aCancellationToken(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 insideCreatePooledConnection.
// 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
WarmupPassAsynccallsOpenNewInternalConnection(owningConnection: null, ...). That can break certain connection-string paths that assume a non-nullSqlConnectionduring physical open; for example,SqlConnectionFactory.CreateConnectiondereferencessqlOwningConnectionin theUserInstanceinitialization path when the pool is empty. Consider using a dummySqlConnectionas the owning connection whenUserInstanceis enabled, while keepingnullfor the normal warmup case.
DbConnectionInternal? connection;
try
{
// owningConnection is null: warmup connections are created unattached and enter
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>
1ff6632 to
143fb35
Compare
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>
There was a problem hiding this comment.
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 thatADP.IsCatchableExceptionTypedeems catchable and let the rest propagate (thefinallystill 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>
- 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>
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>
There was a problem hiding this comment.
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());
Summary
Implements background pool warmup and replenishment for
ChannelDbConnectionPool, per the newspecs/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 whenMinPoolSize == 0, not running, shutting down, or already at/above the minimum. A_warmupLoopRunningCAS 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-readsCounteach iteration and drives the pool to the minimum regardless. It launches the loop via a fire-and-forgetTask.Runand releases the guard if scheduling throws.RunWarmupLoopAsync()— the background loop. Creates connections serially up toMinPoolSize; anullreturn 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 afinallyon every exit path. All exceptions are absorbed so nothing escapes onto the thread pool.OpenNewInternalConnectionpath as user requests, with noisWarmupexemption: 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 checksErrorOccurred), 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.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).Testing
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.Full ConnectionPool unit suite: 263/263 pass. Warmup tests stable across repeated runs.
Implements
specs/003-pool-warmup/spec.md.