Skip to content

feat(pool): Dial limiter - #3891

Open
ndyakov wants to merge 10 commits into
masterfrom
ndyakov/connection-dial-limiter
Open

feat(pool): Dial limiter#3891
ndyakov wants to merge 10 commits into
masterfrom
ndyakov/connection-dial-limiter

Conversation

@ndyakov

@ndyakov ndyakov commented Jul 10, 2026

Copy link
Copy Markdown
Member

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 / DialRateBurst so the pool can pace new dials during bursts instead of dialing up to PoolSize immediately (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 via Put, with PoolTimeout (from Get() entry) as a soft escape that still allows an unpaced dial. Idle hits stay unchanged (nil limiter check only).

MinIdleConns refill is integrated: workers take dial tokens before pool turns, use refillPending so token-waiting workers do not inflate MaxActiveConns / poolSize, and Close() wakes parked waiters. New PoolStats.RateLimitedDials (and cluster aggregation) exposes throttle volume. Options propagate through standalone, cluster, sentinel, universal clients and URL dial_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.

…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.
@ndyakov
ndyakov requested a review from ofekshenawa July 10, 2026 09:10
Comment thread internal/pool/pool.go Outdated
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.
Comment thread internal/pool/pool.go
@ndyakov

ndyakov commented Jul 10, 2026

Copy link
Copy Markdown
Member Author

DialRateLimit — measured latency / throughput / connection tradeoff

Smoke benchmark against a real Redis (local, Apple M4 Max, RESP3): 200 goroutines × 100 PINGs arriving as one burst, PoolSize=50, PoolTimeout=3s. Every request in flight simultaneously, i.e. sustained saturation — the worst case for the limiter, since there are never enough idle connections for everyone.

config conns created throttled Gets timeouts errors ops/s avg p50 p99 max
disabled 50 0 0 0 51,267 3.9ms 3.8ms 5.6ms 13ms
rate=50/s 50 0 0 0 45,570 4.4ms 4.2ms 8.6ms 19ms
rate=20/s 33 8,147 0 0 30,616 6.4ms 6.2ms 12.8ms 24ms
rate=5/s 13 5,923 0 0 12,386 15.8ms 13.9ms 53.6ms 260ms

Takeaways:

  • Disabled = unchanged baseline: the burst instantly dials the pool to 50 connections (the "dial storm" from Feature Request: Add built-in connection creation rate limiter with idle-retry capability for burst traffic #3890). Hot-path benchmarks vs master show no significant delta when the feature is off (interleaved benchstat, p≥0.16, allocs byte-identical).
  • rate ≥ burst demand (50/s): behaves like disabled — the token bucket (default burst = rate) absorbs the storm, park machinery never engages.
  • rate=20/s: connection count drops 34% (33 vs 50); cost is ~2× p99 under this saturated workload.
  • rate=5/s: 74% fewer connections (13 vs 50). Under sustained saturation that necessarily costs throughput (fewer pipes) and tail latency (max 260ms = park waits); zero pool timeouts and zero errors — every request completed via reuse or the PoolTimeout escape hatch.

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 PoolSize for a spike that reuse could absorb, and steady-state latency is untouched because the limiter is only consulted on the dial slow path (idle hits never see it).

Rule of thumb: set DialRateLimit at or above your expected steady-state connection-creation demand; it should throttle storms, not normal growth. DialRateBurst (default = rate) tunes how large a spike passes before pacing engages.

…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.
Comment thread internal/pool/pool.go Outdated
- 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.
Comment thread internal/pool/pool.go Outdated
Comment thread osscluster.go
Comment thread internal/pool/pool.go Outdated
}()

err := p.addIdleConn()
// Min-idle refill dials count against the dial rate limit too:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 ofekshenawa left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Left a small comment there. Other than that, all good!

Comment thread internal/pool/pool.go
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).
Comment thread internal/pool/pool.go
ndyakov and others added 3 commits July 14, 2026 15:50
- 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.
Comment thread internal/pool/pool.go
// loop back to grab it — with the deadline passed, the next
// waitForDialSlot escapes immediately, so this cannot loop forever.
if p.hasAcquirableIdleConn() {
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2468097. Configure here.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread internal/pool/pool.go
// the reservation into live counters (bump live first so a
// concurrent check never sees both sides low and
// overspawns).
p.poolSize.Add(1)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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?

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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).

Fix All in Cursor

❌ 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.

Comment thread internal/pool/pool.go
p.idleConnsLen.Add(1)
p.refillPending.Add(-1)
counted = true
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 40a9a9f. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Add built-in connection creation rate limiter with idle-retry capability for burst traffic

2 participants