using Akka.Actor; using Akka.Cluster.Tools.PublishSubscribe; using Akka.Event; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy; using ZB.MOM.WW.OtOpcUa.Commons.Types; namespace ZB.MOM.WW.OtOpcUa.Runtime.Health; /// /// Per-node supervisor that keeps exactly one child per OTHER, /// non- driver node named in the latest /// snapshot. Because every node runs this supervisor, each /// node is continuously TCP-probed by all of its peers; a node learns its own reachability by /// consuming the resulting messages in /// OpcUaPublishActor. /// /// The supervisor reacts to the published snapshot (a plain /// record on the DistributedPubSub topic) /// rather than to raw Akka Member events, so the desired peer set is derived from one /// authoritative message instead of being reconstructed from membership churn. /// public sealed class PeerProbeSupervisor : ReceiveActor { private readonly NodeId _localNode; private readonly Func _probeFactory; private readonly Dictionary _children = new(); private readonly ILoggingAdapter _log = Context.GetLogger(); /// /// Monotonic generation counter appended to each spawned child's name. Guarantees a freshly /// re-added peer never collides on a child name with a same-peer child that is still /// asynchronously terminating after a Stop. /// private long _generation; /// Creates production props that probe peers on the given OPC UA port. /// This node's identifier; excluded from the probed peer set. /// The OPC UA port each peer probe connects to. /// Props configured to create a . public static Props Props(NodeId localNode, int opcUaPort = PeerOpcUaProbeActor.DefaultOpcUaPort) { // Build the factory delegate OUTSIDE the Props.Create expression tree: a named argument // (opcUaPort:) is not permitted inside an expression tree, so capture it in a local first. Func probeFactory = peer => PeerOpcUaProbeActor.Props(peer, opcUaPort: opcUaPort); return Akka.Actor.Props.Create(() => new PeerProbeSupervisor(localNode, probeFactory)); } /// Creates props with an injected probe factory for tests (e.g. no-op children). /// This node's identifier; excluded from the probed peer set. /// Factory producing the child for a given peer. /// Props configured to create a . public static Props PropsForTests(NodeId localNode, Func probeFactory) => Akka.Actor.Props.Create(() => new PeerProbeSupervisor(localNode, probeFactory)); /// Initializes a new instance of the class. /// This node's identifier; excluded from the probed peer set. /// Factory producing the child for a given peer. public PeerProbeSupervisor(NodeId localNode, Func probeFactory) { _localNode = localNode; _probeFactory = probeFactory; Receive(OnSnapshot); Receive(OnTerminated); Receive(_ => { }); // The redundancy-state topic also carries OpcUaProbeResult (published by our own // PeerOpcUaProbeActor children + peers). The supervisor doesn't consume them — only // OpcUaPublishActor does — so explicitly drop them to avoid unhandled-message noise. Receive(_ => { }); } /// Gets the current number of live peer-probe children. public int ChildCount => _children.Count; /// protected override void PreStart() => DistributedPubSub.Get(Context.System).Mediator.Tell( new Subscribe(PeerOpcUaProbeActor.RedundancyStateTopic, Self)); /// /// Reconciles the live child set against the desired peer set in the snapshot: stops children /// for peers that are gone (or became Detached / self), and spawns a probe for each newly /// desired peer. /// /// The latest redundancy-state snapshot. private void OnSnapshot(RedundancyStateChanged s) { var want = s.Nodes .Where(n => n.NodeId != _localNode && n.Role != RedundancyRole.Detached) .Select(n => n.NodeId) .ToHashSet(); foreach (var gone in _children.Keys.Where(k => !want.Contains(k)).ToList()) { Context.Stop(_children[gone]); _children.Remove(gone); } foreach (var peer in want.Where(p => !_children.ContainsKey(p))) { var name = "probe-" + Sanitize(peer.Value) + "-" + _generation++; var child = Context.ActorOf(_probeFactory(peer), name); Context.Watch(child); _children[peer] = child; } } /// /// Prunes a child that has terminated for any reason (e.g. crash) so /// stays accurate and the next snapshot will respawn it if still desired. /// /// The termination notice for the dead child. private void OnTerminated(Terminated t) { var entry = _children.FirstOrDefault(kvp => kvp.Value.Equals(t.ActorRef)); // No-match yields Value == null because IActorRef is a reference type. A null here means the // terminated ref isn't our CURRENT child for any peer — either it was already pruned in // OnSnapshot (deliberate stop pre-removes from _children), or it's a stale old-generation // child whose peer has since been re-spawned with a fresh child. Either way: no-op, so we // never evict the fresh same-peer child on a stale Terminated. if (entry.Value is null) return; _children.Remove(entry.Key); _log.Debug("PeerProbeSupervisor: pruned terminated child for peer {Peer}", entry.Key); } /// /// Sanitizes a value into a legal Akka child-name fragment — Akka actor /// names may not contain : or ., which appear in host:port node ids. /// /// The raw node-id value. /// The value with every non-alphanumeric character replaced by -. private static string Sanitize(string s) => new string(s.Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray()); }