De-flake ClusterSingletonProxySpec, ClusterSingletonRestartSpec, and DistributedPubSubRestartSpec - #8498
Merged
Conversation
Both specs build multi-node clusters in one process and only fail on loaded CI agents: ClusterSingletonRestartSpec.Restarting_cluster_node_with_same_hostname_and_port_must_handover_to_next_oldest on Windows/net10 (build 130838, at 15s) and ClusterSingletonProxySpec.ClusterSingletonProxy_must_correctly_identify_the_singleton on Linux/net10 (build 130872, ~36s in-test). Neither reproduces in isolation - 8/8 and 6/6 green locally, and 30/30 and 24/24 green here under constrained thread pools and CPU pinning. So the work is to take the load sensitivity out of the structure, not to widen anything. No timeout was raised, no sleep and no backoff was added. ClusterSingletonProxySpec The identify test was synchronous throughout: TestProxy blocked on ExpectMsg for up to 25 seconds per node, and the finally block sat in Task.WhenAll(...).Wait(30s). On a two-core agent the pool starts at two worker threads and injects more slowly, so a blocked test thread is a large fraction of the pool that the five ActorSystems need in order to gossip, heartbeat and deliver the very reply being waited on. The test is now async: TestProxyAsync awaits ExpectMsgAsync and the teardown awaits WhenAll. Awaiting the teardown also matters on its own - the discarded Wait(30s) result meant five cluster systems could still be shutting down while the next test in the assembly ran. The bigger correctness gap was that nothing waited for the singleton to exist. The first TestProxy started before the cluster had formed, so cluster formation, singleton startup and proxy identification all had to fit inside a message timeout. Two gates replace that: every node must see all five members Up, and then each proxy must publish IdentifySingletonResult.Success. The subscription is made in the ActorSys constructor before the proxy actor is created, so the event cannot be missed, and the wait fishes for Success because the proxy also publishes Timeout results on a timer that starts with no initial delay. ClusterSingletonProxy_with_zero_buffering_should_work had the same gap with sharper teeth. It waited for membership and then sent one message into a proxy configured with BufferSize 0, which drops anything it cannot forward (ClusterSingletonProxy.Buffer). Membership does not imply identification, so that send could vanish and the test would wait out its full 25 seconds for a reply that was never coming. It now waits on the identification result. It also terminates its seed node, which was left running for the remainder of the assembly. Two waits in ClusterSingletonProxySingletonTimeoutTest2 read the identify result with AwaitAssertAsync around ExpectMsgAsync, both unbounded. AwaitAssertAsync resolved akka.test.single-expect-default (3s) and so did the inner ExpectMsgAsync on a different TestKit instance, so the budgets did not nest and the loop got about one attempt - against a queue holding the Timeout results the proxy emits every 500ms under that config. Both are now FishForMessageAsync with an explicit 30s bound. The AwaitConditionAsync in ClusterSingletonProxySingletonTimeoutTest gets an explicit bound for the same reason; with none it inherited the 3s default for a two-node join. ClusterSingletonRestartSpec The spec is now async end to end. Three points of substance: Shutdown(_sys1) waits Terminate().Wait(Dilated(5s)) and, when that expires, force-stops the user guardian and logs a warning - verifySystemShutdown defaults to false, so nothing fails. That stops /user but not /system, so remoting keeps sys1's listener bound; the very next statements create sys3 on that same host:port. dot-netty's tcp-reuse-addr is off-for-windows, which is the kind of asymmetry that produces a Windows-only failure. sys1 also owns the singleton at that moment, so a truncated shutdown cuts the hand-over to sys2 short. await _sys1.Terminate() lets CoordinatedShutdown finish: the hand-over completes and the port is released. Each proxy assertion created a TestProbe inside its retry loop. CreateTestProbe blocks the caller until the probe's PreStart has run on the test-actor dispatcher (TestKitBase.cs:739) and replaces the calling thread's SynchronizationContext (TestKitBase.cs:194) - so every attempt added a blocking wait on work that needs a pool thread, a context swap and a leaked system actor. AwaitProxyReplyAsync builds one probe per phase and retries only the send. Replies stay useful across attempts because the message is a plain echo. The 15s window that CI reported waits for sys2 to be fully Removed from sys3's view. Getting there runs sys2's cluster-exiting CoordinatedShutdown phase, which blocks on the singleton hand-over (ClusterSingletonManager.SetupCoordinatedShutdown) and gives up after 10s. JoinAsync only proves sys3's own view of the cluster, so sys2 could still see sys3 as Joining when it left - no hand-over target, phase runs to its timeout, and the removal no longer fits in 15s. A convergence gate now requires both sys2 and sys3 to see the same two-member Up cluster before the Leave. Smaller items: the Within wrappers are gone and each AwaitAssertAsync carries the same bound explicitly, so no wait resolves an ambient deadline; the join assertion reads one Cluster.State snapshot instead of two; the join retry runs at 500ms rather than 100ms, because a JoinTo arriving while the daemon is in TryingToJoin drops it back to Uninitialized and restarts the handshake (ClusterDaemon.cs:1273) - ten of those a second is load on the daemon the test is waiting for. The join must still be re-issued, since sys3 reuses sys1's address and is refused until the old incarnation is removed; a single Join would then wait out retry-unsuccessful-join-after (10s). sys1/sys2/sys3 logs now reach the test output via InitializeLogger, as in ClusterSingletonRestart2Spec. Verified: both specs green in constrained-pool loops (DOTNET_ThreadPool_ForceMaxWorkerThreads=3), full Akka.Cluster.Tools.Tests suite green, build clean with -warnaserror.
… asserting first's restart-kill loop asserted on ActorIdentity.Subject after a 2s Identify window. Use ActorSelection.ResolveOne instead, inside the same retry: the Identify round trip is the delivery confirmation, its temp actor is fresh per attempt, and a final failure raises ActorNotFoundException naming the path that never resolved rather than a bare timeout on a null Subject. Resolve and kill stay in one retry on purpose. Splitting them lets artery's ordinary outbound stream drop the kill in the gap between a successful resolve and the Tell, with no resend - a local artery soak reproduced exactly that. Also: - read the baseline DeltaCount on a probe with an explicit bound instead of on TestActor, which is subscribed to topic1 - name the same-port rebind dependency when the old system fails to stop inside third's WhenTerminated wait - fresh probe and an explicit 1s bound per Count attempt, so the 10s gossip window retries on the 500ms tick instead of spending itself on three 3s waits
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Wave-1 de-flake batch: three flaky test families, test-only changes, no product code touched. No timeout was raised, no sleep or retry backoff added anywhere — every fix removes a structural race or a blocking wait.
ClusterSingletonProxySpec (failed build 130872, Linux, ~36s in-test)
The identify test was synchronous end to end:
TestProxyblocked onExpectMsgfor up to 25s per node while five in-process ActorSystems competed for the same starved thread pool, and nothing waited for the singleton to exist before sending — cluster formation, singleton startup, and identification all had to fit inside one message timeout.IdentifySingletonResult.Success. The event-stream subscription is made before the proxy actor is created, so the result cannot be missed; the wait fishes forSuccessbecause the proxy also publishesTimeoutresults on a zero-initial-delay timer.BufferSize = 0— which drops what it cannot forward. Membership does not imply identification, so the only message could vanish silently. It now gates on the identification result, and terminates its seed system instead of leaving it gossiping through the rest of the assembly.single-expect-default(3s) on different TestKit instances, so their budgets did not nest and the retry loop got roughly one attempt against a queue pre-loaded with 500ms-cadenceTimeoutresults. Both now fish with explicit 30s bounds.Task.WhenAll— the oldWait(30s)discarded its result, letting five cluster systems keep shutting down into the next test.ClusterSingletonRestartSpec (failed build 130838, Windows, 15s removal window)
Shutdown(_sys1)waits 5s, then silently force-stops the user guardian — which does not stop/system, so remoting kept sys1's listener bound while sys3 was created on the same host:port.tcp-reuse-addr = off-for-windowsmakes that rebind a Windows-only failure, matching the Windows-only flake. Nowawait _sys1.Terminate(): the hand-over completes and the port is released.TestProbeinside its retry loop.CreateTestProbeblocks the caller until the probe's PreStart runs and swaps the calling thread'sSynchronizationContext— a blocking wait on work that itself needs a pool thread, once per ~1.1s attempt, at four sites. One probe per phase now; only the send is retried.cluster-exitingphase — and that phase blocks on the singleton hand-over with a 10s timeout.JoinAsynconly proves sys3's own view, so sys2 could still see sys3 as Joining when it left: no hand-over target, phase runs to timeout, removal misses the window. A mutual-convergence gate now requires both sys2 and sys3 to see the same two-member Up cluster before theLeave.JoinToarriving while the daemon is inTryingToJoindrops it back toUninitializedand restarts the handshake, so ten retries a second was load on the very daemon the test was waiting on. The join must still be re-issued — sys3 reuses sys1's address and is refused until the old incarnation is removed.DistributedPubSubRestartSpec (failed build 130808)
The confirmed occurrence livelocked on a reused Identify probe: association establishment flushes the buffered Identifies as a burst of
ActorIdentity(null), and each retry then read one stale null forever — 80 attempts in 25s, none of them waiting.ActorSelection.ResolveOneinside the retry: the Identify round trip is the delivery confirmation, its temp actor is fresh per attempt (structurally immune to the stale-null livelock), and final failure raisesActorNotFoundExceptionnaming the unresolved path instead of a bare timeout on a nullSubject.Tell, with no resend — a local soak reproduced exactly that. The comment in the spec documents it so nobody re-splits it.endbarrier with 45s of headroom.DeltaCountreads moved offTestActor(subscribed totopic1) onto probes with explicit bounds;CountAsyncretries on the 500ms gossip tick with a fresh probe and a 1s bound per attempt instead of spending its 10s window on three 3s waits.Verification
DOTNET_ThreadPool_ForceMaxWorkerThreads=3, ×10Akka.Cluster.Tools.Tests-warnaserror