Skip to content

Commit 00c4f1a

Browse files
Backport MNTR conductor reliability to v1.5 (#8431 + #8485) (#8488)
Barrier failures now actually fail the barrier: three Player.cs sites replied with the FSM-inherited Failure type, so the barrier ask completed successfully and the failure was silently swallowed - the asking node walked on unsynchronized and the breakage surfaced elsewhere as an unrelated-looking flake. All three now reply Status.Failure, plus the discarded-ask cleanup and the runner mislabeling fix. Conductor keeps re-registered nodes: a restarting node's stale ClientDisconnected was matched by role name and evicted the fresh registration, then the barrier coordinator dropped the evicted node's arrivals with no reply, hanging it for the full ask timeout. Disconnects are now matched against the registered FSM identity, unregistered arrivals get an explicit BarrierResult(false), and ReleaseAll detaches the shared event-loop groups. Validated locally with revert-proven tests and three consecutive green ReDeployment MNTR runs. With barrier failures now honest, latent v1.5 spec failures may surface attributed to the node that actually broke - the intended effect. Dev's node-hang kill backstop was deliberately not taken: it needs Process.Kill(entireProcessTree:), unavailable on netstandard2.0; needs a netstandard-safe follow-up.
1 parent ea6d0f6 commit 00c4f1a

8 files changed

Lines changed: 111 additions & 12 deletions

File tree

src/core/Akka.MultiNode.TestAdapter/Internal/MultiNodeTestRunner.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ void OutputHandler(object sender, DataReceivedEventArgs eventArgs)
298298
}
299299
else
300300
{
301-
_sinkCoordinator.Tell(new NodeCompletedSpecWithFail(_test.Node, _test.Role, _test.DisplayName + " passed."));
301+
_sinkCoordinator.Tell(new NodeCompletedSpecWithFail(_test.Node, _test.Role, _test.DisplayName + " failed."));
302302
}
303303
};
304304
opt.OutputDataReceived = OutputHandler;

src/core/Akka.Remote.TestKit.Tests/BarrierSpec.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,28 @@ public void A_BarrierCoordinator_must_fail_if_a_node_registers_twice()
343343
, nodeB), msg.Exception);
344344
}
345345

346+
[Fact(DisplayName = "BarrierCoordinator should fail an arrival from an unregistered client while waiting")]
347+
public async Task A_BarrierCoordinator_must_fail_an_arrival_from_an_unregistered_client_while_waiting()
348+
{
349+
var barrier = GetBarrier();
350+
var a = CreateTestProbe();
351+
var b = CreateTestProbe();
352+
var stranger = CreateTestProbe();
353+
barrier.Tell(new Controller.NodeInfo(A, Address.Parse("akka://sys"), a.Ref));
354+
barrier.Tell(new Controller.NodeInfo(B, Address.Parse("akka://sys"), b.Ref));
355+
a.Send(barrier, new EnterBarrier("bar12", null, A));
356+
357+
// A client the coordinator holds no registration for cannot be counted towards the
358+
// barrier, so it has to be told that rather than left hanging on its ask.
359+
stranger.Send(barrier, new EnterBarrier("bar12", null, C));
360+
await stranger.ExpectMsgAsync(new ToClient<BarrierResult>(new BarrierResult("bar12", false)));
361+
362+
// The stray arrival leaves the barrier itself alone.
363+
b.Send(barrier, new EnterBarrier("bar12", null, B));
364+
await a.ExpectMsgAsync(new ToClient<BarrierResult>(new BarrierResult("bar12", true)));
365+
await b.ExpectMsgAsync(new ToClient<BarrierResult>(new BarrierResult("bar12", true)));
366+
}
367+
346368
//TODO: Controller tests.
347369

348370
private IActorRef GetBarrier()

src/core/Akka.Remote.TestKit.Tests/ControllerSpec.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@
77

88
using System;
99
using System.Collections.Generic;
10+
using System.Linq;
1011
using System.Net;
12+
using System.Threading.Tasks;
1113
using Akka.Actor;
1214
using Akka.TestKit;
15+
using Akka.Util;
1316
using Xunit;
1417

