smartconnpool: do not hand connections to expired waiters - #241
smartconnpool: do not hand connections to expired waiters#241fasterrt wants to merge 11 commits into
Conversation
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 v22 semaphore-based waitlist. Upstream fixed this in v23 and v24 only, and the upstream patch is written against the channel-based waiter that replaced the semaphore after v22, so it cannot be cherry-picked here. - 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
… ordering Adds three invariants alongside the expired-waiter fix: - TestEvictionPreservesConnectionLifecycle: the returned connection goes to the live waiter untouched. No extra dial, no close, Active and Capacity unchanged, and the connection stays reusable. - TestEvictedWaiterErrorIdentity: a waiter evicted by the returner observes the same ErrTimeout sentinel, and the same RESOURCE_EXHAUSTED vtrpc code, as a waiter that times out on its own. - TestLiveWaiterSelectionMatchesPristine: over 4662 all-live waitlist shapes the fix selects the same waiter and leaves the same ages as the pre-fix rule, which is what preserves FIFO order, the Setting preference and the anti-starvation ageing. The first two fail on pristine release-22.0-github; the third passes on both, as an equivalence guard. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1f56db40-28ec-4335-97a1-b60deee78b07
There was a problem hiding this comment.
Copilot review overview
Review tier: Balanced
Findings: 2
新建议题s introduced by this change (3)
| Severity | Finding |
|---|---|
go/pools/smartconnpool/expired_waiter_test.go — This test can pass without exercising the eviction/removal race at all. The pool starts with two… |
|
go/pools/smartconnpool/post_deadline_handoff_test.go — This polling loop has no timeout, so a regression that prevents the live waiter from enqueueing… |
|
go/list/list.go — The documentation implies this method can safely race with another goroutine, but e.list and the… |
What changed in this PR
Prevents smartconnpool from assigning returned connections to expired waiters while preserving routing, metrics, and connection lifecycle behavior.
Changes:
- Evicts expired waiters safely during connection selection.
- Maps eviction to the existing timeout behavior.
- Adds extensive regression and invariant tests.
| File | Description |
|---|---|
go/pools/smartconnpool/waitlist.go |
Evicts expired waiters and excludes them from starvation counts. |
go/pools/smartconnpool/pool.go |
Handles nil eviction results as timeouts. |
go/list/list.go |
Adds conditional O(1) removal. |
go/pools/smartconnpool/post_deadline_handoff_test.go |
Tests deadline and interior-waiter handoffs. |
go/pools/smartconnpool/expired_waiter_test.go |
Tests eviction behavior and concurrency. |
go/pools/smartconnpool/expired_waiter_invariants_test.go |
Verifies lifecycle, errors, and ordering invariants. |
Suppressed comments (1)
go/pools/smartconnpool/post_deadline_handoff_test.go:203
- This second enqueue wait is also unbounded; if the expired waiter exits early or never reaches the list, the test hangs forever. Make this wait bounded so the failure is reported deterministically.
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.
| // TestExpiredWaiterEvictionIsRaceSafe exercises the eviction path concurrently | ||
| // with waiters removing themselves on genuine context cancellation, to confirm | ||
| // that exactly one party owns each waitlist element. | ||
| func TestExpiredWaiterEvictionIsRaceSafe(t *testing.T) { |
There was a problem hiding this comment.
Confirmed, and the defect was worse than described. Fixed in b4577f2.
Two independent reasons the test could pass vacuously:
- Free slots. As you say,
Capacity: 2was never occupied, so a serialized run satisfied everyGetimmediately and the closingwaiting() == 0assertion succeeded with zero waiters ever enqueued. - The contexts could not reach the eviction path. The expiring group used
context.WithTimeout. A real context closesDoneon expiry, which wakes its own waiter, and that waiter then removes itself inwaitForConn. The pool never evicts it. The eviction path was therefore only reachable in the scheduling gap between deadline expiry and wakeup. Only a context that becomes expired without firingDone(the existinglateExpiryCtxhelper) leaves the element fortryReturnConnSlowto evict.
The test now does all four things you asked for:
- occupies both slots before launching workers, so every worker is forced onto the waitlist;
- synchronizes with
require.Eventually(waiting() == workers)before proceeding, which is deterministic because nothing can leave the list until the gate closes; - overlaps returns with cancellation by closing
cancelGateand putting the held connections back together; - asserts the eviction path actually ran, via
evictedServed == 0andliveServed == workers/3.
Measured against the unfixed base 3a64a919f9:
| old test | new test | |
|---|---|---|
| vs unfixed pool | PASS 5/5 | FAIL, 67 of 67 expired waiters served |
So the old test could not have caught the regression it was named for. Also verified -race 5x and -cpu=1 3x (your serialized scenario) on the fixed code.
| for p.wait.waiting() < 1 { | ||
| time.Sleep(200 * time.Microsecond) | ||
| } |
There was a problem hiding this comment.
Agreed, fixed in b4577f2. Both loops now use require.Eventually with a 30s bound and the original 200µs tick.
Worth noting the failure mode you avoided: the package-level -timeout would eventually fire, but it kills the whole package with a goroutine dump rather than failing this test at a named assertion, so the real regression would be buried.
Your line list was exactly right: 186 and 201 were unbounded, and line 95 is not affected because it already carries a require.True(t, time.Now().Before(deadline)) guard inside the loop. Left that one alone. This also brings the file in line with expired_waiter_test.go and the CLAUDE.md testify convention.
| // 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. |
There was a problem hiding this comment.
Correct, the wording was misleading. Fixed in b4577f2 (comment only).
RemoveIfPresent calls the same unexported remove, which mutates e.list, e.next and e.prev as ordinary fields. It gives no synchronization; the old phrasing "may race with another goroutine removing e" invited exactly the lock-free misuse you describe. The only thing it actually buys a caller is skipping a membership scan.
新建 wording states that it saves the caller a membership scan and nothing more, that it performs no synchronization of its own, that callers must serialize every mutation externally as waitlist does with its mutex, and that under that lock the return value is what tells two contenders which of them owns e.
Verified comment-only: every changed line in list.go is a // line and no //go: directive is touched, so codegen is unaffected.
CI triage — all 5 failures are pre-existing / environmental, none caused by this PRTotals: 100 pass / 5 fail / 2 skipping. Every job that compiles and runs this code passes, including
Upgrade/Downgrade: v22↔v23 CLI flag-rename skewBoth shards fail at step 20 These flags were renamed from
The v23 test tree compensates via Two independent facts rule this PR out as the cause:
The same job also fails on the base-branch lineage ( |
Ubuntu superseded libtinfo5 6.3-2ubuntu0.1 and removed it from the archive, so the download now 404s. curl -O writes the 404 HTML body to the .deb and exits 0, and dpkg then fails with "is not a Debian format archive". This breaks the "Setup MySQL" step for the mysql-5.7 flavor, which fails Unit Test (mysql57) and Unit Test (evalengine_mysql57) before any test runs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
test.go has no -retry flag on this branch, so the run fails immediately with "flag provided but not defined: -retry" and exits 2 before any test starts. Upstream removed -retry from test.go in v22 and dropped it from this workflow at the same time; the flag here is a leftover from the v21 workflow. This matches upstream release-22.0's invocation exactly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
get_previous_release.sh matched 'release-N.0' anchored at end of string, so our 'release-N.0-github' branches never matched. It then fell through to the fallback, which picks the highest release branch in the repo - resolving the "previous" release to release-23.0 while testing a v22 branch. That made the upgrade/downgrade jobs build v23 binaries as the "last release" and fail with "unknown flag: --schema-change-signal", since v23 removed it. Also make the major-version extraction tolerate a refs/heads/ prefix, which previously produced "refs/heads/22" and an arithmetic error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
| Check | Result |
|---|---|
Upstream release-22.0 runs of this workflow |
8/8 success, 10-14 min |
| This fork | timeout at 32-36 min (~3x slower => smaller machine) |
test/vtop_example.sh vs upstream release-22.0 |
byte-identical |
examples/operator/ vs upstream |
byte-identical |
| Whole workflow delta vs upstream | quote style + one setup-go cache: line |
This PR is excluded as a cause: a pod stuck in Pending has never started a container, so it never executed a Vitess binary — the pool change cannot influence it.
Re-running does not help; it failed identically on a second attempt (36m 14s). It is a deterministic capacity shortfall.
Note: this job had 0 successes in its last 100 runs. It was hard-blocked much earlier by an invalid -retry=1 flag (removed in 531f93e), which had masked this failure mode.
The vtop_example job only prints 'kubectl get pods -A' from inside the test script, which is not enough to tell why a pod is Pending. Dump node allocatable/allocated resources, cluster events and pending-pod detail so scheduling failures can be attributed to CPU, memory or storage.
The vtop example schedules the whole operator topology onto a single kind node. On the 4-vCPU ubuntu-24.04 runner the node reaches 3930m/4000m (98%) of allocatable CPU, and the two customer vtbackup-init pods for the resharding step never schedule: 0/1 nodes are available: 1 Insufficient cpu. preemption: 0/1 nodes are available: 1 No preemption victims found The tablets then stay at 2/3 Running and checkPodStatusWithTimeout fails after ~21 minutes. Memory was never the constraint (8866Mi/15.6Gi, 55%). Upstream already runs this job on oracle-vm-8cpu-32gb-x86-64. Give the fork an equivalently sized runner (ubuntu-latest-xl: 16 vCPU / 64 GB, verified available in this repository), while leaving the vitessio/vitess label untouched. vars.VTOP_RUNNER allows infra to redirect the job to a different size without another code change.
Reduce the failure-only diagnostics step to the signal that is actually actionable: node allocatable/allocated resources, non-Running pods and FailedScheduling events. Also drops a broken sub-block: 'kubectl get pods -A -o name' does not emit namespaces, so the follow-up describe failed with 'a resource cannot be retrieved by name across all namespaces'. The step exists because outside vitessio/vitess the runner is chosen by vars.VTOP_RUNNER; an undersized runner otherwise fails as an opaque 60-minute timeout instead of 'Insufficient cpu'.
Scope & cost hardening pass — evidenceEvery non-core change on this branch was re-tested for necessity, not retained because CI was once red.
Runner sizing is deliberately not overclaimed. Requirement ≈ 4.2 CPU;
Core untouched: 关注-up: the 3 CI files are repo-wide repairs and should be lifted into one CI PR against |
Addresses three review findings. No production logic changes: pool.go and waitlist.go are byte-identical, and list.go is comment-only. TestExpiredWaiterEvictionIsRaceSafe could pass without exercising anything. Two defects: The pool had free slots, so a serialized or lightly loaded run satisfied every Get immediately, enqueued no waiters, and the closing waiting()==0 assertion succeeded vacuously. The expiring group used context.WithTimeout, but a real context closes Done on expiry and wakes its own waiter, which then removes itself. The pool never evicts it, so the eviction path under test was only reachable in the scheduling window between deadline and wakeup. The test now holds the pool at capacity until every worker is parked, asserts that full count before proceeding, and uses lateExpiryCtx so expiry cannot wake the waiter. Self-removal and eviction are overlapped deliberately by closing the cancel gate and returning the held connections together. It asserts that no expired waiter is served and that every live waiter is. Measured against the unfixed base 3a64a91: the old test passed 5/5, the new one fails with 67 of 67 expired waiters served. Passes with -race (5x) and with -cpu=1 (3x). Replaces two unbounded polling loops in post_deadline_handoff_test.go with require.Eventually. A regression that stopped a waiter enqueueing previously hung the package until the global test timeout instead of failing at the assertion. Corrects the RemoveIfPresent doc comment. It said callers "may race with another goroutine removing e", but e.list and the link fields are ordinary fields and remove() is not atomic. It only saves a membership scan; callers must still serialise mutations, as waitlist does. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1f56db40-28ec-4335-97a1-b60deee78b07
Copilot review findings addressed —
|
| vs unfixed pool | |
|---|---|
| old test | PASS 5/5 — caught nothing |
| new test | FAIL — 67 of 67 expired waiters served |
Also verified on the fixed code: -race 5x, and -cpu=1 3x, which is the serialized scenario raised in the review.
Medium — post_deadline_handoff_test.go: unbounded polling loops
Both loops (186, and 201 from the suppressed comment) now use require.Eventually with a 30s bound and the original 200µs tick. The package-level -timeout would have fired eventually, but it kills the whole package with a goroutine dump rather than failing at a named assertion, so the real regression would be buried. Line 95 was correctly not flagged: it already carries a require.True(t, time.Now().Before(deadline)) guard inside the loop.
Low — list.go: RemoveIfPresent doc implied race safety
Correct. It calls the same unexported remove, which mutates e.list, e.next and e.prev as ordinary fields. The doc now states that it only saves the caller a membership scan, that it performs no synchronisation of its own, that callers must serialise every mutation externally as waitlist does with its mutex, and that under that lock the return value is what tells two contenders which of them owns e.
CI
104 pass / 0 fail / 2 skipping, MERGEABLE/CLEAN.
One transient Unit Test (mysql84) failure was investigated rather than waved away. go/vt/wrangler hit its 10-minute package timeout in TestTableMigrateJournalExists (9m57s); go/pools/smartconnpool passed in 25.6s and go/list passed in that same job. This commit cannot affect that package: smartconnpool production code is byte-identical, list.go is comment-only, and Go does not compile one package's _test.go files into another package's test binary. The re-run on the identical SHA passed.
Comment and test-doc corrections, one dead-code removal, and one test.
No behavioural change to the fix.
Corrections:
* The production comment claimed that handing a connection to an expired
waiter makes the subsequent Begin fail, which closes the connection and
forces a fresh dial. That mechanism was never established, and this PR's
own lifecycle test asserts the opposite (no close, no redial). The honest
statement is that the handoff is wasted: the waiter cannot use the
connection, so the return makes no progress for anyone. Corrected here and
in expired_waiter_test.go.
* The selection loop does not scan the whole list; it stops at
`age > maxAge || setting == connSetting`. Reworded to what the loop does:
every waiter examined before a target is selected is checked for expiry,
including interior waiters.
* maybeStarvingCount's comment claimed expired waiters cause wasted dials.
Its caller, tryReturnAnyConn, only pops existing connections and never
dials. Replaced with the upstream v23 wording.
* TestPostDeadlineHandoff referred to "pristine v21" (this is the v22
branch) and to reproducing "the production signature". Both overclaimed;
now describes the observable shape of the defect against the unfixed base.
* The synthetic contexts pin the precondition -- an expired waiter still
linked when a returner examines it -- so selection can be asserted without
a race. They are not evidence of how often production reaches that state.
Scope notes added to both helpers.
Dead code:
* Dropped the `ctx != nil` guards. ConnPool.Get calls ctx.Err() before any
waiter exists, so a nil ctx panics there and waiter.ctx is never nil. This
also restores exact parity with upstream v23.
Documentation of the two must-preserve invariants:
* tryReturnConnSlow now states that a waiter already expired when examined
must not be selected, and tryReturnConn documents the residual this does
NOT close: a waiter that is live when examined but expires before it is
notified still receives the connection, exactly as before.
* The RemoveIfPresent call site now records that O(1) self-removal is
load-bearing. The scan it replaces was O(n) under wl.mu, the mutex that
serializes every acquisition and return, and every timing-out waiter paid
it. A future backporter must not conclude that the ctx check is the real
fix and this is cleanup.
* Added TestRemoveIfPresentOwnership. The return value is the ownership
token that decides whether the waiter or the returner notifies the
semaphore; nothing in smartconnpool asserts that contract directly.
Found by adversarial review of this PR. The eviction loop introduced here notified every evicted waiter before assigning the connection to the selected target, so the target's handoff queued behind N semaphore releases. That is a latency regression against pristine, which has no eviction loop and therefore notifies its target immediately after unlocking. Under a cancellation storm the returner could hold the only available connection while waking an arbitrarily large dead cohort, widening the window in which the selected live waiter expires post-selection -- the very post-deadline handoff this PR exists to suppress. The evicted waiters are all going to return a timeout, so their wakeups have no deadline to make and belong last. Reordering also lets the tail collapse to a single `return target != nil`. No behavioural change other than notification order; the two notifications are independent, both elements were already unlinked under wl.mu, and neither party can observe the other.


Problem
smartconnpoolcan hand a returned connection to a waiter whose connection-acquisition context has already expired. The waiter has no way to refuse it, so the connection is consumed by a request that can no longer use it, and a live waiter is starved.release-22.0-githubis affected.go/pools/smartconnpool/waitlist.gois the same blob (40c924da32) onrelease-21.0-github,release-22.0-githuband upstreamrelease-22.0. Upstream fixed this in v23 and v24 only; there is no upstream v22 backport.The defect reproduces deterministically on this branch's exact base (
3a64a919f9):Two must-preserve invariants
Both ship together. A future forward-port or backport must keep both.
Invariant A — a waiter already expired when examined is not selected
tryReturnConnSlowcheckse.Value.ctx.Err()on every waiter it examines before it settles on a target, and removes the expired ones instead of leaving them eligible. Deadlines are heterogeneous, so expired waiters sit anywhere in the list, not only at the front.Serving an already-expired waiter wastes the handoff: that waiter cannot use the connection, so the return makes no progress for anyone and the live waiters keep waiting.
Scope of that claim. This is a selection-time invariant. It does not claim that "no connection can ever be delivered to a context that is expired by the time the waiter wakes." A waiter that is live when selected can still have its context expire before it resumes. That post-selection expiry is inherent to any concurrent handoff, is indistinguishable from a caller cancelling a microsecond after acquisition, and is unchanged by this PR. The defect fixed here is narrower and unambiguous: the pool consulted a waiter it could already see was expired, and handed it the connection anyway.
Invariant B — waiter self-removal is O(1)
This is not cleanup that rode along with Invariant A. It is load-bearing.
npointer-chasing iterations per waiter cancellatione.listidentity check plus an unlinkwl.mu, the single mutex serializing every acquisition and return in the poolO(n)per cancellation, soO(n²)across a timeout storm at depthnO(1)per cancellation,O(n)across the stormThe code being replaced walked the list to locate
elembefore removing it, on bothwaitForConnexit paths. This is a source-level complexity claim, not a measured production one.Why both ship
They are independent properties of the same code path. Invariant A changes which waiter is selected but does nothing about the cost every timing-out waiter pays under
wl.mu. Invariant B bounds that cost but would still let the pool hand connections to waiters that cannot use them. The upstream change contains both.An out-of-tree overload harness — not part of this PR, and not production-equivalent — made the dependency concrete: under sufficient offered load the unfixed pool collapsed; A alone was worse than unfixed; B alone restored goodput; A+B restored goodput. Take that as a reason not to drop B, not as evidence about production behaviour.
Reviewer focus
The production change is 3 files, +81 − 29 — of which only 29 added / 20 removed lines are code, the rest being comments. Four things deserve scrutiny; the rest of the diff is tests, comments and CI.
tryReturnConnSlowloop (waitlist.go) —nextis captured beforeRemoveunlinkse; the front-most live waiter is retained as the fallback target; thebreakconditions are unchanged from pristine.TestLiveWaiterSelectionMatchesPristinepasses on the unfixed base and here, which is what makes it evidence that live-waiter selection is untouched.wl.mu, and after the handoff — deliberate, and the one place this PR diverges structurally from upstream v23, which notifies inline under the lock. Rationale for staying outside the lock: under mass expiry that loop is long, andwl.muserializes the whole pool, so notifying inline would work against Invariant B. Rationale for ordering: the target is the only party with a deadline left to make, so its handoff must not queue behindNsemaphore releases — an earlier revision of this PR had that backwards and was caught in adversarial review. Safety: both elements are unlinked under the lock and parked on their semaphores, so no other party can observe or recycle them before we notify.RemoveIfPresentownership protocol — the returned boolean is the ownership token. Exactly one of {timing-out waiter, returner} seestrue, and exactly onenotifyhappens per waiter. Both production callers holdwl.mu; the helper does no synchronisation of its own.maybeStarvingCountskipping expired waiters — this changes connection-opening decisions, so it is worth a look. It is identical to upstream v23.Why this is a semantic port and not a cherry-pick
Between v22 and v23, upstream rewrote the waiter notification mechanism, so the upstream patch does not apply here:
semaphore+ helper goroutine +donechanconn chan *Pooled[C](buffered, cap 1)waiter.ctxsema.notify(false),connstaysnilconn <- nilconn == nilguard inpool.gowaitResult()helperwaitfieldCherry-picking
459f4cfe21conflicts in every hunk ofwaitlist.goand drags in thewait *waitlist[C]pointer +stack.goalignment commentary, which exist only because upstream's waiter struct grew — ours already hasctx, so it does not grow. Those hunks are deliberately not ported.What is portable applies exactly:
go/list/list.gois byte-identical between v22 and the upstream fix's parent, soRemoveIfPresentis taken verbatim.Port scope: what is and is not identical between v21 and v22
The waitlist and list implementations relevant to the defect are byte-identical between the validated v21 base and v22, which is why the fix transplants without adaptation:
release-21.0-github(865f789e15)release-22.0-github(3a64a919f9)go/pools/smartconnpool/waitlist.go40c924da3240c924da32go/list/list.go2ad837b7c62ad837b7c6go/pools/smartconnpool/pool.go4eb6c4fa57cca706bc1cpool.gois not byte-identical, and the difference is substantive rather than cosmetic: v22 adds idle-count handling —MaxIdleCount,idleCount,setIdleCount(),IdleCount()andcloseOnIdleLimitReached.That interaction was reviewed separately, because this fix touches its reachability. In
tryReturnConn:Before this change, an expired waiter would absorb the connection and
wait.tryReturnConnwould reporttrue. After it, when every waiter is expired and no live waiter remains, the eviction path falls through and returnsfalse— socloseOnIdleLimitReachedbecomes reachable in a case where it previously was not.This is inert under GitHub's deployed/default configuration.
defaultConfig.TxPool(go/vt/vttablet/tabletserver/tabletenv/config.go) sets onlySize,TimeoutandIdleTimeout, soMaxIdleCounttakes the zero value, andsetIdleCount()mapsmaxIdleCount == 0to the pool capacity. The guardidle <= pool.idleCount.Load()therefore always holds on the first iteration andcloseOnIdleLimitReachedreturnsfalsewithout closing anything. When an operator does set--queryserver-config-txpool-max-idle-count, closing a genuinely idle connection when no live waiter wants it is the intended v22 behaviour, not a regression introduced here.Fix
tryReturnConnSlowevicts expired waiters as it examines them, so an interior expired waiter is removed rather than selected (the loop still stops at the existingage > maxAge || setting == connSettingcondition)maybeStarvingCountno longer counts expired waiters as starvinggo/listgains O(1)RemoveIfPresent, replacing the O(n) scan a cancelling waiter performed underwl.mu(Invariant B), and giving the waiter and the returner an unambiguous ownership tokenWaitCount/WaitTimeare restored to successful-acquisition-only semantics:waitForConncan now return(nil, nil), sorecordWaitis guarded onconn == nil(see the operator note below)Operator note: metric semantics after this fix
After this fix, evicted/expired waiters no longer increment
WaitCount/WaitTime. This restores those metrics to successful-acquisition semantics:pool.goreturnsErrTimeouton theconn == nilguard before reachingpool.recordWait(start).Accepted residual
With a live target, the handoff now happens before the eviction wakeups. In the all-expired case (
target == nil) the connection is not stacked untiltryReturnConnSlowreturns, so theO(N)wakeup loop is charged synchronously to the returning goroutine (Pooled.Recycle→put→tryReturnConn), and a waiter enqueuing in that window waits it out.Accepted, not fixed: it needs an extreme transient population of linked-but-expired waiters, the delay is proportional to work that must happen anyway, and the 100 ms starvation worker is a backstop. Upstream v23 is strictly worse here — it performs the same
Nwakeups while still holdingwl.mu, blocking the whole pool rather than one returner.Rollout contract
Deliberately small. This changes waiter selection and
WaitCount/WaitTimesemantics, and nothing else.Canary success — all of:
ResourceExhausted/ txpool acquisition timeoutsConnections, dial rate)Roll back if any of:
ResourceExhaustedrises on the canary tablet and not on controlInUsecollapses whileActivestays high — i.e. the liveness shape this change is meant to make less likely, appearing after rolloutsmartconnpoolNot a success signal: a drop in
WaitCount/WaitTime. Part of any drop is definitional, because evicted waiters no longer record a wait. See the operator note above.#239 adds waiter-level telemetry that would make the InUse/Active and waiter-population signals easier to read. It is not a dependency for this PR; if it lands first, treat it as additional evidence.
Validation
Every test below was run against pristine
3a64a919f9(this PR's exact merge-base) and against this branch. Six are regression tests that fail on the unfixed base for the intended reason; one is a deliberate control that must pass on both.TestPostDeadlineHandoffGet()returning success after its acquisition deadline, and the success-only wait metric therefore exceeding that deadlineTestExpiredWaitersDoNotReceiveConnectionsTestInteriorExpiredWaiterNotServedSettingmatches the returned connection — the case a prefix-only eviction would missTestExpiredWaiterEvictionIsRaceSafecontext.WithTimeoutand the real returner/waiter race, rather than a pinned synthetic contextTestEvictionPreservesConnectionLifecycleActive/CapacityunchangedTestEvictedWaiterErrorIdentityErrTimeoutsentinel itself andRESOURCE_EXHAUSTEDTestLiveWaiterSelectionMatchesPristineSettingpreference and ageing are untouchedgo/listTestRemoveIfPresentOwnershipRemoveIfPresentalways returntruefails it. Nothing insmartconnpoolasserts this directly, and breaking it would double-notify a waiterFailure on the base is for the right reason, not merely a red result — e.g.:
Ordering preserved.
TestLiveWaiterSelectionMatchesPristinechecks 4662 all-live waitlist shapes (lengths 1–4 × settings {nil,s1,s2} × ages {0,9} straddlingmaxAge=8) and asserts the fix picks the same waiter and leaves the same ages as the pre-fix rule. When no waiter is expired, this change is a behavioural no-op.Behavioural equivalence with upstream. The four black-box tests in
expired_waiter_test.goandpost_deadline_handoff_test.gocompile and run unmodified against upstreamrelease-23.0at459f4cfe21(which contains vitessio#20308) and all pass.expired_waiter_invariants_test.gois white-box and cannot compile there by construction — it readswaiter.conn, which is a*Pooled[C]field in v22 and a channel in v23. The goal is equivalence on the defect invariant, not source-level similarity.Scope of the synthetic contexts.
expiredCtxandlateExpiryCtxreportErr() != nilwithout firingDone(). That pins the precondition — an expired waiter still linked when a returner examines it — so selection can be asserted deterministically instead of by winning a race. It proves what the pool does if that state is reached; it is not evidence about how often production reaches it.TestExpiredWaiterEvictionIsRaceSafecovers the same invariant with real contexts and the real race.Suites:
go/pools/...andgo/list/...pass, under-race(×3) and under-race -cpu=1.go vetandgofmtclean.Not included
CI changes on this branch (not part of the fix)
The smartconnpool fix is the 6 files above. Every one is byte-identical to the reviewed head
c60ea43e05—git diff c60ea43e05 HEAD -- go/is empty.The branch also carries 6 CI-infrastructure commits touching 3 files and no Go code. All of them repair defects that were already failing this repository before this PR; none is conceptually part of the backport. They are here only because #241 cannot produce a valid CI signal without them.
514addf408.github/actions/setup-mysql/action.ymllibtinfo56.3-2ubuntu0.1 → 0.2.debnow 404s — Ubuntu dropped the superseded build from the pool, soUnit Test (mysql57)and(evalengine_mysql57)died at Setup MySQL531f93e259.github/workflows/vtop_example.yml-retry=1-retryflag in vitessio#19182; fork merge234e15a386resurrected the argument, soVTop Exampleaborted after 11 s withflag provided but not defined: -retry. Removing it restores byte-identical parity with upstream v22712f7055bbtools/get_previous_release.shrelease-NN.0-githubrelease-[0-9]*.0$misses-githubsuffixes, so the upgrade/downgrade jobs resolved "previous release" to release-23.0 instead of release-21.0 and failed on the v22→v23schema_change_signal→schema-change-signalrename43df8a8dfa.github/workflows/vtop_example.yml22362a104f,550af21363.github/workflows/vtop_example.ymlif: failure()VTop Examplehad never passed in this repository — it isfailureon every non-cancelled run onrelease-22.0,release-23.0andbackport-18369-v22. It now passes in 8m43s.VTop runner sizing — measured, and deliberately not overclaimed
runs-onis now:All three branches were verified by executing the expression in CI: upstream still resolves to
oracle-vm-8cpu-32gb-x86-64; with a repository variable set the override wins; with it unset the fallback isubuntu-latest-xl. Upstream behaviour is unchanged.What the workload actually needs, sampled every 10 s across a full green run:
ubuntu-24.04allocatableThe default runner misses by 130 millicores (3.3 %). That is the entire failure: the kind node reached
3930m (98 %)allocated and twovtbackup-initpods could not be placed —0/1 nodes are available: 1 Insufficient cpu. Memory was never a constraint (55 %).So, stated precisely:
ubuntu-latest-xlis simply the smallest label that both schedules in this organisation and has enough CPU.Eight runner labels were tested. Only
ubuntu-24.04/ubuntu-latest(4 CPU) and the-xltier (16 CPU / 64 GB, bothubuntu-latest-xlandubuntu-22.04-xl) ever schedule here;oracle-vm-8cpu-32gb-x86-64,gh-hosted-runners-16cores-1-24.04,ubuntu-latest-{4,8}-cores,ubuntu-latest-8-core,ubuntu-latest-landubuntu-latest-mstayed queued indefinitely and were cancelled. There is no intermediate tier to pick — the jump is 4 → 16.Cost. Per PR, VTop went from 4 CPU × ~34 min = ~138 CPU-min and a guaranteed red result, to 16 CPU × ~11 min = ~176 CPU-min for a green one: ≈1.28× the CPU-minutes for a signal that previously did not exist. If a ~6–8 CPU label is ever exposed to this org, set the repository/organisation variable
VTOP_RUNNERto it and the cost drops with no source change. That hook is the intended cost-control mechanism.Extraction. All 3 CI files are repo-wide fixes that every PR in this fork benefits from. They should be lifted into a single follow-up CI PR against
release-22.0-githubafter this merges; #241 can then drop them. They are retained here only because without them this PR has no trustworthy CI signal — not because they belong to the backport.Relationship to #238
#238 is the same fix targeting
release-21.0-github.waitlist.goandgo/list/list.goare byte-identical between the two branches — the fixedwaitlist.goblob is6850a54632on both — so the defect-relevant production code is the validated v21 artifact unchanged.pool.gois not byte-identical between v21 and v22 (see Port scope above); the hunk this PR applies to it is textually the same, but it lands on a different surrounding file. #238 could not be retargeted: its branch is rooted in v21, so repointing the base atrelease-22.0-githubrenders as 222 commits / 740 files / +20,234 −7,607 instead of the 6-file smartconnpool change here.Incident context
Production evidence from incident 5229 is on #238. The confidence statement is unchanged:
Upstream
vitessio#20308, backported upstream as vitessio#20354 (v23) and vitessio#20355 (v24).