Skip to content

fix(singleflight): give waiting callers the real error of the run they waited on - #1014

Merged
rueian merged 2 commits into
redis:mainfrom
FZambia:fix/singleflight-joiner-error
Aug 1, 2026
Merged

fix(singleflight): give waiting callers the real error of the run they waited on#1014
rueian merged 2 commits into
redis:mainfrom
FZambia:fix/singleflight-joiner-error

Conversation

@FZambia

@FZambia FZambia commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Summary

A deduplicated call handed joined waiters nil even when the run failed. This gives every waiter the real error of the run it waited on.

Impact

  • nil looks like success, so callers that retry until an operation succeeds stop early on a silent failure.
  • Sentinel: refreshRetry retries refresh() until it returns nil. refresh() is deduplicated, so during a failover it can wait on another caller's failed refresh, be told nil, and stop retrying while the master is still unresolved — recovery that should finish when the election ends instead waits for the next periodic refresh, or never happens.
  • Cluster: pick/pickMulti/pickMultiCache mask the real refresh error as ErrNoSlot.

Root cause

singleflight.Do returned nil to any caller that joined an in-flight run, regardless of how that run ended.

Fix

Store the error on a per-run holder written before the channel is closed, so every waiter reads the outcome of exactly the run it waited on. Cluster callers surface the real error through their existing return nil, err path — no call-site changes needed.

Tests

  • TestSingleFlightJoinerReceivesFlightError — a joined waiter now observes the run's error.
  • Cluster and sentinel suites pass unchanged.

Note

Medium Risk
Touches core concurrency deduplication used by sentinel failover and cluster slot refresh; behavior change is intentional but affects error propagation on shared in-flight work.

Overview
call.Do / DelayDo no longer return nil to goroutines that joined an in-flight run when that run failed. Waiters now receive fl.err from a new per-run flight struct (error set before the wait channel closes), instead of always getting nil after <-ch.

This fixes retry-until-success callers (e.g. sentinel refreshRetry, cluster pick paths) that could treat a failed shared refresh as success and stop retrying or mask the real error as ErrNoSlot.

Tests extend TestSingleFlight to expect all 1000 callers to see the error, and add stress/race coverage for joiners, overlapping flights, and cancellable waiters at completion.

Reviewed by Cursor Bugbot for commit 5cb1475. Bugbot is set up for automated code reviews on this repo. Configure here.

…y waited on

A caller that arrived while fn was already running waited for that run
but then always got nil, no matter how the run ended. nil looks like
success, which breaks callers that retry until an operation succeeds.

The concrete victim is sentinelClient.refreshRetry, which retries
refresh() until it returns nil. refresh() is deduplicated, so during a
sentinel failover — when refreshes keep failing until a new master is
elected — refreshRetry can end up waiting on a refresh started by some
other caller. If that refresh fails and refreshRetry is told nil, it
stops retrying while the master is still unresolved. If the other
caller does not retry either (a periodic topology refresh tick, for
example), nobody is left to re-resolve the master: recovery that should
finish the moment the election ends instead waits for the next periodic
refresh, or never happens.

