feat(mesh): MeshCommand envelope + generation-suffixed ClusterClient factory

Phase 2 Task 2. Two small pieces the comm actors are built on.

MeshCommand carries a command plus the DPS topic it would have been published
on, so the admin singletons stop knowing which transport is in force. Without
it the dark switch would have to be threaded through five publish sites across
two actors, each free to drift. Central-side only -- it never crosses the wire,
so it carries no serialization contract.

DefaultMeshClusterClientFactory appends a generation counter to the actor name.
Context.Stop is asynchronous and the name stays reserved until termination
completes, so recreating the same name inside one message handler throws
InvalidActorNameException -- which is exactly what a contact-set change does
(stop the old client, create the replacement, one handler). Ported from the
sister project, where it was a shipped bug fix rather than foresight.

Sabotage-verified: freezing the name to a constant reddens both name tests with
InvalidActorNameException.

Dropped a third test I had written -- it asserted ClusterClientSettings
directly and never touched the factory, so it was coverage theatre. Replaced
with a comment saying where contact propagation IS covered (the Task 8 boundary
test, the only place it can actually fail).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-22 10:45:13 -04:00
parent 7b71a6a35d
commit 0d6669d5ff
3 changed files with 142 additions and 0 deletions
@@ -0,0 +1,28 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
/// <summary>
/// A central→node command, plus the DistributedPubSub topic it would have been published on.
/// </summary>
/// <param name="Topic">
/// The legacy DPS topic. Carried so the router can publish under
/// <c>MeshTransport:Mode = Dps</c> without a second dispatch table; ignored entirely under
/// <c>ClusterClient</c> mode, where the destination is an actor path rather than a topic.
/// </param>
/// <param name="Message">
/// The command itself, never wrapped. Node-side handlers see exactly the type they see today,
/// which is what lets the dark switch touch publishers only.
/// </param>
/// <remarks>
/// <para>
/// This envelope exists so the admin singletons stop knowing which transport is in force.
/// Before per-cluster mesh Phase 2 each publisher held its own
/// <c>DistributedPubSub.Get(Context.System).Mediator</c> call, so the switch would have had
/// to be threaded through five call sites across two actors, and each would have been free
/// to drift.
/// </para>
/// <para>
/// Central-side only — it never crosses the wire, so it carries no serialization contract
/// and needs no additive-evolution discipline.
/// </para>
/// </remarks>
public sealed record MeshCommand(string Topic, object Message);