3fb6549209
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
273 lines
12 KiB
C#
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);
|
|
}
|
|
}
|