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); } private IReadOnlyList BuildSnapshot() { var driverLeader = _cluster.State.RoleLeader("driver"); 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 role = member.Roles.Contains("driver") ? (driverLeader == member.Address ? CommonsRedundancyRole.Primary : CommonsRedundancyRole.Secondary) : CommonsRedundancyRole.Detached; list.Add(new NodeRedundancyState( ToNodeId(member.Address), role, IsClusterLeader: clusterLeader == member.Address, IsRoleLeaderForDriver: driverLeader == member.Address, 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() { } } }