1518
namespace Akka.Remote.TestKit.Tests
@@ -48,6 +51,40 @@ public void Controller_must_publish_its_nodes()
4851
ExpectMsg<Terminated>();
4952
}, TimeSpan.FromSeconds(20));
5053
}
54+
55+
[Fact(DisplayName = "Controller should keep a re-registered node when its previous connection reports a disconnect")]
56+
public async Task Controller_must_keep_a_re_registered_node_when_the_previous_connection_disconnects()
57+
{
58+
var address = Address.Parse("akka://sys");
59+
var c = Sys.ActorOf(Props.Create(() => new Controller(1, new IPEndPoint(IPAddress.Loopback, 0))));
60+
var oldConnection = CreateTestProbe();
61+
var newConnection = CreateTestProbe();
62+
63+
oldConnection.Send(c, new Controller.NodeInfo(A, address, oldConnection.Ref));
64+
await oldConnection.ExpectMsgAsync<ToClient<Done>>();
65+
66+
// Tear the node down the way TestConductor.Shutdown does, then let it come back on a
67+
// fresh connection under the same role, the way StartNewSystem does.
68+
c.Tell(new Terminate(A, new Left<bool, int>(true)));
69+
await oldConnection.ExpectMsgAsync<ToClient<TerminateMsg>>();
70+
71+
newConnection.Send(c, new Controller.NodeInfo(A, address, newConnection.Ref));
72+
await newConnection.ExpectMsgAsync<ToClient<Done>>();
73+
74+
// The connection that has already been replaced now reports its disconnect. It must
75+
// not evict the registration that replaced it.
76+
oldConnection.Send(c, new Controller.ClientDisconnected(A));
77+
78+
c.Tell(Controller.GetNodes.Instance);
79+
var nodes = await ExpectMsgAsync<IEnumerable<RoleName>>();
80+
Assert.Contains(A, nodes.ToList());
81+
82+
// The barrier coordinator has to still know the node as well, otherwise its arrivals
83+
// are ignored and every later barrier stalls.
84+
newConnection.Send(c, new EnterBarrier("after-restart", null, A));
85+
var result = await newConnection.ExpectMsgAsync<ToClient<BarrierResult>>();
86+
Assert.True(result.Msg.Success);
87+
}
5188
}
5289
}
5390

src/core/Akka.Remote.TestKit/BarrierCoordinator.cs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -556,9 +556,17 @@ private void InitFSM()
556556
case EnterBarrier barrier:
557557
if (barrier.Name != currentBarrier)
558558
throw new WrongBarrierException(barrier.Name, Sender, barrier.Role, @event.StateData);
559-
var together = clients.Any(x => Equals(x.FSM, Sender))
560-
? @event.StateData.Arrived.Add(Sender)
561-
: @event.StateData.Arrived;
559+
560+
// An arrival from a client we hold no registration for can never be
561+
// counted towards this barrier, so failing it is the only honest answer.
562+
// Staying silent leaves that client blocked on an ask nothing will ever
563+
// complete: it reports a 60 second ask timeout long after the barrier
564+
// itself timed out on another node, which buries the real cause. Idle
565+
// already answers an unregistered sender this way.
566+
if (!clients.Any(x => Equals(x.FSM, Sender)))
567+
return Stay().Replying(new ToClient<BarrierResult>(new BarrierResult(barrier.Name, false)));
568+
569+
var together = @event.StateData.Arrived.Add(Sender);
562570
var enterDeadline = GetDeadline(barrier.Timeout);
563571
//we only allow the deadlines to get shorter
564572
if (enterDeadline.TimeLeft < @event.StateData.Deadline.TimeLeft)

src/core/Akka.Remote.TestKit/Conductor.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -530,7 +530,10 @@ protected void InitFSM()
530530

531531
OnTermination(_ =>
532532
{
533-
_controller.Tell(new Controller.ClientDisconnected(_roleName));
533+
// Name the sender explicitly. The controller compares it against the FSM it has
534+
// registered for this role, which is how it tells a disconnect that belongs to
535+
// this connection apart from one that a newer connection already replaced.
536+
_controller.Tell(new Controller.ClientDisconnected(_roleName), Self);
534537
_channel.CloseAsync();
535538
});
536539

