feat(mesh): CentralCommunicationActor — dark-switched fan-out, DB-sourced contacts

Phase 2 Task 3. Central-side end of the boundary: routes every central->node
command out over DPS or ClusterClient per MeshTransport:Mode, and forwards
inbound ApplyAcks to the deploy coordinator singleton (Forward, not Tell, so
the coordinator sees the node rather than this relay).

Three things here are load-bearing and easy to get wrong later:

SendToAll, never Send. Today's DPS publish reaches EVERY DriverHostActor and
the node side has no ClusterId or node filter to compensate -- scoping happens
later, inside the artifact. ClusterClient.Send delivers to exactly ONE
registered actor, so it would deploy to a single node while every other node
silently kept its old config, and the deployment could still seal green.
Sabotage-verified: swapping to Send reddens exactly that one test.

Exactly ONE ClusterClient, fleet-wide. A receptionist serves its whole cluster,
so SendToAll reaches every registered node-comm actor in the mesh regardless of
which contact point was dialled. One client per application Cluster -- the
shape Phase 6 wants -- would fan each command out once per cluster while the
fleet is still one mesh: N x duplicate DispatchDeployment and ApplyAck. Marked
TODO(Phase 6).

The contact set does NOT scope delivery. Excluding a maintenance-mode node from
the contacts does not stop it receiving commands; it still receives them, as it
does under today's broadcast. The filter is about which receptionists are worth
dialling, not about who gets the message. Stated in the code because the
opposite is the natural assumption.

Also carries the sister project's shipped fixes: per-row address parsing so one
malformed ClusterNode cannot abort the refresh and leave the contact set
half-built (regression test orders the bad row FIRST), the cache entry cleared
before recreate so a failed create cannot leave commands routing into a
stopping actor, and a Status.Failure handler so a DB outage is a Warning rather
than a silent debug-level unhandled message.

Two test-harness bugs found and fixed while writing this, both mine: SubscribeAck
goes to the SENDER (so the mediator subscribe needed an explicit sender), and
EventFilter matches case-INSENSITIVELY, so a "was NOT" filter also caught this
actor's own "was not created" warning.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-22 12:10:05 -04:00
parent e762ae00f2
commit d5b5cb6ede
3 changed files with 612 additions and 0 deletions
@@ -0,0 +1,33 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
/// <summary>
/// Actor paths the two mesh comm actors register under. <b>These strings are the wire
/// contract</b> of the central↔node boundary.
/// </summary>
/// <remarks>
/// <para>
/// Declared once, here, rather than as literals on each side. The <c>deployments</c> /
/// <c>deployment-acks</c> topic names are declared twice — once in
/// <c>ConfigPublishCoordinator</c> and again in <c>DriverHostActor</c> — and agree only by
/// convention; renaming one would compile, deploy, and deliver nothing. A ClusterClient path
/// has the same failure mode and no compiler to catch it, so the constant is shared.
/// </para>
/// <para>
/// Nothing else pins these values, which is why <c>MeshCommActorPathTests</c> boots a real
/// host and <c>Identify</c>-probes both paths.
/// </para>
/// </remarks>
public static class MeshPaths
{
/// <summary>Central-side comm actor — where a driver node sends its deployment acks.</summary>
public const string CentralCommunication = "/user/central-communication";
/// <summary>Node-side comm actor — where central sends commands.</summary>
public const string NodeCommunication = "/user/node-communication";
/// <summary>Actor name of <see cref="CentralCommunication"/> (the path's final segment).</summary>
public const string CentralCommunicationName = "central-communication";
/// <summary>Actor name of <see cref="NodeCommunication"/> (the path's final segment).</summary>
public const string NodeCommunicationName = "node-communication";
}