using Akka.Actor;
using Akka.Event;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Communication;
///
/// Node-side end of the central↔node command boundary (per-cluster mesh Phase 2). This node's
/// NodeCommandService gRPC ingress hands it every decoded command, resolving it from
/// ActorRegistry by NodeCommunicationActorKey — the actor's name
/// () is a local convention, not an address anything
/// off-process uses.
///
///
///
/// Registered per node, NOT as a cluster singleton — central dials each node's own
/// command endpoint, so every node needs its own live comm actor.
///
///
/// Inbound commands are re-emitted on the node's , not on their
/// DistributedPubSub topic. DPS is mesh-wide, and central delivers one copy to every
/// node's comm actor — so a DPS re-publish would deliver N copies of every command to every
/// subscriber, where N is the node count. The EventStream is node-local by construction,
/// which is exactly the needed semantics. It also avoids a registration handshake with
/// actors spawned later: ScriptedAlarmHostActor is a child of
/// DriverHostActor and has no registry key, so there is no ref to hand in at wiring
/// time.
///
///
/// Outbound is only. There is no Ask across this boundary in
/// Phase 2 — every migrated command is fire-and-forget, and the deploy ack returns as an
/// independent message the coordinator already subscribes for. Nothing here needs sender
/// preservation, which is why this actor is far smaller than the sister project's.
///
///
public sealed class NodeCommunicationActor : ReceiveActor
{
private readonly IApplyAckSender? _ackSender;
private readonly ILoggingAdapter _log = Context.GetLogger();
/// Creates the props for this actor.
///
/// The gRPC ack transport under MeshTransport:Mode = Grpc, or
/// under Dps, where acks still go out over DistributedPubSub from
/// DriverHostActor and this actor never sees them.
///
/// The props.
public static Props Props(IApplyAckSender? ackSender = null) =>
Akka.Actor.Props.Create(() => new NodeCommunicationActor(ackSender));
/// Initializes a new instance of the class.
/// The gRPC ack transport, or .
public NodeCommunicationActor(IApplyAckSender? ackSender = null)
{
_ackSender = ackSender;
// Inbound from central. Each type is listed explicitly rather than caught by a ReceiveAny:
// an unknown type must dead-letter loudly during development rather than be republished
// blind onto the node's EventStream, where it would be silently ignored by every subscriber.
Receive(PublishLocally);
Receive(PublishLocally);
Receive(PublishLocally);
Receive(PublishLocally);
// Outbound to central.
Receive(HandleApplyAck);
}
private void PublishLocally(object msg)
{
_log.Debug("Mesh: received {MessageType} from central; publishing node-locally", msg.GetType().Name);
Context.System.EventStream.Publish(msg);
}
private void HandleApplyAck(ApplyAck ack)
{
if (_ackSender is not null)
{
// Fire-and-forget by construction. The sender never throws and never retries (see
// IApplyAckSender), so there is nothing to await and nothing to pipe back: awaiting would
// stall this mailbox behind a network call to central, and an Ask would invent a
// request/response the one-way contract does not have.
try
{
_ = _ackSender.SendAsync(ack);
}
catch (Exception ex)
{
// "Never throws" is a contract on a public seam, which makes it documentation until
// something enforces it. An implementation that threw synchronously — a guard clause
// ahead of the async state machine is the easy way to do it by accident — would fault
// this Receive, restart the actor, and take the node's whole inbound command path down
// with it. One dropped ack is a deployment that times out; a restarting comm actor is
// every command to this node going missing.
_log.Error(
ex,
"The ApplyAck sender threw synchronously for {DeploymentId}; dropping the ack. "
+ "IApplyAckSender must never throw — this is a defect in the registered sender.",
ack.DeploymentId);
}
return;
}
_log.Warning(
"No central ack transport — dropping ApplyAck for {DeploymentId}. The ack is not "
+ "buffered and will not be retried, so the deployment will time out at central "
+ "naming this node even though it applied successfully. Check "
+ "MeshTransport:CentralGrpcEndpoints, and that MeshTransport:Mode is Grpc on a node "
+ "whose central lives in another mesh",
ack.DeploymentId);
}
}