1ce2042e14
Task 3 review: scoping only the rows/enabled DB queries by ClusterId left the observed set (from Cluster.State.Members) filtered by the driver role alone. In the mixed-mesh window — DB scoping live via a cluster role, but the physical Akka mesh not yet split so central still sees foreign members via gossip — a foreign-cluster driver member stays in observed while its row is filtered out, hitting the RunningNodeHasNoRow branch, which logs at ERROR. That traded the Warning-level false positive for a worse Error-level one. Filter observed by the node's own cluster ROLE too. Extracted the projection into a static FilterOwnClusterDriverMembers(members, ownClusterRole) so the membership filter is unit-testable without seeding a live cluster (the actor harness joins as admin only, so observed is always empty). Sourced at registration from IClusterRoleInfo.ClusterRole alongside ClusterId. Null role (legacy, no cluster role) keeps every driver member — reconcile-all, unchanged. Also clarified the ownClusterId doc wording (non-null-and-non-empty). Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
218 lines
8.9 KiB
C#
218 lines
8.9 KiB
C#
using System.Collections.Immutable;
|
|
using System.Reflection;
|
|
using Akka.Actor;
|
|
using Akka.Cluster;
|
|
using Akka.Util;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.Fleet;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
|
|
|
|
/// <summary>
|
|
/// Per-cluster mesh Phase 1 Task 4: <c>ClusterNode.AkkaPort</c> and the node's own
|
|
/// <c>Cluster:Port</c> are the same fact in two places, and nothing makes them agree. These pin
|
|
/// the shapes that drift takes.
|
|
/// </summary>
|
|
public sealed class ClusterNodeAddressReconcilerTests
|
|
{
|
|
private static ClusterNodeAddress Row(string host, int akkaPort, string? nodeId = null) =>
|
|
new(nodeId ?? $"{host}:{akkaPort}", host, akkaPort);
|
|
|
|
/// <summary>A fleet whose rows match membership reports nothing.</summary>
|
|
[Fact]
|
|
public void Matching_rows_and_membership_produce_no_findings()
|
|
{
|
|
var findings = ClusterNodeAddressReconciler.Reconcile(
|
|
[("site-a-1", 4053), ("site-a-2", 4053)],
|
|
[Row("site-a-1", 4053), Row("site-a-2", 4053)],
|
|
["site-a-1:4053", "site-a-2:4053"]);
|
|
|
|
findings.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The drift the plan names: a node binds 4054 while its row says 4053. Membership shows it
|
|
/// at an address no row claims, and the row it should have matched looks absent — both halves
|
|
/// are reported, because either alone reads as a different problem.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Node_bound_to_the_wrong_port_is_reported_from_both_sides()
|
|
{
|
|
var findings = ClusterNodeAddressReconciler.Reconcile(
|
|
[("site-a-1", 4054)],
|
|
[Row("site-a-1", 4053)],
|
|
["site-a-1:4053"]);
|
|
|
|
findings.Count.ShouldBe(2);
|
|
findings.ShouldContain(f =>
|
|
f.Kind == AddressMismatchKind.RunningNodeHasNoRow && f.NodeId == "site-a-1:4054");
|
|
findings.ShouldContain(f =>
|
|
f.Kind == AddressMismatchKind.EnabledRowNotInCluster && f.NodeId == "site-a-1:4053");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The Phase 2 shape: the row's NodeId still matches membership, but its <c>Host</c> /
|
|
/// <c>AkkaPort</c> columns — the values central will actually dial — have been edited away
|
|
/// from it. Gossip still works today, so nothing else would notice.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Row_whose_dial_target_disagrees_with_its_own_NodeId_is_reported()
|
|
{
|
|
var findings = ClusterNodeAddressReconciler.Reconcile(
|
|
[("site-a-1", 4053)],
|
|
[Row("site-a-1", 4054, nodeId: "site-a-1:4053")],
|
|
["site-a-1:4053"]);
|
|
|
|
var only = findings.ShouldHaveSingleItem();
|
|
only.Kind.ShouldBe(AddressMismatchKind.RowDialTargetDisagrees);
|
|
only.NodeId.ShouldBe("site-a-1:4053");
|
|
only.Detail.ShouldContain("site-a-1:4054");
|
|
}
|
|
|
|
/// <summary>A running driver node nobody registered — the coordinator discards its acks.</summary>
|
|
[Fact]
|
|
public void Running_node_with_no_row_is_reported()
|
|
{
|
|
var findings = ClusterNodeAddressReconciler.Reconcile(
|
|
[("ghost-9", 4053)], [], []);
|
|
|
|
findings.ShouldHaveSingleItem().Kind.ShouldBe(AddressMismatchKind.RunningNodeHasNoRow);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A disabled row for an absent node is silence, not a finding — being absent is the entire
|
|
/// point of disabling it. Without this the maintenance hatch would trade a failed deploy for
|
|
/// a permanent warning.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Disabled_row_for_an_absent_node_is_not_reported()
|
|
{
|
|
var findings = ClusterNodeAddressReconciler.Reconcile(
|
|
[("site-a-1", 4053)],
|
|
[Row("site-a-1", 4053), Row("site-a-2", 4053)],
|
|
["site-a-1:4053"]);
|
|
|
|
findings.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>
|
|
/// An admin-only member has no <c>ClusterNode</c> row by design, so the caller filters
|
|
/// membership to driver-role members. Passing an unfiltered list would report correct
|
|
/// configuration as broken — pinned here because the filter lives at the call site.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Admin_only_members_are_the_callers_responsibility_to_exclude()
|
|
{
|
|
// What the actor passes: driver members only.
|
|
ClusterNodeAddressReconciler.Reconcile(
|
|
[("central-1", 4053)], [Row("central-1", 4053)], ["central-1:4053"])
|
|
.ShouldBeEmpty();
|
|
|
|
// What an unfiltered call would produce — the false positive this guards against.
|
|
ClusterNodeAddressReconciler.Reconcile(
|
|
[("central-1", 4053), ("admin-only-1", 4053)],
|
|
[Row("central-1", 4053)],
|
|
["central-1:4053"])
|
|
.ShouldHaveSingleItem().Kind.ShouldBe(AddressMismatchKind.RunningNodeHasNoRow);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Host matching is case-insensitive, matching the coordinator's <c>NodeIdComparer</c> — DNS
|
|
/// and SQL collation are both case-insensitive, so the same node surfaces with either casing.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Host_matching_is_case_insensitive()
|
|
{
|
|
ClusterNodeAddressReconciler.Reconcile(
|
|
[("SITE-A-1", 4053)], [Row("site-a-1", 4053)], ["site-a-1:4053"])
|
|
.ShouldBeEmpty();
|
|
}
|
|
|
|
private static int _memberUid;
|
|
|
|
// Akka's Member.Create factories are internal, so reflect the Up-status overload
|
|
// Create(UniqueAddress, int upNumber, MemberStatus, ImmutableHashSet<string> roles, AppVersion).
|
|
private static readonly MethodInfo MemberCreate = typeof(Member).GetMethod(
|
|
"Create",
|
|
BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public,
|
|
binder: null,
|
|
types:
|
|
[
|
|
typeof(UniqueAddress), typeof(int), typeof(MemberStatus),
|
|
typeof(ImmutableHashSet<string>), typeof(AppVersion),
|
|
],
|
|
modifiers: null)
|
|
?? throw new InvalidOperationException("Akka.Cluster.Member.Create(5-arg) not found — Akka API changed");
|
|
|
|
/// <summary>
|
|
/// Builds an Up cluster <see cref="Member"/> at <paramref name="host"/>:<paramref name="port"/>
|
|
/// carrying <paramref name="roles"/>, for exercising
|
|
/// <see cref="ClusterNodeAddressReconcilerActor.FilterOwnClusterDriverMembers"/> directly.
|
|
/// </summary>
|
|
private static Member UpMember(string host, int port, params string[] roles) =>
|
|
(Member)MemberCreate.Invoke(null,
|
|
[
|
|
new UniqueAddress(new Address("akka.tcp", "otopcua", host, port), Interlocked.Increment(ref _memberUid)),
|
|
1,
|
|
MemberStatus.Up,
|
|
roles.ToImmutableHashSet(),
|
|
AppVersion.Zero,
|
|
])!;
|
|
|
|
/// <summary>
|
|
/// Per-cluster mesh Phase 6: scoped to <c>cluster-MAIN</c>, a driver member carrying a FOREIGN
|
|
/// cluster role (<c>cluster-SITE-A</c>) — the mixed-mesh window, where central still sees it via
|
|
/// gossip while its row is already filtered out by ClusterId — is excluded from observed. Left
|
|
/// in, it would hit the Error-level <see cref="AddressMismatchKind.RunningNodeHasNoRow"/> branch.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Foreign_cluster_role_member_is_excluded_when_scoped()
|
|
{
|
|
var observed = ClusterNodeAddressReconcilerActor.FilterOwnClusterDriverMembers(
|
|
[UpMember("site-a-1", 4053, "driver", "cluster-SITE-A")],
|
|
ownClusterRole: "cluster-MAIN");
|
|
|
|
observed.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>Scoped to <c>cluster-MAIN</c>, a member carrying that same role is kept.</summary>
|
|
[Fact]
|
|
public void Own_cluster_role_member_is_kept_when_scoped()
|
|
{
|
|
var observed = ClusterNodeAddressReconcilerActor.FilterOwnClusterDriverMembers(
|
|
[UpMember("main-1", 4053, "driver", "cluster-MAIN")],
|
|
ownClusterRole: "cluster-MAIN");
|
|
|
|
observed.ShouldHaveSingleItem().ShouldBe(("main-1", 4053));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Legacy: a null cluster role keeps every Up driver member regardless of cluster — an admin
|
|
/// node with no cluster role genuinely sees the whole fleet via gossip (reconcile-all).
|
|
/// </summary>
|
|
[Fact]
|
|
public void Null_cluster_role_keeps_all_driver_members()
|
|
{
|
|
var observed = ClusterNodeAddressReconcilerActor.FilterOwnClusterDriverMembers(
|
|
[
|
|
UpMember("main-1", 4053, "driver", "cluster-MAIN"),
|
|
UpMember("site-a-1", 4053, "driver", "cluster-SITE-A"),
|
|
],
|
|
ownClusterRole: null);
|
|
|
|
observed.Count.ShouldBe(2);
|
|
observed.ShouldContain(("main-1", 4053));
|
|
observed.ShouldContain(("site-a-1", 4053));
|
|
}
|
|
|
|
/// <summary>Non-driver members (e.g. admin-only) are excluded whether or not a scope is set.</summary>
|
|
[Fact]
|
|
public void Non_driver_members_are_always_excluded()
|
|
{
|
|
ClusterNodeAddressReconcilerActor.FilterOwnClusterDriverMembers(
|
|
[UpMember("central-1", 4053, "admin", "cluster-MAIN")], ownClusterRole: "cluster-MAIN")
|
|
.ShouldBeEmpty();
|
|
}
|
|
}
|