e3dcb180c3
Review R1: - M1 (#609): DriverHostActor.StopChild releases the instance's resilience state before evicting the hub. - m2 (#597): DriverHostActor publishes ShouldServiceAsPrimary() to IDriverPrimaryRoleSource at start and on cluster membership changes (deduped), so the native-ack gate denies in a pair's boot window. - m4 (#601): ApplyCachedArtifact returns false on empty/malformed bytes; the cached revision is not adopted, Direct mode goes Stale (no RUNNING FROM CACHE), FetchAndCache lands Steady-with-no-revision and a re-dispatch of the same revision really applies. - m3 (#603): LocalDbReplicationPolicy splits into CapturesChanges (PeerAddress||ApiKey, registration) and SharesRowsWithPeer (ApiKey && (PeerAddress||SyncListenPort>0), alarm drain gate). - m5 (#618): Program.cs catch-all filters HostAbortedException (design-time tooling). - m6 (Cluster-002): ClusterRoleInfo seeds only Up/WeaklyUp members. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
127 lines
5.2 KiB
C#
127 lines
5.2 KiB
C#
using System.Collections.Immutable;
|
|
using System.Net;
|
|
using System.Net.Sockets;
|
|
using Akka.Actor;
|
|
using Akka.Cluster;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
|
|
|
|
/// <summary>
|
|
/// Cluster-002: <see cref="ClusterRoleInfo.MembersWithRole"/> must drop a member the moment it
|
|
/// is leaving / exiting / downed, not several seconds later at <c>MemberRemoved</c>. The cluster
|
|
/// events are fed straight into <c>HandleMemberEvent</c>, so no gossip timing is involved.
|
|
/// </summary>
|
|
public sealed class ClusterRoleInfoMemberEventTests
|
|
{
|
|
private const string SystemName = "clusterroleinfo-member-event-test";
|
|
|
|
private static int FreePort()
|
|
{
|
|
var listener = new TcpListener(IPAddress.Loopback, 0);
|
|
listener.Start();
|
|
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
|
listener.Stop();
|
|
return port;
|
|
}
|
|
|
|
// Member's factory is internal to Akka.Cluster; reflection is the only way to mint one without
|
|
// forming a real cluster (the whole point here is to avoid gossip timing).
|
|
private static Member NewMember(int port, MemberStatus status)
|
|
{
|
|
// Pick the 5-argument overload: (UniqueAddress, upNumber, status, roles, appVersion).
|
|
var create = typeof(Member)
|
|
.GetMethods(System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic
|
|
| System.Reflection.BindingFlags.Public)
|
|
.Single(m => m.Name == "Create" && m.GetParameters().Length == 5);
|
|
var versionType = create.GetParameters()[4].ParameterType;
|
|
var args = new object?[]
|
|
{
|
|
new UniqueAddress(new Address("akka.tcp", SystemName, "127.0.0.1", port), 1),
|
|
1, status, ImmutableHashSet.Create("driver"),
|
|
versionType.IsValueType ? Activator.CreateInstance(versionType) : null,
|
|
};
|
|
return (Member)create.Invoke(null, args)!;
|
|
}
|
|
|
|
private static async Task WithInfoAsync(Func<ClusterRoleInfo, Task> body)
|
|
{
|
|
var port = FreePort();
|
|
var options = new AkkaClusterOptions
|
|
{
|
|
SystemName = SystemName, Hostname = "127.0.0.1", PublicHostname = "127.0.0.1",
|
|
Port = port, Roles = new[] { "driver" }, SeedNodes = Array.Empty<string>(),
|
|
};
|
|
var system = ActorSystem.Create(SystemName, Akka.Configuration.ConfigurationFactory.ParseString(
|
|
"akka.actor.provider = cluster\nakka.remote.dot-netty.tcp.hostname = 127.0.0.1\n"
|
|
+ $"akka.remote.dot-netty.tcp.port = {port}"));
|
|
try
|
|
{
|
|
using var info = new ClusterRoleInfo(system, Options.Create(options),
|
|
NullLogger<ClusterRoleInfo>.Instance);
|
|
await body(info);
|
|
}
|
|
finally
|
|
{
|
|
await system.Terminate();
|
|
}
|
|
}
|
|
|
|
/// <summary>Each departing transition removes the member at once; the Up add is the positive control.</summary>
|
|
[Theory]
|
|
[InlineData("left")]
|
|
[InlineData("exited")]
|
|
[InlineData("downed")]
|
|
[InlineData("removed")]
|
|
public async Task Departing_transition_removes_the_member_immediately(string transition)
|
|
{
|
|
await WithInfoAsync(info =>
|
|
{
|
|
var up = NewMember(11111, MemberStatus.Up);
|
|
info.HandleMemberEvent(new ClusterEvent.MemberUp(up));
|
|
info.MembersWithRole("driver").Count.ShouldBe(1, "positive control: the Up member is listed");
|
|
|
|
ClusterEvent.IMemberEvent evt = transition switch
|
|
{
|
|
"left" => new ClusterEvent.MemberLeft(NewMember(11111, MemberStatus.Leaving)),
|
|
"exited" => new ClusterEvent.MemberExited(NewMember(11111, MemberStatus.Exiting)),
|
|
"downed" => new ClusterEvent.MemberDowned(NewMember(11111, MemberStatus.Down)),
|
|
_ => new ClusterEvent.MemberRemoved(NewMember(11111, MemberStatus.Removed), MemberStatus.Exiting),
|
|
};
|
|
info.HandleMemberEvent(evt);
|
|
|
|
info.MembersWithRole("driver").ShouldBeEmpty();
|
|
return Task.CompletedTask;
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Review R1 m6: the initial seed lists only Up / WeaklyUp members. A member already departing at seed
|
|
/// time never fires a later Left/Exited/Downed event, so seeding it would list it until MemberRemoved.
|
|
/// The Up and WeaklyUp members are the positive control (the seed does list members).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Initial_seed_lists_only_up_and_weakly_up_members()
|
|
{
|
|
await WithInfoAsync(info =>
|
|
{
|
|
var members = ImmutableSortedSet.Create<Member>(
|
|
NewMember(21001, MemberStatus.Up),
|
|
NewMember(21002, MemberStatus.WeaklyUp),
|
|
NewMember(21003, MemberStatus.Leaving),
|
|
NewMember(21004, MemberStatus.Exiting),
|
|
NewMember(21005, MemberStatus.Down),
|
|
NewMember(21006, MemberStatus.Joining));
|
|
|
|
info.SeedFrom(new ClusterEvent.CurrentClusterState().Copy(members: members));
|
|
|
|
info.MembersWithRole("driver").Select(n => n.Value).OrderBy(v => v).ShouldBe(
|
|
["127.0.0.1:21001", "127.0.0.1:21002"]);
|
|
return Task.CompletedTask;
|
|
});
|
|
}
|
|
}
|