feat(pool): Dial limiter - #3891
Conversation
…dle-retry Adds opt-in DialRateLimit/DialRateBurst. When idle conns are exhausted and a dial would be throttled, the request parks and waits for a returned idle conn (preferring reuse) instead of dialing; only after PoolTimeout does it create a new connection. Exposes PoolStats.RateLimitedDials. Closes #3890.
Fixes from an in-depth concurrency review of the dial rate limiter: - Busy-spin: the post-enqueue double-check now scans for genuinely acquirable idle conns (IDLE/CREATED) instead of trusting idleConnsLen, which also counts UNUSABLE conns and in-flight min-idle prewarm slots. - MinIdleConns bypass: min-idle refill dials now respect the rate limit (waitForDialToken), preventing reconnect storms after mass conn loss. - Lost wakeup: timer/context exits from waitForDialSlot now detect a concurrently delivered signal (abandon CAS) and consume or forward it instead of dropping it. - Shutdown: Close() wakes all parked waiters (signalAll) and the post-enqueue double-check observes closed pools, so throttled Gets fail fast with ErrClosed instead of sleeping out the park timer. - Timeout bound: the throttle deadline is anchored at Get() entry, so waitTurn time counts against PoolTimeout (no ~2x PoolTimeout waits). - Fairness: fresh Gets defer dial tokens to already-parked waiters, removing systematic queue-jumping under sustained throttling. - Unusable re-pooled conns emit a best-effort waiter signal. - Wait queue: O(1) abandon via state CAS + lazy tombstone cleanup (no O(n) removal under the signal mutex). - Stats: RateLimitedDials moved to the end of pool.Stats to preserve the public redis.PoolStats field order.
DialRateLimit — measured latency / throughput / connection tradeoffSmoke benchmark against a real Redis (local, Apple M4 Max, RESP3): 200 goroutines × 100 PINGs arriving as one burst,
Takeaways:
Note on interpretation: sustained saturation makes the tradeoff look maximal. The feature's target scenario is a short burst on an otherwise steady workload — there the pool stops inflating to Rule of thumb: set |
…adeoffs Move dialWaitQueue.len into the test file — production code only needs empty(), and golangci-lint flags len as unused there. Document the live-Redis measurements (latency, throughput, connections created at various DialRateLimit values vs disabled) alongside the tests. Note: the govulncheck CI failure is unrelated to this branch — it flags GO-2026-5856 in crypto/tls@go1.26.4 (fixed in go1.26.5) and fails on master identically; the fix is bumping the CI Go toolchain.
- lint: move the test-only dialWaitQueue.len helper into the test file; production code only needs empty(). - Escape path re-checks the idle pool once before dialing: the select can win the timer race over a concurrently delivered reuse wakeup, and dialing without looking would waste both the signal and a dial. - Fairness: waitForDialSlot now reports whether the caller genuinely parked; only parked callers compete with queued waiters for dial tokens (a token-available early return no longer grants priority). RateLimitedDials counting is decoupled from park status. - ConnectionWaitTime metric includes dial-limiter park time (refreshed before dialing so it never includes the dial itself). - Document live-Redis measurements (latency, throughput, connections created at various DialRateLimit values vs disabled) with the tests. Note: the govulncheck CI failure is unrelated to this branch — it flags GO-2026-5856 in crypto/tls@go1.26.4 (fixed in go1.26.5) and fails on master identically; the fix is bumping the CI Go toolchain.
| }() | ||
|
|
||
| err := p.addIdleConn() | ||
| // Min-idle refill dials count against the dial rate limit too: |
There was a problem hiding this comment.
Here the refill worker takes a pool slot and then waits for its rate-limit token, holding the slot the whole time it waits. So if MinIdleConns and DialRateLimit are both on, the workers can end up sitting on all the slots while they wait, and real Get() calls have nothing left and time out. Could we grab the token first and then the slot?
ofekshenawa
left a comment
There was a problem hiding this comment.
Left a small comment there. Other than that, all good!
Review fixes for the DialRateLimit feature: - Busy-spin (bugbot, high): a Get() barred from taking a dial token by the fairness gate no longer returns early from waitForDialSlot when a token is available — that token belongs to an earlier queued waiter. It now parks until a reuse signal or the deadline instead of looping hot for up to PoolTimeout. - Refill turn starvation (ofekshenawa): min-idle refill workers acquire the dial token BEFORE the pool turn, so a worker sleeping for a token no longer sits on a turn and starves foreground Get() calls. If no turn is free after the token is acquired, the token is refunded to the bucket. Refill also defers tokens to parked foreground waiters. - Cluster stats (bugbot, low): ClusterClient.PoolStats() now aggregates RateLimitedDials across master and replica node pools. - CI: pin govulncheck job to Go 1.26.5 — '1.26.x' resolved to a cached 1.26.4 toolchain that carries GO-2026-5856 (crypto/tls, fixed in 1.26.5), failing the scan independently of the code under review. New regression tests: refill starvation (foreground Get succeeds via PoolTimeout escape while a refill worker waits for a token) and token refund semantics (capped at burst).
- a waiter that finds a token reserved for queued callers parked for the full remaining budget with no token wakeup, oversleeping to PoolTimeout and escape-dialing while a token sat unused; park one token interval and re-run Allow() instead - guard PoolTimeout <= 0 for direct internal/pool users - godoc: limiter paces Get() and min-idle dials only, and is soft under sustained overload (full-budget waiters escape unpaced) - rescale deadline test timings so the pinned regression still fails while CI slack grows to 300ms
checkMinIdleConns bumped poolSize/idleConnsLen once per spawned refill worker, while the workers may sleep on a dial rate-limit token for a long time. newConn treats poolSize as live capacity when MaxActiveConns is set, so Get() answered ErrPoolExhausted for connections that did not exist yet. Track parked workers in a separate refillPending reservation counter that bounds the refill loop, and convert it into the live counters only once the token and pool turn are held and the dial actually starts. The regression test fails on the previous code with "redis: connection pool exhausted" and passes with the fix.
| // loop back to grab it — with the deadline passed, the next | ||
| // waitForDialSlot escapes immediately, so this cannot loop forever. | ||
| if p.hasAcquirableIdleConn() { | ||
| continue |
There was a problem hiding this comment.
Escape path creates tight CPU spin without sleep or context check
Medium Severity
When a throttled Get() escapes the deadline and hasAcquirableIdleConn() returns true, the outer loop continues back to the inner idle-check loop. If the inner loop exhausts getAttempts without obtaining a usable connection (e.g., a hook rejects and puts it back via putConnWithoutTurn), the code re-enters the rate limiter path where waitForDialSlot returns immediately (deadline passed), hasAcquirableIdleConn() still returns true, and continue fires again — forming a tight CPU loop with no sleep, no context cancellation check, and no iteration bound, spinning until Allow() eventually succeeds (up to 1/rate seconds).
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 2468097. Configure here.
There was a problem hiding this comment.
A hook rejected idle connection can be retried in a tight loop after the timeout, so maybe limit the retry and check for context cancellation.
| // the reservation into live counters (bump live first so a | ||
| // concurrent check never sees both sides low and | ||
| // overspawns). | ||
| p.poolSize.Add(1) |
There was a problem hiding this comment.
While min idle jobs wait for a rate limit token, normal requests can fill the pool, so maybe recheck capacity before those jobs create another connection?
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 40a9a9f. Configure here.
| p.idleConnsLen.Add(1) | ||
| p.refillPending.Add(-1) | ||
| counted = true | ||
| } |
There was a problem hiding this comment.
Refill worker skips capacity recheck after token wait
Low Severity
After waitForDialToken() returns (potentially seconds later at low rates), the refill worker acquires a semaphore turn and unconditionally bumps poolSize/idleConnsLen without rechecking whether idleConnsLen < MinIdleConns still holds. Other refill workers or normal Get() paths may have already satisfied the min-idle requirement during the wait, causing unnecessary connection creation beyond MinIdleConns.
Reviewed by Cursor Bugbot for commit 40a9a9f. Configure here.


