915beec11a
Phase 2 Task 4. Node-side end of the boundary, registered with the receptionist per node rather than as a singleton so central's contact rotation reaches whichever node answers. Inbound commands are re-emitted on the node's EventStream, NOT back onto their DistributedPubSub topic. DPS is mesh-wide and central SendToAll-s to every node's comm actor, so a DPS republish would deliver N copies of every command to every subscriber. The EventStream is node-local by construction. 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. Each inbound type is listed explicitly rather than caught by a ReceiveAny, so an unknown type dead-letters loudly instead of being republished blind onto a stream where every subscriber ignores it. Outbound is ApplyAck only, and it uses Send rather than SendToAll -- the deploy coordinator is one singleton behind central's proxy, so SendToAll would deliver one ack per central node and the coordinator would count this node twice. Notably there is NO Ask across this boundary in Phase 2: every migrated command is fire-and-forget, which is why this actor is far smaller than the sister project's equivalent and needs no sender preservation at all. Sabotage-verified twice: deleting the AlarmCommand handler and flipping Send to SendToAll each redden exactly their own test. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
95 lines
4.5 KiB
C#
95 lines
4.5 KiB
C#
using Akka.Actor;
|
|
using Akka.Cluster.Tools.Client;
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Node-side end of the central↔node command boundary (per-cluster mesh Phase 2), registered
|
|
/// with the <c>ClusterClientReceptionist</c> at <see cref="MeshPaths.NodeCommunication"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Registered per node, NOT as a cluster singleton</b> — central's ClusterClient rotates
|
|
/// across contact points and must find a live comm actor at whichever node answers.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Inbound commands are re-emitted on the node's <see cref="EventStream"/>, not on their
|
|
/// DistributedPubSub topic.</b> DPS is mesh-wide, and central <c>SendToAll</c>s 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: <c>ScriptedAlarmHostActor</c> is a <i>child of</i>
|
|
/// <c>DriverHostActor</c> and has no registry key, so there is no ref to hand in at wiring
|
|
/// time.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Outbound is <see cref="ApplyAck"/> only.</b> 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class NodeCommunicationActor : ReceiveActor
|
|
{
|
|
private readonly IActorRef? _centralClient;
|
|
private readonly ILoggingAdapter _log = Context.GetLogger();
|
|
|
|
/// <summary>Creates the props for this actor.</summary>
|
|
/// <param name="centralClient">
|
|
/// ClusterClient dialling central's receptionist, or <see langword="null"/> under
|
|
/// <c>MeshTransport:Mode = Dps</c>, where acks still go out over DistributedPubSub from
|
|
/// <c>DriverHostActor</c> and this actor never sees them.
|
|
/// </param>
|
|
/// <returns>The props.</returns>
|
|
public static Props Props(IActorRef? centralClient) =>
|
|
Akka.Actor.Props.Create(() => new NodeCommunicationActor(centralClient));
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="NodeCommunicationActor"/> class.</summary>
|
|
/// <param name="centralClient">ClusterClient dialling central, or <see langword="null"/>.</param>
|
|
public NodeCommunicationActor(IActorRef? centralClient)
|
|
{
|
|
_centralClient = centralClient;
|
|
|
|
// 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<DispatchDeployment>(PublishLocally);
|
|
Receive<RestartDriver>(PublishLocally);
|
|
Receive<ReconnectDriver>(PublishLocally);
|
|
Receive<AlarmCommand>(PublishLocally);
|
|
|
|
// Outbound to central.
|
|
Receive<ApplyAck>(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 (_centralClient is null)
|
|
{
|
|
_log.Warning(
|
|
"No central ClusterClient — 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:CentralContactPoints",
|
|
ack.DeploymentId);
|
|
return;
|
|
}
|
|
|
|
// Send, not SendToAll: the deploy coordinator is a single singleton, and the central comm
|
|
// actor forwards to its proxy. SendToAll would deliver one ack per central node, and the
|
|
// coordinator would count the same node twice.
|
|
_centralClient.Tell(new ClusterClient.Send(MeshPaths.CentralCommunication, ack));
|
|
}
|
|
}
|