Skip to content

balancer/ringhash: fix flaky TestRingHash_RequestHashKeyConnecting - #9329

Merged
mbissa merged 3 commits into
grpc:masterfrom
lopster568:fix-ringhash-requesthashkey-flake
Oct 13, 2026
Merged

mbissa merged 3 commits into
grpc:masterfrom
lopster568:fix-ringhash-requesthashkey-flake

Conversation

@lopster568

@lopster568 lopster568 commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #9030

TestRingHash_RequestHashKeyConnecting counts connection attempts in the same loop that resumes the blocked one, so it can count an attempt that its own Resume() caused. Resuming makes the endpoint READY, ring_hash publishes a
picker with hasEndpointInConnectingState false, and the queued RPCs pick again and start a connection to one idle endpoint along the ring walk, which is what gRFC A76 asks for (picker.go:93-115). If the loop is still scanning when that
dial reaches the blocking dialer it counts that attempt and the test fails. Both failures linked from the issue show the second subchannel being created after the resume, never before it.

The fix orders the phase so the assertion cannot observe something the test itself caused:

  1. First RPC in a goroutine, wait for a hold to be entered.
  2. Count all 20 holds, want exactly 1.
  3. Second RPC on the test goroutine, short deadline, WaitForReady(true), want DeadlineExceeded.
  4. Count all 20 holds again, want exactly 1.
  5. Only then resume the single started hold.

Both counts precede any Resume(). Only two pickers can exist before that point and a pick against either cannot start a second connection attempt, so at most 1 is tightened to exactly 1 and the fixed 10ms sleep is removed. I proved this
with a throwaway instrument that forces the CI interleaving deterministically (current loop fails 100 of 100, this change 0 of 100), and also reproduced the flake organically under core pinning (current 2 of 300, this change 0 of 2000).
The CI log ordering, the picker state argument and the full numbers are in the first comment.

Relation to #9079

#9079 split the counting loop from the resume loop and made the second RPC synchronous. Both are right and both are kept here. Its stated cause was that the second RPC could reach the picker before the balancer had recorded the endpoint
as connecting; I could not reproduce that and I do not think it can happen, for the reasons in the first comment. The AwaitState(ctx, t, cc, connectivity.Connecting) it added is not carried over, because exitIdleMode sets the channel to CONNECTING before the resolver is started (clientconn.go:402), so that wait is already satisfied while ring_hash has reported nothing.

#9079 was closed by its author saying the test has multiple race conditions that cannot all be fixed, and that other tests in this file are flaky too. I am not claiming otherwise. This addresses the one mechanism that both linked failures
show and that reproduces locally. I have not tried to make the file as a whole race free.

Also in this diff

The one remaining background RPC reports through a buffered channel instead of calling t.Errorf, matching lines 1945-1964 of this file, since a t.Fatalf in the test goroutine could otherwise produce a log from the background goroutine after the test finished. The doc comment now describes what the test checks.

One review point

The check that the second RPC blocked in the picker matches on the message substring from picker_wrapper.go:129. The idiom is used elsewhere in the repo (test/roundrobin_test.go:125), but the string belongs to another package. Nothing
in the fix depends on it, so it can be dropped if unwanted.

RELEASE NOTES: n/a

Fixes grpc#9030

`TestRingHash_RequestHashKeyConnecting` counts connection attempts in the same
loop that resumes the blocked one, so it can count an attempt that its own
`Resume()` caused. Resuming makes the endpoint READY, ring_hash publishes a
picker with `hasEndpointInConnectingState` false, and the queued RPCs pick again
and start a connection to one idle endpoint along the ring walk, which is what
gRFC A76 asks for (picker.go:93-115). If the loop is still scanning when that
dial reaches the blocking dialer it counts that attempt and the test fails. Both
failures linked from the issue show the second subchannel being created after the
resume, never before it.

The fix orders the phase so the assertion cannot observe something the test
itself caused:

1. First RPC in a goroutine, wait for a hold to be entered.
2. Count all 20 holds, want exactly 1.
3. Second RPC on the test goroutine, short deadline, `WaitForReady(true)`, want
   `DeadlineExceeded`.
4. Count all 20 holds again, want exactly 1.
5. Only then resume the single started hold.

Both counts precede any `Resume()`. Only two pickers can exist before that point
and a pick against either cannot start a second connection attempt, so `at most
1` is tightened to `exactly 1` and the fixed 10ms sleep is removed. I proved this
with a throwaway instrument that forces the CI interleaving deterministically
(current loop fails 100 of 100, this change 0 of 100), and also reproduced the
flake organically under core pinning (current 2 of 300, this change 0 of 2000).
The CI log ordering, the picker state argument and the full numbers are in the
first comment.

### Relation to grpc#9079

grpc#9079 split the counting loop from the resume loop and made the second RPC
synchronous. Both are right and both are kept here. Its stated cause was that the
second RPC could reach the picker before the balancer had recorded the endpoint
as connecting; I could not reproduce that and I do not think it can happen, for
the reasons in the first comment. The `AwaitState(ctx, t, cc,
connectivity.Connecting)` it added is not carried over, because `exitIdleMode`
sets the channel to CONNECTING before the resolver is started
(clientconn.go:402), so that wait is already satisfied while ring_hash has
reported nothing.

grpc#9079 was closed by its author saying the test has multiple race conditions that
cannot all be fixed, and that other tests in this file are flaky too. I am not
claiming otherwise. This addresses the one mechanism that both linked failures
show and that reproduces locally. I have not tried to make the file as a whole
race free.

### Also in this diff

The one remaining background RPC reports through a buffered channel instead of
calling `t.Errorf`, matching lines 1945-1964 of this file, since a `t.Fatalf` in
the test goroutine could otherwise produce a log from the background goroutine
after the test finished. The doc comment now describes what the test checks.

### One review point

The check that the second RPC blocked in the picker matches on the message
substring from picker_wrapper.go:129. The idiom is used elsewhere in the repo
(test/roundrobin_test.go:125), but the string belongs to another package. Nothing
in the fix depends on it, so it can be dropped if unwanted.

RELEASE NOTES: n/a
@lopster568

lopster568 commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor Author

Detail behind the description, kept out of the commit message.

The interleaving in the two CI failures

The failure line carries no relative timestamp, so the last row is the next logged event after it.

event 2026-04-01 2026-08-11
first hold intercepted t=+10.98ms t=+6.22ms
first hold resumed t=+22.01ms t=+16.65ms
channel READY t=+22.84ms t=+17.37ms
second subchannel created t=+23.34ms t=+17.47ms
next event after the failure t=+24.10ms t=+18.04ms

Intercept to resume is 11.03ms and 10.43ms, which is the defaultTestShortTimeout sleep plus scheduling. In both runs the second subchannel is created after the resume, so the attempt being counted is one the test's own Resume() caused.

Why the second RPC cannot see a stale picker

#9079's stated cause was that the second RPC could reach the picker before ring_hash had recorded the first endpoint as connecting. Reading the path, that ordering is foreclosed:

  • pickfirst.ExitIdle reports CONNECTING to the channel (pickfirst.go:362) before it calls startFirstPassLocked.
  • That report reaches pickerWrapper.updatePicker through direct calls, with no serializer in between.
  • Only then does startFirstPassLocked reach sd.subConn.Connect() (pickfirst.go:535), which is go acbw.ac.connect() (balancer_wrapper.go:349), and only that goroutine enters the dialer.

The go statement is causally after the picker install, so the dialer being entered already implies a published picker with hasEndpointInConnectingState set. For the same reason AwaitState(ctx, t, cc, connectivity.Connecting) is not a barrier here: exitIdleMode sets the channel to CONNECTING before the resolver is started (clientconn.go:402), so it is satisfied while ring_hash has reported nothing at all.

Why the count is now deterministic

Before the resume the channel can hold only two pickers:

  • the initial one with every endpoint idle, which only the first RPC ever sees. A pick against it triggers exactly one exitIdle.
  • the one with the first endpoint connecting, published before the dialer can be entered. A pick against it starts requestedConnection` at true (picker.go:98), so the idle branch at picker.go:105 is unreachable and the pick has no side effect.

No third picker can appear: the held dial keeps that endpoint in CONNECTING, no other endpoint changes state, and updatePickerLocked abandons its own idle scan as soon as it sees a connecting endpoint (ringhash.go:276).

The short deadline on the second RPC is not a tolerance. Whether the test passes no longer depends on its length, because pickerWrapper.pick calls Pick once before it consults ctx.Done() (picker_wrapper.go:111-157), so the pick happens for any deadline. The deadline only bounds how long a regression has to make itself visible.

How it was proven

The flake does not reproduce on an unloaded workstation, so I wrote a throwaway instrument that blocks the counting loop immediately after Resume() until the channel is READY and a second hold has been entered. That makes the CI schedule deterministic instead of a matter of machine speed.

variant n fail pass
current loop, instrument, rescan all holds 100 100 0
current loop, instrument, scan order left as is 100 67 33
this change, same instrument 100 0 100

The middle row is 67 rather than 100 because the current loop never revisits an index it has passed, so it sees the extra dial only when that endpoint sits at a higher index than the resumed one. A variant that observes every extra dial showed one extra dial in 26 runs and two in 74, since both queued RPCs pick again and each may probe an idle endpoint. Feeding that mix into the index model predicts a 60% failure rate for the unmodified loop, close to the 67 of 100 measured.

With this change the number of started holds at assertion time was exactly 1 on all 600 instrumented runs, never 0 and never 2.

Organic reproduction

Pinning the test binary to one core while leaving GOMAXPROCS at 22 oversubscribes the way CI does:

binary n fail
current 300 2
this change 2000 0

The two failures carry the CI text, Got 2 connection attempts, want at most 1, with the same event order as the runs linked from the issue:

Intercepted connection attempt to addr "127.0.0.1:45209"   t=+2.955987ms
Resuming connection attempt to addr "127.0.0.1:45209"      t=+14.263964ms
Channel Connectivity change to READY                       t=+14.681215ms
Subchannel created                                         t=+14.73364ms
Intercepted connection attempt to addr "127.0.0.1:36147"   t=+14.771514ms
Got 2 connection attempts

Two failures in 300 is a noisy rate and it would be easy to overstate what the 2000 clean runs prove. At the point estimate of 0.67% they would be a one in 600000 coincidence, but at the low end of the 95% interval on that rate (0.18%)
they would be closer to one in 40. So these runs corroborate the fix; th instrumented result above is what establishes it. Neither binary failed under a 15% CPU quota, so core pinning is what makes the flake visible on a workstation
at all.

Rest of the file

#9079's closing comment said other tests in this file are flaky too, so I looked. The same sleep-then-count shape appears in
TestRingHash_ContinuesConnectingWithoutPicksToMultipleSubConnsConcurrently (line 1993), but there the mutating hold.Fail() runs after the assertion, so it has no equivalent read-after-write.
TestRingHash_RecoverWhenEndpointEntersIdle and TestRingHash_RecoverWhenResolverRemovesEndpoint check their bounds at poll loop exit rather than after a settle sleep. I am not claiming those are race free, only that they do not share this mechanism.

Also run

The changed test 200 times without instrumentation, and go test -race ./balancer/ringhash/..., both clean. gofmt and go vet clean.

I have not run this at the 100K scale used on this issue in April. If someone ca kick that off before merging, it would be worth having.

@codecov

codecov Bot commented Aug 18, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.61%. Comparing base (30ce1d5) to head (6cae8bc).
⚠️ Report is 43 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #9329      +/-   ##
==========================================
+ Coverage   83.26%   87.61%   +4.34%     
==========================================
  Files         423      430       +7     
  Lines       35236    30705    -4531     
==========================================
- Hits        29341    26901    -2440     
+ Misses       4402     3803     -599     
+ Partials     1493        1    -1492     

see 430 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread balancer/ringhash/ringhash_e2e_test.go Outdated
// of racing the assertions below. Since there is already an endpoint in
// Connecting state, the picker must queue the RPC rather than trigger
// another connection attempt, so the RPC fails with DeadlineExceeded.
sCtx, sCancel := context.WithTimeout(ctx, defaultTestShortTimeout)

@eshitachandwani eshitachandwani Aug 23, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I ran this for 100K times and it failed for around 40 - 60 times with the error :

[grpc/balancer/ringhash/ringhash_e2e_test.go:3034]: EmptyCall(): got error "context deadline exceeded", want it to contain "while waiting for connections to become ready"

So basically since the second RPC has a very short deadline, it is failing because the context expired before reaching the pick. We can increase the timeout of the second RPC.

We will also need to remove the grpc.WithConnectParams(fastConnectParams) which is also causing the test to fail if the test is going slow.

To fix the race between Resume() and checking the connections , why cant we first check the number of connections attempted and after that call Resume() on the connection? This seems like a simpler and cleaner fix.
With this too , the test flakes 1/300K times with Connection attempt timed out because of the connection time being very low and the last go routine going slow. But after removing the grpc.WithConnectParams(fastConnectParams) this fix seems to work seemlessly.

The approach here works too, but it could still be flaky based on the timeout we decide for the second RPC.
Let me know what you think.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You are right, 10ms is too tight there.

I will increase the second RPC's timeout and drop grpc.WithConnectParams(fastConnectParams) here.

On the ordering, I think we may be describing the same shape: both counts already run before any Resume(), at lines 2884 and 2917, with the single Resume() at 2925. If you are seeing a case where that still races, could you point me at it?

@github-actions

Copy link
Copy Markdown

This PR is labeled as requiring an update from the reporter, and no update has been received after 6 days. If no update is provided in the next 7 days, this issue will be automatically closed.

@github-actions github-actions Bot added stale and removed stale labels Aug 30, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

This PR is labeled as requiring an update from the reporter, and no update has been received after 6 days. If no update is provided in the next 7 days, this issue will be automatically closed.

The second RPC in TestRingHash_RequestHashKeyConnecting used
defaultTestShortTimeout (10ms) as its deadline. Under load the context can
expire before newAttemptLocked checks it, so the RPC fails with a plain
"context deadline exceeded" instead of the picker's "while waiting for
connections to become ready" and the test fails. Reviewer stress runs hit
this in about 50 of 100K iterations. Use a 100ms deadline local to this
test, which is what defaultTestShortTimeout already is in several other
packages. The worst pre-pick delay I measured with -race on an
oversubscribed core was 40ms.

Also drop grpc.WithConnectParams(fastConnectParams) from this test. Its
100ms MinConnectTimeout expires while the dial is held, the subchannel
fails and reconnects, and the test then counts a second connection attempt.
Nothing in this test fails a connection on purpose, so the backoff settings
had no other effect.

With -race, pinned to one core alongside 20 busy loops, the previous
version failed 93 of 1500 runs: 4 with the plain context error, 89 with
extra connection attempts or RPCs routed to another backend after the held
dial timed out. This version passed 1500 of 1500 under the same load.
@lopster568

Copy link
Copy Markdown
Contributor Author

Raised the second RPC's deadline to 100ms with a const local to this test (the shared 10ms defaultTestShortTimeout is unchanged) and dropped grpc.WithConnectParams(fastConnectParams) from this test in 6167c18.

With -race on a core shared with 20 busy loops, the previous version failed 93 of 1500 runs (4 with the plain context deadline exceeded you saw, 89 with extra connection attempts or RPCs routed to another backend after the 100ms MinConnectTimeout expired on the held dial) and this version passed 1500 of 1500; the worst pre-pick delay I measured under that load was 40ms.

100ms is also the value defaultTestShortTimeout already has in pickfirst, rls, priority and a few other packages, so it seemed a fair ceiling.

Comment thread balancer/ringhash/ringhash_e2e_test.go Resolved
If the test goroutine is descheduled for longer than secondRPCTimeout
right after the context is created, the RPC fails at the ctx check in
newAttemptLocked with a plain "context deadline exceeded". The message
check then fails the test. Such an RPC never calls Pick, so it cannot
change the balancer state and sending another one is safe. As suggested
in review, loop until an RPC fails with the picker's "while waiting for
connections to become ready", bounded by the test context.

To exercise the retry path I gave the first two attempts of every run a
1ns deadline. All 200 of those attempts ended with the plain context
error and all 100 runs passed, including the check after the loop that
exactly 1 connection attempt was made. With the normal deadline the test
passed 200 of 200 runs and go test -race ./balancer/ringhash/... passed.

@eshitachandwani eshitachandwani left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM. Adding @mbissa as a second reviewer.

@mbissa
mbissa merged commit ca742c1 into grpc:master Oct 13, 2026
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Flaky test [1/100K]: RingHash_RequestHashKeyConnecting

3 participants