fix(cluster): gossiped-role predicates compare case-insensitively — a miscased peer no longer un-elects the Primary

This commit is contained in:
Joseph Doherty
2026-08-20 10:04:16 -04:00
parent bcec88ef70
commit e46c1db5e5
12 changed files with 370 additions and 11 deletions
@@ -6,6 +6,7 @@
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
@rendermode RenderMode.InteractiveServer
@using Microsoft.EntityFrameworkCore
@using ZB.MOM.WW.OtOpcUa.Cluster
@using ZB.MOM.WW.OtOpcUa.Commons.Interfaces
@using ZB.MOM.WW.OtOpcUa.Configuration
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
@@ -146,7 +147,7 @@ else
// Union with current Akka driver members so a freshly-joined node that has no
// NodeDeploymentState row yet still appears as "waiting".
var akkaDrivers = Cluster.MembersWithRole("driver")
var akkaDrivers = Cluster.MembersWithRole(RoleParser.Driver)
.Select(n => n.Value).ToHashSet(StringComparer.OrdinalIgnoreCase);
var allNodes = byNode.Keys.Union(akkaDrivers, StringComparer.OrdinalIgnoreCase)
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase).ToList();
@@ -156,7 +157,7 @@ else
byNode.TryGetValue(nodeId, out var state);
return new NodeRow(
NodeId: nodeId,
Roles: akkaDrivers.Contains(nodeId) ? new[] { "driver" } : Array.Empty<string>(),
Roles: akkaDrivers.Contains(nodeId) ? new[] { RoleParser.Driver } : Array.Empty<string>(),
Status: state?.Status,
StartedAtUtc: state?.StartedAtUtc,
AppliedAtUtc: state?.AppliedAtUtc,
@@ -181,10 +181,12 @@ public sealed class ClusterNodeAddressReconcilerActor : ReceiveActor, IWithTimer
public static IReadOnlyList<(string Host, int Port)> FilterOwnClusterDriverMembers(
IEnumerable<Member> members, string? ownClusterRole)
{
// R3-04 (01/C-9): GossipedRolesHave, not ordinal Contains — a peer gossiping a miscased
// 'Driver' (rolling-upgrade window) must not read as a phantom RunningNodeHasNoRow finding.
var driverMembers = members
.Where(m => m.Status == MemberStatus.Up && m.Roles.Contains(DriverRole));
.Where(m => m.Status == MemberStatus.Up && RoleParser.GossipedRolesHave(m.Roles, DriverRole));
if (!string.IsNullOrEmpty(ownClusterRole))
driverMembers = driverMembers.Where(m => m.Roles.Contains(ownClusterRole));
driverMembers = driverMembers.Where(m => RoleParser.GossipedRolesHave(m.Roles, ownClusterRole));
return driverMembers
.Select(m => (Host: m.Address.Host ?? string.Empty, Port: m.Address.Port ?? 0))
.Where(a => !string.IsNullOrWhiteSpace(a.Host) && a.Port > 0)
@@ -3,6 +3,7 @@ using Akka.Actor;
using Akka.Cluster;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.OtOpcUa.Cluster;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy;
@@ -122,7 +123,9 @@ public sealed class ManualFailoverService : IManualFailoverService
}
private static List<Member> OrderedDrivers(IEnumerable<Member> members) => members
.Where(m => m.Status == MemberStatus.Up && m.Roles.Contains(RedundancyStateActor.DriverRole))
// R3-04 (01/C-9): GossipedRolesHave, not ordinal Contains — a peer gossiping a miscased
// 'Driver' (rolling-upgrade window) must still be a manual-failover candidate.
.Where(m => m.Status == MemberStatus.Up && RoleParser.GossipedRolesHave(m.Roles, RedundancyStateActor.DriverRole))
.OrderBy(m => m, Member.AgeOrdering)
.ToList();
}
@@ -190,7 +190,15 @@ public sealed class RedundancyStateActor : ReceiveActor, IWithTimers
if (string.IsNullOrWhiteSpace(host)) continue;
var isPrimary = driverPrimary is not null && driverPrimary == member.Address;
var role = member.Roles.Contains(DriverRole)
// R3-04 (01/C-9): GossipedRolesHave, not ordinal Contains — a peer running an older build
// can still gossip a miscased 'Driver' during a rolling upgrade, and this list is what
// /hosts and /fleet render, so a miscased-but-genuinely-driver member must not read as
// Detached. NOTE: this does NOT reach SelectDriverPrimary/driverPrimary above — that
// delegates to ClusterActiveNode.OldestUpMember (ZB.MOM.WW.Health.Akka), which matches via
// Akka's own ordinal Member.HasRole and is not this repo's to change (see R3-04 execution
// notes). So a member gossiping only 'Driver' now classifies Secondary rather than
// Detached, but a PAIR that BOTH gossip 'Driver' still elects no Primary.
var role = RoleParser.GossipedRolesHave(member.Roles, DriverRole)
? (isPrimary ? CommonsRedundancyRole.Primary : CommonsRedundancyRole.Secondary)
: CommonsRedundancyRole.Detached;
@@ -5,6 +5,7 @@ using Akka.Event;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
@@ -1608,8 +1609,12 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
if (_driverMemberCountProvider is not null) return _driverMemberCountProvider();
try
{
// R3-04 (01/C-9): GossipedRolesHave, not ordinal Contains — a peer gossiping a miscased
// 'Driver' (rolling-upgrade window) must still count toward the R2-04 primary-gate member
// count, or a genuine 2-node pair silently takes the single-node allow branch.
return Akka.Cluster.Cluster.Get(Context.System).State.Members
.Count(m => m.Status == Akka.Cluster.MemberStatus.Up && m.Roles.Contains(ServiceCollectionExtensions.DriverRole));
.Count(m => m.Status == Akka.Cluster.MemberStatus.Up
&& RoleParser.GossipedRolesHave(m.Roles, ServiceCollectionExtensions.DriverRole));
}
catch (Exception)
{
@@ -1,6 +1,7 @@
using Akka.Actor;
using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Alerts;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
@@ -516,8 +517,11 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
if (_driverMemberCountProvider is not null) return _driverMemberCountProvider();
try
{
// R3-04 (01/C-9): GossipedRolesHave, not ordinal Contains — a peer gossiping a miscased
// 'Driver' (rolling-upgrade window) must still count toward the alerts-emit gate.
return Akka.Cluster.Cluster.Get(Context.System).State.Members
.Count(m => m.Status == Akka.Cluster.MemberStatus.Up && m.Roles.Contains(ServiceCollectionExtensions.DriverRole));
.Count(m => m.Status == Akka.Cluster.MemberStatus.Up
&& RoleParser.GossipedRolesHave(m.Roles, ServiceCollectionExtensions.DriverRole));
}
catch (Exception)
{