Store the error on a per-run holder written before the channel close,
so every waiter reads the outcome of exactly the run it waited on.
Cluster callers are compatible: pick/pickMulti/pickMultiCache now
surface the real refresh error instead of falling through to ErrNoSlot,
and lazyRefresh ignores results entirely.
Comment thread singleflight.go
// before ch is closed, so any goroutine that observed the close may read err
// without further synchronization.
type flight struct {
err error

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.

I wonder if an additional flight struct is needed. Can't the error be embedded in the call struct directly?

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.

Good question — it was tried, and the error cannot live on call, because call is reused by the next flight.

do() clears the flight before it closes the channel, so the next flight can start while the previous waiters are still waking up. If the error is a field of call, that next flight overwrites it inside this window, and the waiters then read its result instead of their own.

All five current tests still pass with the error moved to call, so the suite does not cover this. A test with 50 waiters, where the next flight starts as soon as the previous one clears its counter, fails at once:

WARNING: DATA RACE
  Write at 0x00c00007eda0 by goroutine 60:   (*call).do()   singleflight.go:70
  Previous read by goroutine 29:             (*call).Do()   singleflight.go:42

--- FAIL: joiner 0 got <nil>, want flight failed

That <nil> is the bug of this PR again: a waiter is told its flight succeeded when it failed.

The data race is a second problem. A waiter reads the error without the lock. That is safe for its own flight, because the write happens before close(ch) and the read happens after <-ch. It is not safe against the next flight's write, which has no such ordering. So an error on call would also need the mutex on every wake, while flight.err needs no synchronization at all.

flight holds only what belongs to one flight. The channel is already per flight and the error has the same lifetime, so they live together, at the cost of one small allocation per refresh.

That 50-waiter test can be added here if you want it, since the current tests clearly do not cover this case.

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.

hmm, ok. Maybe we should replace the ch channel with a wg sync.WaitGroup to reduce GC overhead.

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.

IDK.. I was unable to have wg and keep cancellable property. All solutions with the same behavior but fixed issue require 1 extra alloc compared to main branch.

nobody waits a waiter joins
main today, waiters get nil 112 B, 1 alloc 112 B, 1 alloc
flight{err, ch}, this PR 136 B, 2 136 B, 2
flight{err, wg} 32 B, 1 not cancellable
chan error, buffered 128 B, 2 128 B, 2
error box, allocated by the first waiter 112 B, 1 128 B, 2

Last one is kinda close perf-wise and only 16 bytes extra alloc. I can push that version if it makes more sense (but it's more complicated than the one 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.

I see. Sorry, I forgot about the cancellation.

rueian pushed a commit that referenced this pull request Jul 28, 2026
…onn (#1015)

## Summary

On a failover `_switchTarget` could close the live `mConn`/`rConn` and
leave the client pointing at a closed mux. This closes only connections
the call itself dialed.

## Impact

- After an **ordinary failover** the client loses all connectivity to a
*healthy* Redis: every command fails with `ErrClosing` **without even
attempting to dial**, and the client holds no usable connection to any
node.
- Recovery depends on `refreshRetry`, which `switchTargetRetry` spawns
on failure. That normally re-dials and repairs `mConn` — so the usual
shape of this is a total outage lasting as long as it takes Sentinel's
view to settle, not a permanent one.
- It becomes permanent when it coincides with a concurrent failing
refresh: the deduplicated `refresh()` hands the joining `refreshRetry`
`nil`, `refreshRetry` treats that as success and exits, and nothing is
left to replace the closed `mConn`. That is the singleflight bug fixed
in #1014, which is why the two belong together.

## Root cause

`_switchTarget` reuses the live `mConn`/`rConn` as `target` when
Sentinel reports the address the client already holds. On the ROLE
failure paths it then called `target.Close()` and returned *without
swapping*, so `mConn`/`rConn` referenced a closed mux and nothing
replaced it (the swap is only reached on success). This is reachable in
normal operation: a just-demoted master answers `ROLE` with `slave` →
`errNotMaster` → the live connection is closed out from under every
caller.

## Fix

Close only connections this call dialed. A reused connection is still
referenced by `mConn`/`rConn` and must outlive the failure; the caller
retries the refresh and replaces it once a real master is found.

Note the trade-off: keeping a demoted node as `mConn` means writes fail
`-READONLY` until refresh replaces it. That is strictly better than
`ErrClosing` on every command including reads, and it is transient
either way.

## Tests

- `TestSwitchTargetDoesNotCloseReusedConn` — fails against the previous
code (closes the connection still referenced by `mConn`), passes with
the fix.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **High Risk**
> Touches Sentinel connection lifecycle during failover; the prior
behavior caused prolonged total client outage (`ErrClosing` on every
command). The fix is narrowly scoped but sits on critical Redis
connectivity paths.
> 
> **Overview**
> Fixes a Sentinel failover bug where `_switchTarget` could **close the
live `mConn`/`rConn`** when ROLE validation failed but the target was
the **reused** connection for an address the client already held (e.g.
demoted master returns `slave` → `errNotMaster`).
> 
> The change tracks whether `target` is reused vs newly dialed and uses
**`closeIfOwned`** on ROLE/command errors and role mismatches so only
connections created in this call are closed; reused connections stay
open until a successful swap or a later refresh replaces them.
> 
> Adds **`TestSwitchTargetDoesNotCloseReusedConn`** to lock in that
`mConn` must not be closed on that failure path.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
e2e751b. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
The call struct is reused by the next flight, which is why the error lives
on the flight rather than on call. Nothing covered that: moving the error to
call keeps all the existing tests green while bringing back the nil the
waiters used to get.

TestSingleFlightJoinerKeepsItsOwnFlightError closes that gap. Fifty waiters
join a failing flight while the next one starts as soon as the first clears
its counter, so it overlaps with the waiters waking up. With the error on
call it fails at once, both as a wrong value and as a data race.

The other two drive callers into the moment a flight completes, half of them
able to be cancelled and half not, since the two take different branches of
Do. One leaves the cancellable callers alone, the other cancels them.
@FZambia
FZambia force-pushed the fix/singleflight-joiner-error branch from 61b005e to 5cb1475 Compare July 28, 2026 15:38
@rueian
rueian merged commit 25a788b into redis:main Aug 1, 2026
29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants