Skip to content

Tests: don't assume endpoint discovery is synchronous with connect - #3197

Merged
mgravell merged 2 commits into
mainfrom
marc/fix-endpoint-discovery-race
Aug 25, 2026
Merged

Tests: don't assume endpoint discovery is synchronous with connect#3197
mgravell merged 2 commits into
mainfrom
marc/fix-endpoint-discovery-race

Conversation

@mgravell

Copy link
Copy Markdown
Collaborator

EndpointPruningUnitTests.NodeAbsentFromTopologyIsPrunedAfterThreeGenerations fails on CI roughly one run in four, and it fails at the Assert.Contains before any pruning happens - so the test was never reaching the behaviour it exists to check. Seen on #3193; confirmed to reproduce on main, so it is pre-existing rather than from that stack.

Cause

A node learned from the CLUSTER SLOTS reply during handshake is registered on the connection's own path, and that does not have to have completed by the time ConnectAsync returns - GetEndPoints() is a snapshot. The test asserted the discovered node was present the instant connect returned, which is an ordering the library never promised. The invariant it actually cares about is that a discovered node appears promptly, so it now polls.

Reproducing it, which is the useful part

It needs CPU constraint, not load:

taskset -c 0,1 dotnet test tests/StackExchange.Redis.Tests/StackExchange.Redis.Tests.csproj \
  --no-build -c Release -f net10.0 /p:CI=true

Two cores is what a GitHub runner has. On main, that fails about 1 run in 4; with this change, 0 in 6. Six whole-suite runs under heavy parallel load on 14 cores never reproduced it once, which is why it looked like a mystery - and is worth remembering for the next runner-only failure.

Scope

Swept the sibling suites (EndpointPruningUnitTests, EndpointResolutionUnitTests, ServerRetirementUnitTests) for the same assumption. Most assertions on GetEndPoints() are about configured endpoints - present from the start, so not racy - or follow awaited topology work. Only two assert on discovery: the one above, and NewNodeIsDialledByTheAdvertisedForm. Both now poll.

Poll.UntilAsync is new (Helpers/Poll.cs); TestBase.UntilConditionAsync already exists but is protected, and these suites don't derive from TestBase.

Test-only change; no library code touched.

Note in passing: the same two-core setup also turned up a different pre-existing flake, ScriptingTests.SimpleRawScriptEvaluate (RESP3) (1 run in 6, fails in 3ms). Not touched here.

EndpointPruningUnitTests.NodeAbsentFromTopologyIsPrunedAfterThreeGenerations
fails on CI roughly one run in four, at the Assert.Contains *before* any pruning
is involved - so the test never reached the behaviour it exists to check.

A node learned from the CLUSTER SLOTS reply during handshake is registered on the
connection's own path, and that does not have to have finished by the time
ConnectAsync returns; GetEndPoints() is a snapshot. The invariant these tests
care about is that a discovered node appears *promptly*, not synchronously, so
they now poll for it.

Reproducing this locally needs the CPU constraint, not load: `taskset -c 0,1`
plus `-c Release /p:CI=true` reproduces it at about the CI rate, while no amount
of parallel load on 14 cores ever did. Verified on main both ways - failing
1-in-4 before, 0-in-6 after.

Swept the sibling suites for the same assumption. Most assertions on
GetEndPoints() are about *configured* endpoints (present from the start) or follow
awaited topology work, so only the two that assert on discovery needed changing:
the one above, and NewNodeIsDialledByTheAdvertisedForm.
Poll.UntilAsync had its own 5s timeout and ignored the framework's, so a
cancelled or timed-out test would keep polling to its own schedule, and a
predicate that never comes true reported false on our clock rather than
surfacing the framework's cancellation.

The token goes to Task.Delay rather than being probed between polls, so
cancellation lands inside the current interval instead of after it. The local
timeout stays as a backstop for any caller with no ambient limit.
@mgravell
mgravell merged commit 6b2324a into main Aug 25, 2026
6 checks passed
@mgravell
mgravell deleted the marc/fix-endpoint-discovery-race branch August 25, 2026 15:06
mgravell added a commit that referenced this pull request Aug 25, 2026
…3198)

#3197 fixed one flaw in NodeAbsentFromTopologyIsPrunedAfterThreeGenerations - it
asserted discovery synchronously. The failure then moved one line down, to
"two absences is not yet evidence", still around one two-core run in four.

The reason is in the primitive: OnMissingFromTopology returns
`generation - absentSince + 1`, which is the number of generations *elapsed*, not
the number of times this server was observed absent. So any topology application,
from anywhere, advances it - and a test that applies two generations and then
asserts "not pruned yet" is really asserting that nothing else applied one. It
cannot control that, which is why it passes on a fast machine and fails on a
two-core runner. Suppressing the heartbeat was tried first and did not fix it,
so the assertion is the problem rather than any particular background path.

Split accordingly:

- AbsenceIsCountedAsGenerationsElapsed drives the counter directly with explicit
  generation numbers. Deterministic, and it documents the elapsed-versus-observed
  distinction - including that a two-generation gap counts as two, which is the
  thing an end-to-end test cannot control.
- NodeAbsentFromTopologyIsEventuallyPruned keeps what it can honestly own: an
  absent, idle, cluster-discovered node is eventually pruned. Bounded, so a
  regression fails rather than hangs.

Verified with `taskset -c 0,1` and -c Release /p:CI=true: 6 clean whole-suite
runs, against roughly 1 in 4 failing before.

No library change - the product behaviour is fine, and arguably intended: three
generations elapsed is three generations elapsed, whoever applied them.
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.

1 participant