Adds opt-in DialRateLimit/DialRateBurst. When idle conns are exhausted and a
dial would be throttled, the request parks and waits for a returned idle conn
(preferring reuse) instead of dialing; only after PoolTimeout does it create a
new connection. Exposes PoolStats.RateLimitedDials.
Closes #3890.
Note
Medium Risk
Substantial new concurrency in
ConnPool.Get()and min-idle refill when the feature is on, though default-off limits blast radius; mis-tuning can add tail latency or soft-limit bypass after PoolTimeout.Overview
Adds opt-in
DialRateLimit/DialRateBurstso the pool can pace new dials during bursts instead of dialing up toPoolSizeimmediately (addresses #3890). When enabled, a miss path that would dial first tries a token-bucket limiter; if throttled,Get()parks on a FIFO wait queue and prefers reusing an idle connection returned viaPut, withPoolTimeout(fromGet()entry) as a soft escape that still allows an unpaced dial. Idle hits stay unchanged (nil limiter check only).MinIdleConnsrefill is integrated: workers take dial tokens before pool turns, userefillPendingso token-waiting workers do not inflateMaxActiveConns/poolSize, andClose()wakes parked waiters. NewPoolStats.RateLimitedDials(and cluster aggregation) exposes throttle volume. Options propagate through standalone, cluster, sentinel, universal clients and URLdial_rate_limit/dial_rate_burst.CI pins Go 1.26.5 in govulncheck so runner-cached older patches do not fail the job on stdlib vulns unrelated to the PR.
Reviewed by Cursor Bugbot for commit 40a9a9f. Bugbot is set up for automated code reviews on this repo. Configure here.