Files
lmxopcua/src/Core/ZB.MOM.WW.OtOpcUa.Cluster/ClusterRoleInfo.cs
T

273 lines
12 KiB
C#

using Akka.Actor;
using Akka.Cluster;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using CommonsNodeId = ZB.MOM.WW.OtOpcUa.Commons.Types.NodeId;
namespace ZB.MOM.WW.OtOpcUa.Cluster;
/// <summary>
/// Thread-safe live view of cluster membership and role topology. Subscribes to
/// <see cref="ClusterEvent.IMemberEvent"/>, <see cref="ClusterEvent.RoleLeaderChanged"/>, and
/// <see cref="ClusterEvent.LeaderChanged"/> through an internal subscriber actor and keeps
/// a snapshot of role-to-members + role-to-leader. The CLR-facing event surface is
/// <see cref="IClusterRoleInfo.RoleLeaderChanged"/>.
/// </summary>
public sealed class ClusterRoleInfo : IClusterRoleInfo, IDisposable
{
private readonly Akka.Cluster.Cluster _cluster;
private readonly ILogger<ClusterRoleInfo> _logger;
private readonly CommonsNodeId _localNode;
private readonly HashSet<string> _localRoles;
private readonly string? _clusterRole;
private readonly string? _clusterId;
private readonly object _lock = new();
// R3-04 (01/C-9, census row 17) — the two dictionaries below have DIFFERENT correct comparers,
// and that split is deliberate, not an oversight (review finding, 2026-08-20).
//
// _membersByRole answers "every member carrying (a spelling of) this role" — OrdinalIgnoreCase,
// so a PEER running an older build that still gossips a miscased plain role during a rolling
// upgrade is not invisible to MembersWithRole("driver"). Merging casings IS the point here.
//
// _roleLeaders must stay Ordinal. Akka computes — and raises RoleLeaderChanged for — role
// leadership PER EXACT GOSSIPED ROLE STRING: "driver" and "Driver" are two entirely distinct
// roles to Akka's own role-leader machinery, each independently electing its own leader. An
// OrdinalIgnoreCase dictionary here would collapse both onto ONE key, so whichever
// RoleLeaderChanged event landed last would silently overwrite the other's leader — turning two
// real, independent facts Akka tracks separately into one, wrong, merged one. Pinned by
// ClusterRoleInfoRoleLeaderCaseTests.RoleLeaderChanged_for_driver_then_Driver_keeps_distinct_entries.
private readonly Dictionary<string, Akka.Actor.Address?> _roleLeaders = new(StringComparer.Ordinal);
private readonly Dictionary<string, HashSet<Member>> _membersByRole = new(StringComparer.OrdinalIgnoreCase);
private IActorRef? _subscriber;
/// <summary>Initializes a new instance of the ClusterRoleInfo class.</summary>
/// <param name="system">The Akka actor system.</param>
/// <param name="options">The cluster configuration options.</param>
/// <param name="logger">The logger instance.</param>
public ClusterRoleInfo(ActorSystem system, IOptions<AkkaClusterOptions> options, ILogger<ClusterRoleInfo> logger)
{
_cluster = Akka.Cluster.Cluster.Get(system);
_logger = logger;
// NodeId encodes host:port so cluster members on shared hosts (test loopback, dev VMs
// sharing a bind IP) stay distinct. Production hosts have unique DNS names so the port
// suffix is harmless redundancy.
_localNode = CommonsNodeId.Parse($"{options.Value.PublicHostname}:{options.Value.Port}");
// OrdinalIgnoreCase (Gitea #532): Cluster:Roles binds verbatim, so a node authored with 'Driver'
// used to answer HasRole("driver") false while gossiping a role Akka would still match against
// its own ordinal comparison — the node was a driver to the cluster and not to itself.
_localRoles = new HashSet<string>(options.Value.Roles, StringComparer.OrdinalIgnoreCase);
// Derived from the node's OWN configured roles (not live Cluster.State) so this identity is
// available at host-build time, before the cluster forms. Iterate the ORIGINAL array — not
// _localRoles — because first-wins requires configuration order, which HashSet does not
// guarantee. The OTOPCUA_ROLES fallback is already applied to the bound options by
// AddOtOpcUaCluster's PostConfigure, so nothing is re-resolved here.
var clusterRoles = RoleParser.ClusterRolesIn(options.Value.Roles);
if (clusterRoles.Count > 0)
{
_clusterRole = clusterRoles[0];
_clusterId = RoleParser.ClusterIdFromRole(_clusterRole);
if (clusterRoles.Count > 1)
{
_logger.LogWarning(
"Node configured with {Count} cluster-scoped roles ({Roles}); using the first ({ClusterRole})",
clusterRoles.Count, string.Join(", ", clusterRoles), _clusterRole);
}
}
SeedFromCurrentState();
_subscriber = system.ActorOf(Props.Create(() => new SubscriberActor(this)), "clusterroleinfo-subscriber");
}
/// <inheritdoc />
public CommonsNodeId LocalNode => _localNode;
/// <inheritdoc />
public IReadOnlySet<string> LocalRoles => _localRoles;
/// <inheritdoc />
public string? ClusterRole => _clusterRole;
/// <inheritdoc />
public string? ClusterId => _clusterId;
/// <inheritdoc />
public bool HasRole(string role) => _localRoles.Contains(role);
/// <inheritdoc />
public IReadOnlyList<CommonsNodeId> MembersWithRole(string role)
{
lock (_lock)
{
if (!_membersByRole.TryGetValue(role, out var members)) return Array.Empty<CommonsNodeId>();
return members
.Select(m => ToNodeId(m.Address))
.ToArray();
}
}
/// <inheritdoc />
public CommonsNodeId? RoleLeader(string role)
{
lock (_lock)
{
return _roleLeaders.TryGetValue(role, out var leader) && leader is not null
? ToNodeId(leader)
: (CommonsNodeId?)null;
}
}
/// <summary>Occurs when the leader for a role changes.</summary>
public event EventHandler<RoleLeaderChangedEventArgs>? RoleLeaderChanged;
private void SeedFromCurrentState()
{
var snapshot = _cluster.State;
lock (_lock)
{
foreach (var member in snapshot.Members)
foreach (var role in member.Roles)
{
if (!_membersByRole.TryGetValue(role, out var set))
_membersByRole[role] = set = new HashSet<Member>();
set.Add(member);
}
foreach (var role in snapshot.Members.SelectMany(m => m.Roles).Distinct())
{
var leaderAddr = snapshot.RoleLeader(role);
_roleLeaders[role] = leaderAddr;
}
}
}
/// <summary>Handles a cluster member event (member up/removed).</summary>
/// <param name="evt">The member event from the cluster.</param>
internal void HandleMemberEvent(ClusterEvent.IMemberEvent evt)
{
lock (_lock)
{
switch (evt)
{
case ClusterEvent.MemberUp up:
foreach (var role in up.Member.Roles)
{
if (!_membersByRole.TryGetValue(role, out var set))
_membersByRole[role] = set = new HashSet<Member>();
set.Add(up.Member);
}
break;
// Cluster-002: a leaving / exiting / downed member is no longer a usable member, so
// drop it as soon as that transition lands rather than waiting for MemberRemoved
// (several seconds later). MembersWithRole now has a production caller (the
// historization scope-peer check), which must not defer to a departing partner.
// Member equality is by UniqueAddress, so the status change does not matter here.
case ClusterEvent.MemberLeft left:
RemoveMember(left.Member);
break;
case ClusterEvent.MemberExited exited:
RemoveMember(exited.Member);
break;
case ClusterEvent.MemberDowned downed:
RemoveMember(downed.Member);
break;
case ClusterEvent.MemberRemoved removed:
RemoveMember(removed.Member);
break;
}
}
}
// Caller holds _lock.
private void RemoveMember(Member member)
{
foreach (var role in member.Roles)
if (_membersByRole.TryGetValue(role, out var set))
set.Remove(member);
}
/// <summary>Handles a role leader change event.</summary>
/// <param name="evt">The role leader changed event from the cluster.</param>
internal void HandleRoleLeaderChanged(ClusterEvent.RoleLeaderChanged evt)
{
CommonsNodeId? previous = null;
CommonsNodeId? next = null;
var raise = false;
lock (_lock)
{
_roleLeaders.TryGetValue(evt.Role, out var prevLeader);
if (prevLeader is not null)
previous = ToNodeId(prevLeader);
// Record the event's own address. The leader used to be resolved by looking the address up
// in Cluster.State.Members, but that snapshot is published asynchronously and can lag the
// event: a miss stored null and nothing re-ran, so RoleLeader(role) stayed null for good
// (Gitea #590). Only the address is ever read, so no Member lookup is needed.
_roleLeaders[evt.Role] = evt.Leader;
if (evt.Leader is not null)
next = ToNodeId(evt.Leader);
raise = !Nullable.Equals(previous, next);
}
if (!raise) return;
try
{
RoleLeaderChanged?.Invoke(this, new RoleLeaderChangedEventArgs
{
Role = evt.Role,
PreviousLeader = previous,
NewLeader = next,
});
}
catch (Exception ex)
{
_logger.LogWarning(ex, "RoleLeaderChanged subscriber threw for role {Role}", evt.Role);
}
}
private static CommonsNodeId ToNodeId(Akka.Actor.Address address) =>
CommonsNodeId.Parse($"{address.Host ?? string.Empty}:{address.Port ?? 0}");
/// <summary>Disposes the ClusterRoleInfo and stops the subscriber actor.</summary>
public void Dispose()
{
_subscriber?.Tell(PoisonPill.Instance);
_subscriber = null;
}
private sealed class SubscriberActor : ReceiveActor
{
/// <summary>Initializes a new instance of the SubscriberActor class.</summary>
/// <param name="owner">The ClusterRoleInfo instance to forward events to.</param>
public SubscriberActor(ClusterRoleInfo owner)
{
Receive<ClusterEvent.IMemberEvent>(e => owner.HandleMemberEvent(e));
Receive<ClusterEvent.RoleLeaderChanged>(e => owner.HandleRoleLeaderChanged(e));
// LeaderChanged is intentionally a no-op here: ServiceLevel lives in RedundancyStateActor
// (per-cluster mesh Phase 6: a cluster-{ClusterId}-scoped singleton spawned on every
// driver node, no longer the admin-role singleton it once was) and has no consumer on this
// ClusterRoleInfo path by design.
Receive<ClusterEvent.LeaderChanged>(_ => { });
Receive<ClusterEvent.CurrentClusterState>(_ => { /* seeded from initial snapshot */ });
}
/// <inheritdoc />
protected override void PreStart()
{
Akka.Cluster.Cluster.Get(Context.System).Subscribe(
Self,
ClusterEvent.InitialStateAsEvents,
typeof(ClusterEvent.IMemberEvent),
typeof(ClusterEvent.LeaderChanged),
typeof(ClusterEvent.RoleLeaderChanged));
}
/// <inheritdoc />
protected override void PostStop() =>
Akka.Cluster.Cluster.Get(Context.System).Unsubscribe(Self);
}
}