Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Communication/NodeCommunicationActor.cs
T
Joseph Doherty cd7135570c refactor(mesh): collapse MeshPaths to local actor names — no longer a wire contract (review follow-up)
MeshPaths.CentralCommunication / .NodeCommunication (the "/user/..." full-path constants) had
ZERO production consumers after the ClusterClient deletion: both gRPC services resolve their
comm actor through ActorRegistry by typed key, never by ActorSelection over a path. Only the
path-probe test still read them, while the class doc went on calling them "the wire contract
of the central↔node boundary" — a claim that stopped being true the moment nothing addressed
an actor across a process boundary by name.

  - The two full-path constants are deleted; the two Name constants remain.
  - MeshPaths' doc now says what it is (the local actor-naming convention the registry-keyed
    services are spawned under) and records what it stopped being, so the next reader does not
    re-derive a contract from the file's history.
  - MeshCommActorPathTests composes "/user/" + Name itself — the absolute path is that test's
    own addressing detail now, not something the product declares. Renamed
    Both_mesh_comm_actors_exist_at_their_contract_paths ->
    ..._are_spawned_under_their_declared_names, because what it still pins is that the two
    registration sites actually ran on a real booted node; a rename is now a compile error, so
    "contract" was the wrong word for what would break.
  - NodeCommunicationActor's summary no longer cross-references the deleted constant or calls
    the name an address. CentralCommunicationActor was checked and makes no such claim.
2026-08-06 16:14:25 -04:00

117 lines
5.8 KiB
C#

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;
/// <summary>
/// Node-side end of the central↔node command boundary (per-cluster mesh Phase 2). This node's
/// <c>NodeCommandService</c> gRPC ingress hands it every decoded command, resolving it from
/// <c>ActorRegistry</c> by <c>NodeCommunicationActorKey</c> — the actor's name
/// (<see cref="MeshPaths.NodeCommunicationName"/>) is a local convention, not an address anything
/// off-process uses.
/// </summary>
/// <remarks>
/// <para>
/// <b>Registered per node, NOT as a cluster singleton</b> — central dials each node's own
/// command endpoint, so every node needs its own live comm actor.
/// </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 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: <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 IApplyAckSender? _ackSender;
private readonly ILoggingAdapter _log = Context.GetLogger();
/// <summary>Creates the props for this actor.</summary>
/// <param name="ackSender">
/// The gRPC ack transport under <c>MeshTransport:Mode = Grpc</c>, or <see langword="null"/>
/// under <c>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(IApplyAckSender? ackSender = null) =>
Akka.Actor.Props.Create(() => new NodeCommunicationActor(ackSender));
/// <summary>Initializes a new instance of the <see cref="NodeCommunicationActor"/> class.</summary>
/// <param name="ackSender">The gRPC ack transport, or <see langword="null"/>.</param>
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<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 (_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);
}
}