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