feat(mesh-phase5): tap the 4 telemetry publish seams into the local hub (DPS intact)

Each of the four telemetry publish seams now also emits its DPS payload into the
node-local ITelemetryLocalHub (Phase 5), leaving the existing DistributedPubSub
publish untouched:

- driver-health   AkkaDriverHealthPublisher            -> TelemetryItem.Health
- resilience      DriverResilienceStatusPublisherService -> TelemetryItem.Resilience
- alerts          ScriptedAlarmHostActor + DriverHostActor (native) -> TelemetryItem.Alarm
- script-logs     VirtualTagActor + DpsScriptLogPublisher -> TelemetryItem.Script

DI services take the hub as a required ctor param; actors take an optional
nullable hub threaded through their Props and the real spawn sites
(DriverHostActor <- ServiceCollectionExtensions; VirtualTagActor/ScriptedAlarmHostActor
<- DriverHostActor), so existing test Props constructions keep compiling and simply
do not emit. The hub is a no-op until a gRPC client subscribes, so the emit is safe
on every node.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-23 15:27:29 -04:00
parent 9882b1d250
commit 8e5090edf3
12 changed files with 429 additions and 25 deletions
@@ -11,6 +11,7 @@ using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Telemetry;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.Runtime.ScriptedAlarms;
@@ -116,6 +117,10 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
/// alerts-emit gate reads while the role is unknown. Null ⇒ read the live cluster state.</summary>
private readonly Func<int>? _driverMemberCountProvider;
/// <summary>Optional node-local live-telemetry hub (Phase 5): each cluster-wide <c>alerts</c> transition
/// is also emitted here for the gRPC streaming service. Null (tests / no hub wired) skips the emit.</summary>
private readonly ITelemetryLocalHub? _telemetryHub;
/// <summary>Monotonic load generation, bumped on every <see cref="OnApply"/>. The continuation that
/// pipes back an <see cref="AlarmsLoaded"/> captures the generation it was started under; a stale
/// completion (an earlier generation arriving after a newer apply) is discarded in
@@ -138,6 +143,10 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
/// <param name="localNode">The local cluster node id, used to read this node's <see cref="RedundancyRole"/>
/// from the <c>redundancy-state</c> topic so only the Primary publishes the cluster-wide <c>alerts</c>
/// transition. Null (the default) leaves the role unknown ⇒ default-emit (single-node deploys + tests).</param>
/// <param name="driverMemberCountProvider">Test seam (archreview 03/S4): overrides the count of Up
/// <c>driver</c>-role cluster members the alerts-emit gate reads while the role is unknown.</param>
/// <param name="telemetryHub">Optional node-local live-telemetry hub (Phase 5) each cluster-wide
/// <c>alerts</c> transition is also emitted into; null (tests) skips the emit.</param>
/// <returns>The <see cref="Akka.Actor.Props"/> used to instantiate the actor.</returns>
public static Props Props(
IActorRef publishActor,
@@ -145,8 +154,9 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
DependencyMuxTagUpstreamSource upstream,
ScriptedAlarmEngine engine,
NodeId? localNode = null,
Func<int>? driverMemberCountProvider = null) =>
Akka.Actor.Props.Create(() => new ScriptedAlarmHostActor(publishActor, mux, upstream, engine, localNode, driverMemberCountProvider));
Func<int>? driverMemberCountProvider = null,
ITelemetryLocalHub? telemetryHub = null) =>
Akka.Actor.Props.Create(() => new ScriptedAlarmHostActor(publishActor, mux, upstream, engine, localNode, driverMemberCountProvider, telemetryHub));
/// <summary>Initializes a new instance of the <see cref="ScriptedAlarmHostActor"/> class.</summary>
/// <param name="publishActor">The OPC UA publish actor emissions are bridged to.</param>
@@ -159,13 +169,16 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
/// <param name="driverMemberCountProvider">Test seam (archreview 03/S4): overrides the count of Up
/// <c>driver</c>-role cluster members the alerts-emit gate reads while the role is unknown. Null reads
/// the live cluster state (0 on a non-cluster ActorRefProvider ⇒ single-node default-emit).</param>
/// <param name="telemetryHub">Optional node-local live-telemetry hub (Phase 5) each cluster-wide
/// <c>alerts</c> transition is also emitted into; null (tests) skips the emit.</param>
public ScriptedAlarmHostActor(
IActorRef publishActor,
IActorRef? mux,
DependencyMuxTagUpstreamSource upstream,
ScriptedAlarmEngine engine,
NodeId? localNode = null,
Func<int>? driverMemberCountProvider = null)
Func<int>? driverMemberCountProvider = null,
ITelemetryLocalHub? telemetryHub = null)
{
ArgumentNullException.ThrowIfNull(publishActor);
ArgumentNullException.ThrowIfNull(upstream);
@@ -176,6 +189,7 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
_engine = engine;
_localNode = localNode;
_driverMemberCountProvider = driverMemberCountProvider;
_telemetryHub = telemetryHub;
// OnEvent fires on the engine's worker thread. NEVER touch Context / actor state here —
// marshal onto the actor thread via the thread-safe Self.Tell. Keep the handler in a field
@@ -377,6 +391,10 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor
}
_mediator.Tell(new Publish(AlertsTopic, evt));
// Phase 5: fan the same transition into the node-local live-telemetry hub (no-op until a gRPC
// client subscribes). The DPS publish above is unchanged — a strictly additive tap, and it rides
// the same Primary gate as the publish (only the Primary reaches this line).
_telemetryHub?.Emit(new TelemetryItem.Alarm(evt));
}
/// <summary>Count of Up cluster members carrying the <c>driver</c> role, for the alerts-emit gate. Uses