using Akka.Actor; using Akka.Cluster; using Akka.Cluster.Tools.PublishSubscribe; using Akka.Event; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy; using ZB.MOM.WW.OtOpcUa.Commons.Types; using CommonsRedundancyRole = ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy.RedundancyRole; namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy; /// /// Admin-role cluster singleton that aggregates per-node cluster events into a /// snapshot and publishes it on the redundancy-state /// DistributedPubSub topic. Subscribers (notably the OPC UA host's ServiceLevel calc) react to /// topology changes without polling. /// /// Recomputation is debounced by — a burst of cluster events from /// a rolling restart should produce one published snapshot, not one per event. /// public sealed class RedundancyStateActor : ReceiveActor, IWithTimers { public const string Topic = "redundancy-state"; public static readonly TimeSpan DebounceWindow = TimeSpan.FromMilliseconds(250); /// /// Default interval for the periodic heartbeat re-publish. DistributedPubSub does not replay /// to late subscribers, so a node that subscribes just after a change-driven publish would /// otherwise never learn its role until the next membership change. The heartbeat re-publishes /// the current snapshot so any missed subscriber converges within this interval. /// public static readonly TimeSpan DefaultHeartbeatInterval = TimeSpan.FromSeconds(10); private readonly ILoggingAdapter _log = Context.GetLogger(); private readonly Akka.Cluster.Cluster _cluster; private readonly Action? _broadcastOverride; private readonly TimeSpan _heartbeatInterval; private bool _dirty; /// Gets the timer scheduler for this actor. public ITimerScheduler Timers { get; set; } = null!; /// Creates a Props for creating a RedundancyStateActor. /// Optional broadcast override for testing. /// /// Optional periodic heartbeat re-publish interval; defaults to /// . Injectable so tests can use a short interval. /// /// Props for actor creation. public static Props Props(Action? broadcast = null, TimeSpan? heartbeatInterval = null) => Akka.Actor.Props.Create(() => new RedundancyStateActor(broadcast, heartbeatInterval)); /// Initializes a new instance of RedundancyStateActor with no broadcast override. public RedundancyStateActor() : this(null, null) { } /// Initializes a new instance of RedundancyStateActor. /// Optional broadcast override for testing. /// /// Optional periodic heartbeat re-publish interval; defaults to /// . /// public RedundancyStateActor(Action? broadcast, TimeSpan? heartbeatInterval = null) { _cluster = Akka.Cluster.Cluster.Get(Context.System); _broadcastOverride = broadcast; _heartbeatInterval = heartbeatInterval ?? DefaultHeartbeatInterval; Receive(_ => MarkDirty()); Receive(_ => MarkDirty()); Receive(_ => MarkDirty()); Receive(_ => MarkDirty()); Receive(_ => MarkDirty()); Receive(_ => PublishIfDirty()); Receive(_ => { _dirty = true; PublishIfDirty(); }); } /// protected override void PreStart() { _cluster.Subscribe( Self, ClusterEvent.InitialStateAsEvents, typeof(ClusterEvent.IMemberEvent), typeof(ClusterEvent.LeaderChanged), typeof(ClusterEvent.RoleLeaderChanged), typeof(ClusterEvent.ReachabilityEvent)); // DistributedPubSub does not replay to late subscribers, so re-publish the current // snapshot on a recurring heartbeat — any node that missed the change-driven publish // converges within _heartbeatInterval. Idempotent: consumers re-cache the same role. Timers.StartPeriodicTimer("heartbeat", Heartbeat.Instance, _heartbeatInterval); } /// protected override void PostStop() => _cluster.Unsubscribe(Self); private void MarkDirty() { _dirty = true; Timers.StartSingleTimer("debounce", RecomputeNow.Instance, DebounceWindow); } private void PublishIfDirty() { if (!_dirty) return; _dirty = false; var snapshot = BuildSnapshot(); var msg = new RedundancyStateChanged(snapshot, CorrelationId.NewId()); if (_broadcastOverride is not null) _broadcastOverride(msg); else DistributedPubSub.Get(Context.System).Mediator.Tell(new Publish(Topic, msg)); _log.Debug("Published RedundancyStateChanged with {Count} nodes", snapshot.Count); } /// The cluster role that marks a node as carrying the driver runtime. public const string DriverRole = "driver"; /// /// Selects the driver Primary: the oldest Up member carrying the . /// /// /// /// This deliberately does not use ClusterState.RoleLeader(role), which it /// previously did. Role leader is the lowest-addressed Up member with the role — /// address order (host, then port), with no relationship to time. Oldest is the member /// with the lowest up-number, and it is what ClusterSingletonManager uses to place /// singletons. /// /// /// On a freshly-formed cluster the two agree, which is why the discrepancy was invisible. /// They diverge after any restart: the restarted node re-joins as the youngest but keeps /// its address, so if it holds the lower address it becomes role leader while the /// singletons — and all the work they own — stay on the other node. Electing it Primary /// would enable the Primary-gated data plane (inbound writes, alarm acks, the fleet-wide /// alerts emit, the alarm-history drain) on the node that is not hosting the work. /// /// /// Only members are eligible, matching singleton placement: /// a Leaving node is handing its singletons over and must not be named Primary. /// /// /// The current cluster members, in any order. /// The oldest Up driver member's address, or null when there is none. public static Address? SelectDriverPrimary(IEnumerable members) { ArgumentNullException.ThrowIfNull(members); return members .Where(m => m.Status == MemberStatus.Up && m.Roles.Contains(DriverRole)) .OrderBy(m => m, Member.AgeOrdering) .FirstOrDefault() ?.Address; } private IReadOnlyList BuildSnapshot() { var driverPrimary = SelectDriverPrimary(_cluster.State.Members); var clusterLeader = _cluster.State.Leader; var now = DateTime.UtcNow; var list = new List(_cluster.State.Members.Count); foreach (var member in _cluster.State.Members) { var host = member.Address.Host; if (string.IsNullOrWhiteSpace(host)) continue; var isPrimary = driverPrimary is not null && driverPrimary == member.Address; var role = member.Roles.Contains(DriverRole) ? (isPrimary ? CommonsRedundancyRole.Primary : CommonsRedundancyRole.Secondary) : CommonsRedundancyRole.Detached; list.Add(new NodeRedundancyState( ToNodeId(member.Address), role, IsClusterLeader: clusterLeader == member.Address, IsDriverPrimary: isPrimary, AsOfUtc: now)); } return list; } /// /// Builds the canonical cluster node id (host:port) for an Akka member address — the SAME /// format ClusterRoleInfo.LocalNode/ToNodeId use, so redundancy-state consumers /// (OPC UA ServiceLevel, scripted-alarm emit gate, historian gate) can match their local node. /// /// The cluster member's address. /// The canonical host:port node id. public static NodeId ToNodeId(Akka.Actor.Address address) => NodeId.Parse($"{address.Host ?? string.Empty}:{address.Port ?? 0}"); public sealed class RecomputeNow { public static readonly RecomputeNow Instance = new(); private RecomputeNow() { } } /// /// Marker message fired by the periodic heartbeat timer. Forces a re-publish of the current /// snapshot regardless of the change-driven _dirty flag, so late subscribers converge. /// public sealed class Heartbeat { /// The singleton heartbeat message instance. public static readonly Heartbeat Instance = new(); private Heartbeat() { } } }