src/core/Akka.Remote.TestKit/Controller.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,19 @@ protected override void OnReceive(object message)
307307
var clientDisconnected = message as ClientDisconnected;
308308
if (clientDisconnected != null && clientDisconnected.Name != null)
309309
{
310+
// A ServerFSM can report its disconnect after the same role has already come back
311+
// on a fresh channel, which is exactly what a node that calls StartNewSystem does.
312+
// Removing the role by name alone would drop the live registration, and from then
313+
// on the barrier coordinator ignores every arrival from that node, so the next
314+
// barrier stalls until it times out. Only let a ServerFSM evict the registration
315+
// it owns.
316+
if (_nodes.TryGetValue(clientDisconnected.Name, out var registered)
317+
&& !Equals(registered.FSM, Sender))
318+
{
319+
_log.Debug("Ignoring disconnect of superseded connection for {0}", clientDisconnected.Name);
320+
return;
321+
}
322+
310323
_nodes = _nodes.Remove(clientDisconnected.Name);
311324
_barrier.Forward(clientDisconnected);
312325
return;

src/core/Akka.Remote.TestKit/Player.cs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,9 @@ public async Task EnterAsync(TimeSpan timeout, RoleName roleName, ImmutableList<
177177
{
178178
var askTimeout = barrierTimeout + Settings.QueryTimeout;
179179
// Use async ask with cancellation token
180-
var result = await _client.Ask(new ToServer<EnterBarrier>(new EnterBarrier(name, barrierTimeout, roleName)), askTimeout, cancellationToken);
180+
// A failed barrier now faults this ask (see the Status.Failure note in ClientFSM), so the
181+
// exception propagates to the caller instead of the spec silently continuing unsynchronized.
182+
await _client.Ask(new ToServer<EnterBarrier>(new EnterBarrier(name, barrierTimeout, roleName)), askTimeout, cancellationToken);
181183
}
182184
catch (TaskCanceledException ex)
183185
{
@@ -421,7 +423,7 @@ public void InitFSM()
421423
_log.Error("Received {0} instead of Done", @event.FsmEvent);
422424
return GoTo(State.Failed);
423425
case IServerOp:
424-
return Stay().Replying(new Failure(new IllegalStateException("not connected yet")));
426+
return Stay().Replying(new Status.Failure(new IllegalStateException("not connected yet")));
425427
case StateTimeout:
426428
_log.Error("connect timeout to TestConductor");
427429
return GoTo(State.Failed);
@@ -475,17 +477,22 @@ public void InitFSM()
475477
else
476478
{
477479
object response;
480+
// NOTE: these MUST be Status.Failure. The unqualified name `Failure` binds to the
481+
// inherited FSMBase.Failure (an FSM termination reason), which the ask completion
482+
// switch in FutureActorRef does not treat as a fault - it falls through to
483+
// `case T t: TrySetResult(t)`, so the ask completes SUCCESSFULLY and the caller
484+
// walks through a barrier that never synchronized.
478485
if (barrierResult.Name != @event.StateData.RunningOp.Value.Item1)
479486
{
480487
response =
481-
new Failure(
488+
new Status.Failure(
482489
new Exception("wrong barrier " + barrierResult + " received while waiting for " +
483490
@event.StateData.RunningOp.Value.Item1));
484491
}
485492
else if (!barrierResult.Success)
486493
{
487494
response =
488-
new Failure(
495+
new Status.Failure(
489496
new Exception("barrier failed:" + @event.StateData.RunningOp.Value.Item1));
490497
}
491498
else

src/core/Akka.Remote.TestKit/RemoteConnection.cs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using System.Linq;
1111
using System.Net;
1212
using System.Net.Sockets;
13+
using System.Threading;
1314
using System.Threading.Tasks;
1415
using Akka.Remote.TestKit.Proto;
1516
using Akka.Remote.Transport.DotNetty;
@@ -126,9 +127,17 @@ public static void Shutdown(IChannel connection)
126127

127128
public static async Task ReleaseAll()
128129
{
129-
Task tc = _clientPool?.ShutdownGracefullyAsync() ?? TaskEx.Completed;
130-
Task ts = _serverPool?.ShutdownGracefullyAsync() ?? TaskEx.Completed;
131-
await Task.WhenAll(tc, ts).ConfigureAwait(false);
130+
// Detach the pools before shutting them down. A shut down event loop group cannot
131+
// serve another connection, so leaving the fields set hands every later
132+
// CreateConnection call a dead pool and the connection never completes.
133+
var clientPool = Interlocked.Exchange(ref _clientPool, null);
134+
var serverPool = Interlocked.Exchange(ref _serverPool, null);
135+
var serverWorkerPool = Interlocked.Exchange(ref _serverWorkerPool, null);
136+
137+
Task tc = clientPool?.ShutdownGracefullyAsync() ?? TaskEx.Completed;
138+
Task ts = serverPool?.ShutdownGracefullyAsync() ?? TaskEx.Completed;
139+
Task tsw = serverWorkerPool?.ShutdownGracefullyAsync() ?? TaskEx.Completed;
140+
await Task.WhenAll(tc, ts, tsw).ConfigureAwait(false);
132141
}
133142

134143
#endregion

0 commit comments

Comments
 (0)