Skip to content

smartconnpool: do not hand connections to expired waiters - #238

Closed
fasterrt wants to merge 2 commits into
release-21.0-githubfrom
backport-20308-to-release-21.0-github
Closed

smartconnpool: do not hand connections to expired waiters#238
fasterrt wants to merge 2 commits into
release-21.0-githubfrom
backport-20308-to-release-21.0-github

Conversation

@fasterrt

Copy link
Copy Markdown

Problem

smartconnpool can hand a returned connection to a waiter whose connection-acquisition context has already expired. The waiter has no way to refuse it, so a scarce transaction slot is consumed by a request that can no longer use it.

Production evidence

  • Production vttablet runs the vulnerable v21 lineage: 865f789e15165bfd506e75e6e4292e9874924780 (this branch's base).
  • Effective TX-pool acquisition timeout = 1s (helpers/vttablet-up omits --queryserver-config-txpool-timeout, so defaultConfig.TxPool.Timeout = time.Second applies).
  • During incident 5229, the mean successful acquisition wait exceeded the 1s deadline for >= 13.6 minutes (52 consecutive 16s samples, 1147-1207 ms).
  • In the same minute: InUse 899.67 -> 10.75, ResourceExhausted 0 -> 127,834, Active pinned at 900.
  • Healthy comparison window (2026-08-25) max successful wait = 125.95 ms.
  • WaitCount/WaitTime are recorded on successful acquisitions only, so a recorded wait above the deadline means a waiter was served after its deadline had passed.
  • The exact deployed commit reproduces post-deadline successful handoff in a deterministic test (below). This backport prevents it.

Correctness invariant

A connection must not be handed to a waiter whose acquisition context has already expired.

Fix

Semantic backport of vitessio#20308, adapted to the v21 semaphore-based implementation.

  • Expired-waiter eviction during the return scantryReturnConnSlow checks each waiter's context before handing over the connection and evicts expired ones.
  • Scans through interior expired waiters — heterogeneous deadlines mean expired waiters are not only a leading prefix. The scan continues past interior expired waiters to find a live one, and if every waiter has expired the connection is not handed over at all (it returns to the pool). Stopping at the first live waiter would be insufficient.
  • O(1) RemoveIfPresentgo/list gains an idempotent removal so a waiter being evicted by the returner while concurrently timing out itself cannot be double-removed.
  • Preserved WaitCount/WaitTime success-only semantics — the fix makes waitForConn able to return (nil, nil), which previously could not happen. recordWait is therefore guarded on conn == nil so the timeout path never pollutes the success-only metrics. Without this guard the fix would silently change the meaning of the very metric used as evidence above.
  • v21 semaphore adaptation — upstream v23 uses an unbuffered-channel handoff; v21 signals waiters through a semaphore. The eviction is applied to the v21 mechanism; no channel semantics were imported.

Not included

  • no waiter cap (--queryserver-config-txpool-waiter-cap does not exist in v21)
  • no txpool timeout tuning
  • no Launch admission control
  • no unrelated post-v21 fixes

Validation

Base 865f789e15, Go 1.26.4.

Check Result
TestPostDeadlineHandoff on unpatched v21 FAILerr=<nil> ctxErr=context deadline exceeded, WaitCount=1 WaitTime=202ms (connection handed to an expired waiter)
TestPostDeadlineHandoff after fix PASSRESOURCE_EXHAUSTED, WaitCount=0 WaitTime=0s
TestInteriorExpiredWaiterNotServed on unpatched v21 FAILlive served=1 expired served=1
TestInteriorExpiredWaiterNotServed after fix PASSlive served=1 expired served=0
TestExpiredWaitersDoNotReceiveConnections PASS
TestExpiredWaiterEvictionIsRaceSafe PASS
Full suite go/pools/smartconnpool, go/list ok 24.552s / ok 0.493s
Race suite ok 46.723s / ok 1.684s

Healthy-path handoff is unchanged: the added work is a context check per waiter examined, only on the return path, and the existing pool benchmarks/suite show no material change.

Incident causality

The production signature is strongly consistent with this defect and the behavior is reproducible on the deployed commit, but waiter-level telemetry was unavailable to prove incident causality conclusively.

Specifically not claimed: that root cause is confirmed, that vitessio#20308 caused 5229, or that every >1s acquisition was an expired handoff. A recorded wait above the deadline could also arise from scheduling delay after a pre-deadline assignment; source inspection alone cannot exclude that. The follow-up observability PR adds the counters that would settle it.

Upstream

vitessio#20308 — fixed upstream in June, present in v23 and v24 (verified in v24.0.2 source, go/pools/smartconnpool/waitlist.go).

Strategic note

release-21.0 is EOL. This backport is the immediate repair. The longer-term recommendation is migration to supported v24, which already contains this fix; that upgrade is tracked separately and is gated on gh-ost Online DDL strategy migration (v24 removed the gh-ost strategy).

A returned connection could be handed to a waiter whose acquisition
context had already expired. The waiter had no way to refuse it, so the
connection was consumed by a request that could no longer use it.

Semantic backport of vitessio#20308, adapted to the v21
semaphore-based waitlist:

- tryReturnConnSlow evicts expired waiters during the return scan and
  continues past interior expired waiters to find a live one
- maybeStarvingCount no longer counts expired waiters as starving
- go/list gains O(1) RemoveIfPresent so an evicted waiter that is
  concurrently timing out cannot double-remove
- WaitCount/WaitTime remain successful-acquisition-only: waitForConn can
  now return (nil, nil), so recordWait is guarded on conn == nil

No waiter cap, no timeout changes, no unrelated fixes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1f56db40-28ec-4335-97a1-b60deee78b07
Copilot AI balanced review requested due to automatic review settings August 28, 2026 01:08

Copilot AI 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.

Copilot review overview

Review tier: Balanced
Findings: 1 Medium severity · 1 Low severity

新建议题s introduced by this change (2)
Severity Finding
Medium severity go/​pools/​smartconnpool/​post_deadline_handoff_test.go — This polling loop has no timeout, so a failure to enqueue leaves the package test hanging until the…
Low severity go/​list/​list.go — This wording implies that concurrent removals are safe, but e.list and the link fields are…
What changed in this PR

Backports expired-waiter eviction to v21’s semaphore-based smart connection pool.

Changes:

  • Evicts expired waiters before connection handoff.
  • Preserves success-only wait metrics.
  • Adds O(1) waitlist removal and regression tests.
File Description
go/​pools/​smartconnpool/​waitlist.go Evicts expired waiters safely.
go/​pools/​smartconnpool/​pool.go Handles nil eviction results.
go/​list/​list.go Adds conditional O(1) removal.
go/​pools/​smartconnpool/​post_deadline_handoff_test.go Tests deadline and interior-waiter behavior.
go/​pools/​smartconnpool/​expired_waiter_test.go Tests eviction and race safety.
Suppressed comments (1)

go/pools/smartconnpool/post_deadline_handoff_test.go:203

  • This second enqueue wait is also unbounded; if the expired waiter exits or never queues, the test hangs rather than reporting the failed precondition. Bound the poll with require.Eventually.
	for p.wait.waiting() < 2 {
		time.Sleep(200 * time.Microsecond)
	}

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +186 to +188
for p.wait.waiting() < 1 {
time.Sleep(200 * time.Microsecond)
}
Comment thread go/list/list.go
Comment on lines +145 to +148
// RemoveIfPresent removes e from l if e is currently an element of l, and
// reports whether it was removed. Unlike Remove, it does not panic when e does
// not belong to l, so callers that may race with another goroutine removing e
// can use the return value to decide who owns the element.
@fasterrt

Copy link
Copy Markdown
Author

Note on red CI + independent linux/amd64 verification

The red checks on this PR are pre-existing repo-wide CI breakage, not caused by this change.

Nearly every failing job dies in the Get dependencies step before any Go code is compiled or run:

dpkg-deb: error: 'libtinfo5_6.3-2ubuntu0.1_amd64.deb' is not a Debian format archive
dpkg: error processing archive libtinfo5_6.3-2ubuntu0.1_amd64.deb (--install)
...
cat: output.txt: No such file or directory

The libtinfo5 URL now returns a 1807-byte HTML error page instead of a .deb. The test binary never executes.

Proof this is unrelated to this PR: #236 is a Dependabot npm/yarn bump containing zero Go changes, and it has 150 failing endtoend tests on Cluster jobs with the same signature. End-to-End Test (Race) — which does run — passes on this PR.

Independent verification on linux/amd64

Because CI cannot run the suite, I verified in a clean GitHub Codespace (Linux x86_64, go1.26.1 linux/amd64) at this PR's head 93161e9ca8, and ran the identical tests against the unpatched base 865f789e15 as a control. Only the patch differs between the two runs.

Control — unpatched base 865f789e15 (tests copied in, no other change):

--- FAIL: TestExpiredWaitersDoNotReceiveConnections (2.10s)
        expired waiter 0 received a connection; it can never use it
--- FAIL: TestPostDeadlineHandoff (0.20s)
        Get -> err=<nil> elapsed=202.500199ms ctxErr=context deadline exceeded
        WaitCount=1 WaitTime=202.497304ms | dials=1 closes=0
--- FAIL: TestInteriorExpiredWaiterNotServed (0.10s)
        live served=1  expired served=1
        interior expired waiter must not receive a connection
FAIL    vitess.io/vitess/go/pools/smartconnpool

Note WaitCount=1 WaitTime=202ms on the unpatched base: the deployed code records a successful acquisition whose context had already expired. That is the production signature this PR removes.

This PR's head 93161e9ca8:

--- PASS: TestExpiredWaitersDoNotReceiveConnections (2.10s)
--- PASS: TestExpiredWaiterEvictionIsRaceSafe (0.00s)
--- PASS: TestPostDeadlineHandoff (0.20s)
        elapsed=202.652203ms ctxErr=context deadline exceeded | WaitCount=0 WaitTime=0s
--- PASS: TestInteriorExpiredWaiterNotServed (0.10s)
        live served=1  expired served=0

ok      vitess.io/vitess/go/pools/smartconnpool  25.500s
ok      vitess.io/vitess/go/list                  0.004s

race:
ok      vitess.io/vitess/go/pools/smartconnpool  39.074s
ok      vitess.io/vitess/go/list                  1.015s

Same results were obtained on darwin/arm64 (go1.26.4), so the behaviour is not platform-specific.

@fasterrt

Copy link
Copy Markdown
Author

Load-level evidence: 5.5x fewer post-deadline acquisitions, at zero throughput cost

The deterministic tests in this PR prove the invariant. This adds a load-level A/B against the exact deployed base (865f789e15165bfd506e75e6e4292e9874924780) so the effect can be seen under sustained overload rather than only in a constructed race.

Harness. Open-loop staged overload driving the real smartconnpool.ConnPool. Each acquisition creates its own child context carrying only the acquisition deadline, mirroring connpool/pool.go:136. Capacity 20, acquisition timeout 1s (production value, unscaled), mean service 10ms (exponential), stages 1.0x → 1.2x → 1.5x → 2.0x → 3.0x → 4.0x → recovery, 8s per stage. Both trees built and run on the same machine, same harness, same seed.

Production TX-pool waiters are FIFO-ordered, so the run uses heterogeneous deadlines (25% of requests use 150ms): a short-deadline waiter enqueued early expires while longer-deadline waiters behind it are still live, which is exactly the interior-expired-waiter state this PR addresses.

Metric. Successful acquisitions whose acquisition context had already expired (ctx.Err() == DeadlineExceeded, read before cancel()).

run deployed base 865f789e15 this PR
1 1473 275
2 1432 251
3 1372 254
mean 1426 260

5.5x reduction, n=3, no overlap. Worst-case excess past the deadline also drops: base reached 49.0ms / 34.1ms / 17.4ms across runs, this PR stayed at 11.8ms or below.

Throughput is unchanged. Goodput per stage was identical on both builds:

             1.0x   1.2x   1.5x   2.0x   3.0x   4.0x   recovery
base         1600   1920   2040   2195   2230   2189   1600
this PR      1600   1920   2044   2193   2239   2190   1600

So the extra list scan and the eviction path cost nothing measurable at 4x overload with ~6,400 concurrent waiters.

The residue is the goroutine-scheduling floor, not a remaining defect. On this branch tryReturnConnSlow holds wl.mu and verifies ctx.Err() == nil at the instant of selection, so any later expiry happened after a legal selection. The residual events have p50 of 70–119µs. Re-running under -race, which inflates scheduler delay, moves that p50 to 1.9ms and raises the count proportionally — the residue tracks scheduling delay, as expected.

What this does not show

The pre-fix build also kept making progress in this harness: it plateaued at ~2,200 tx/s, held InUse at 19.9/20, and recovered fully. This load test therefore does not demonstrate that the defect causes pool collapse, and it is not offered as incident-causality evidence. It shows the defect is real and reachable under ordinary overload, that this PR removes ~82% of its occurrences, and that it does so without a throughput or latency cost.

Suites

smartconnpool + list, plus -race on both, plus -race under sustained overload: all green, no data races.

fasterrt pushed a commit that referenced this pull request Aug 28, 2026
ExpiredHandoffs was an indicator, not a count. It fired when an expired
waiter was at index 0 or matched the returned connection's Setting, which
is wrong in both directions:

- undercount: an expired waiter behind a live one with age > maxAge was
  the actual pre-fix target, but the counter stayed at 0.
- overcount: an expired waiter at the front incremented the counter even
  when a live Setting-match further down would have won under pristine v21.

Reconstruct the pre-fix target in full instead, so the counter increments
exactly iff the waiter that pristine v21 would have selected was expired.
The pre-fix rule is "first waiter with age > maxAge || setting ==
connSetting, else the front element", so the predicate is evaluated on
every element, in the original order, before any unlinking.

This runs inside the existing scan under the existing lock: no second
pass and no wider critical section. ctx.Err() is now read once per
element and shared by the eviction and the accounting.

No change to #238 behaviour: eviction and target selection are untouched.

Tests: deterministic cases for both counterexamples plus an exhaustive
check over every waitlist shape up to length 4 (333,450 cases) against an
independent reference implementation of the pristine rule. All three fail
on the previous heuristic.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1f56db40-28ec-4335-97a1-b60deee78b07
release-21.0-github builds with go 1.23.10 (go.mod), but the backport of
vitessio#19004 brought in test code written against the release-22.0 toolchain:

  - t.Context()  (testing.T.Context, Go 1.24)
  - wg.Go(...)   (sync.WaitGroup.Go, Go 1.25)
  - b.Loop()     (testing.B.Loop, Go 1.24)

This makes go/pools/smartconnpool fail to typecheck, which fails
'Static Code Checks Etc', every 'Unit Test' matrix job and 'Code
Coverage' on this branch, and hides the results of the new expired-waiter
tests added by this PR.

Rewrite the four call sites with their Go 1.23 equivalents. No test
behaviour changes: the contexts already carry their own timeout and
cancel, and b.ResetTimer() was already being called before the loop.

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

Copy link
Copy Markdown
Author

CI status: what this PR broke vs. what was already broken

Short version: the one failure actually attributable to this PR is fixed (be4736c). Everything still red is pre-existing on release-21.0-github.

Fixed here: smartconnpool did not compile under Go 1.23

This branch builds with Go 1.23.10 (go.mod), but #216 backported vitessio#19004 from a Go 1.24+/1.25 branch and brought test code with it:

Symbol Requires
t.Context() Go 1.24
wg.Go(...) Go 1.25
b.Loop() Go 1.24
go/pools/smartconnpool/pool_test.go:1297:42: t.Context undefined
go/pools/smartconnpool/pool_test.go:1331:6:  wg.Go undefined
go/pools/smartconnpool/pool_test.go:1449:8:  b.Loop undefined
FAIL vitess.io/vitess/go/pools/smartconnpool [build failed]

That failed Static Code Checks Etc, every Unit Test job and Code Coverage — and meant the new expired-waiter tests in this PR were never actually executed. #216 was merged with these same checks red, so this arrived with the base, not with this PR.

be4736c rewrites the four call sites with their Go 1.23 equivalents. No behaviour change: the contexts already carry their own timeout + cancel, and b.ResetTimer() was already called before the loop.

Verified locally against the real go1.23.10 toolchain:

go vet ./go/pools/smartconnpool/... ./go/list/...            # clean
go test        ./go/pools/smartconnpool/... ./go/list/...    # ok
go test -race  ./go/pools/smartconnpool/... ./go/list/...    # ok
golangci-lint v1.60.2 run ...                                # exit 0

Result on CI:

  • Static Code Checks Etcpass
  • ok vitess.io/vitess/go/pools/smartconnpool 23.7s (was [build failed])
  • ok vitess.io/vitess/go/list

Still red, all pre-existing on the base branch

I verified each of these by running them at base commit 865f789e15 with none of this PR's changes, and independently in #240, which touches only CI workflow YAML and contains zero Go changes — yet reproduces them all.

1. libtinfo5 404 — ~57 e2e jobs + Unit Test (mysql57) / (evalengine_mysql57)

dpkg-deb: error: 'libtinfo5_6.3-2ubuntu0.1_amd64.deb' is not a Debian format archive

Ubuntu superseded 6.3-2ubuntu0.1 with 6.3-2ubuntu0.2 and dropped the old file. The pinned URL now returns a 1807-byte HTML 404; curl -L -O (no --fail) writes it to disk and exits 0, then dpkg chokes on the HTML.

Fixed in #240. With that change these jobs install libtinfo5:amd64 (6.3-2ubuntu0.2) and run to completion — #240 went from 69 failures to 2. This PR will need base merged in once #240 lands.

2. planbuilder (10 cases) + semantics/TestCopySemanticInfoIntoColName

All introduced by 126279d2ea ("Fix query planning for complex queries with impossible conditions", from #204), which changed operators/subquery_builder.go and semantics/semantic_table.go and added 308 lines of select_cases.json expectations.

These are not stale-whitespace nits and should not be blindly regenerated. Regenerating testdata/expected/select_cases.json would silently bake in a planning regression:

-  "OperatorType": "VindexLookup"     (expected)
+  "OperatorType": "Route"            (actual)

i.e. a lookup-vindex plan degrading to a plain Route. Other diffs drop a "Table" key and a "Type" key. This needs whoever owns that backport — out of scope for a connection-pool change, and untouched by it (semantics doesn't even import go/list or go/pools/smartconnpool).

3. Flaky / independently broken

None of these touch the connection pool.

Summary

Check Cause Status
Static Code Checks Etc Go 1.23 incompat from #216 fixed here
smartconnpool build Go 1.23 incompat from #216 fixed here
~57 e2e + mysql57 unit tests libtinfo5 404 fixed in #240
planbuilder / semantics incomplete backport in #204 pre-existing, needs owner
vtbackup, TestStaticConfigHUP, vttablet/cli flaky / pre-existing pre-existing

@fasterrt

Copy link
Copy Markdown
Author

Superseded by #241 — retargeted to release-22.0-github

DBA/infra clarified that the backport target must be v22, because the fleet is almost fully upgraded to v22. This PR has been re-created against release-22.0-github as #241.

Why a new PR instead of retargeting this one

Retargeting was measured, not assumed. This branch is rooted in release-21.0-github, so merge-base(release-22.0-github, backport-20308-to-release-21.0-github) is e562c709a5 — long before either branch. Repointing the base would render this PR as:

222 commits, 740 files changed, +20234 -7607

i.e. the entire v21↔v22 divergence, instead of a 6-file fix. The alternative — force-pushing a v22-rooted history onto a branch named ...release-21.0-github — would rewrite an open PR's history, orphan the review context, and leave the branch name contradicting its base. Both were rejected.

The port is content-identical

go/pools/smartconnpool/waitlist.go is the same blob (40c924da32) on release-21.0-github, release-22.0-github and upstream release-22.0, as are go/list/list.go, sema_norace.go, sema_race.go, connection.go and stack.go. So waitlist.go, list.go and both test files in #241 are byte-identical to this PR. Only two things changed:

  • pool.go — same two-line hunk, different line offsets (613/676 vs 574/637)
  • the pool_test: restore Go 1.23 compatibility commit was dropped: it exists only because release-21.0-github declares go 1.23.10 while its pool_test.go uses wg.Go (Go 1.25). release-22.0-github is go 1.24.13 and does not use wg.Go, so no shim is needed.

#241 additionally adds lifecycle, error-identity and ordering proofs, and demonstrates behavioural equivalence against upstream release-23.0.

Note

This branch is not deleted. If any v21 hosts remain in the fleet after the v22 rollout completes, this PR can be reopened as-is — the fix applies unchanged to that lineage.

The evidence comments above remain valid; the production analysis is lineage-independent.

@fasterrt fasterrt closed this Aug 28, 2026
注册 for free to join this conversation on GitHub. Already have an account? 登录 to comment

标签

None yet

项目

None yet

Development

Successfully merging this pull request may close these issues.

2 participants