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() { }
}
}