feat(redundancy): OpcUaPublishActor computes ServiceLevel via calculator (DB+stale+leader; legacy seam)

This commit is contained in:
Joseph Doherty
2026-06-15 12:51:32 -04:00
parent ff0f62db38
commit 3e609a2b19
2 changed files with 298 additions and 26 deletions
@@ -1,7 +1,9 @@
using Akka.Actor; using Akka.Actor;
using Akka.Cluster;
using Akka.Cluster.Tools.PublishSubscribe; using Akka.Cluster.Tools.PublishSubscribe;
using Akka.Event; using Akka.Event;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Cluster.Redundancy;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
using ZB.MOM.WW.OtOpcUa.Commons.Observability; using ZB.MOM.WW.OtOpcUa.Commons.Observability;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
@@ -9,6 +11,7 @@ using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Health;
namespace ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; namespace ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
@@ -54,10 +57,15 @@ public sealed class OpcUaPublishActor : ReceiveActor
private readonly NodeId? _localNode; private readonly NodeId? _localNode;
private readonly IDbContextFactory<OtOpcUaConfigDbContext>? _dbFactory; private readonly IDbContextFactory<OtOpcUaConfigDbContext>? _dbFactory;
private readonly Phase7Applier? _applier; private readonly Phase7Applier? _applier;
private readonly IActorRef? _dbHealthProbe;
private readonly TimeSpan _staleWindow;
private readonly Akka.Cluster.Cluster _cluster = Akka.Cluster.Cluster.Get(Context.System);
private readonly ILoggingAdapter _log = Context.GetLogger(); private readonly ILoggingAdapter _log = Context.GetLogger();
private int _writes; private int _writes;
private byte _lastServiceLevel; private byte _lastServiceLevel;
private DbHealthProbeActor.DbHealthStatus? _lastDbHealth;
private RedundancyStateChanged? _lastSnapshot;
private Phase7CompositionResult _lastApplied = new( private Phase7CompositionResult _lastApplied = new(
Array.Empty<UnsAreaProjection>(), Array.Empty<UnsAreaProjection>(),
Array.Empty<UnsLineProjection>(), Array.Empty<UnsLineProjection>(),
@@ -74,25 +82,35 @@ public sealed class OpcUaPublishActor : ReceiveActor
/// <c>redundancy-state</c> DPS topic so cluster transitions drive the local ServiceLevel /// <c>redundancy-state</c> DPS topic so cluster transitions drive the local ServiceLevel
/// publish path. When <paramref name="dbFactory"/> + <paramref name="applier"/> are supplied, /// publish path. When <paramref name="dbFactory"/> + <paramref name="applier"/> are supplied,
/// <see cref="RebuildAddressSpace"/> reads the latest deployment artifact + drives the /// <see cref="RebuildAddressSpace"/> reads the latest deployment artifact + drives the
/// applier through the sink.</summary> /// applier through the sink. When <paramref name="dbHealthProbe"/> is supplied the local
/// ServiceLevel is computed via <see cref="ServiceLevelCalculator"/> from real DB-health +
/// staleness + role-leader inputs; otherwise the legacy role-only switch is used.</summary>
/// <param name="sink">The OPC UA address space sink.</param> /// <param name="sink">The OPC UA address space sink.</param>
/// <param name="serviceLevel">The service level publisher.</param> /// <param name="serviceLevel">The service level publisher.</param>
/// <param name="localNode">The local cluster node ID.</param> /// <param name="localNode">The local cluster node ID.</param>
/// <param name="dbFactory">The optional database context factory.</param> /// <param name="dbFactory">The optional database context factory.</param>
/// <param name="applier">The optional Phase 7 applier.</param> /// <param name="applier">The optional Phase 7 applier.</param>
/// <param name="dbHealthProbe">The optional <see cref="DbHealthProbeActor"/> ref; when null the
/// legacy role-only ServiceLevel seam is used until a <see cref="DbHealthProbeActor.DbHealthStatus"/> arrives.</param>
/// <param name="staleWindow">The window beyond which a DB-health sample or redundancy snapshot is
/// considered stale; defaults to 30 seconds.</param>
public static Props Props( public static Props Props(
IOpcUaAddressSpaceSink? sink = null, IOpcUaAddressSpaceSink? sink = null,
IServiceLevelPublisher? serviceLevel = null, IServiceLevelPublisher? serviceLevel = null,
NodeId? localNode = null, NodeId? localNode = null,
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory = null, IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory = null,
Phase7Applier? applier = null) => Phase7Applier? applier = null,
IActorRef? dbHealthProbe = null,
TimeSpan? staleWindow = null) =>
Akka.Actor.Props.Create(() => new OpcUaPublishActor( Akka.Actor.Props.Create(() => new OpcUaPublishActor(
sink ?? NullOpcUaAddressSpaceSink.Instance, sink ?? NullOpcUaAddressSpaceSink.Instance,
serviceLevel ?? NullServiceLevelPublisher.Instance, serviceLevel ?? NullServiceLevelPublisher.Instance,
subscribeRedundancyTopic: true, subscribeRedundancyTopic: true,
localNode, localNode,
dbFactory, dbFactory,
applier)).WithDispatcher(DispatcherId); applier,
dbHealthProbe,
staleWindow)).WithDispatcher(DispatcherId);
/// <summary>Test-only Props that omits the pinned-dispatcher requirement and skips the /// <summary>Test-only Props that omits the pinned-dispatcher requirement and skips the
/// DPS subscribe so unit tests can spin up the actor on a vanilla TestKit cluster.</summary> /// DPS subscribe so unit tests can spin up the actor on a vanilla TestKit cluster.</summary>
@@ -102,20 +120,28 @@ public sealed class OpcUaPublishActor : ReceiveActor
/// <param name="localNode">The local cluster node ID.</param> /// <param name="localNode">The local cluster node ID.</param>
/// <param name="dbFactory">The optional database context factory.</param> /// <param name="dbFactory">The optional database context factory.</param>
/// <param name="applier">The optional Phase 7 applier.</param> /// <param name="applier">The optional Phase 7 applier.</param>
/// <param name="dbHealthProbe">The optional <see cref="DbHealthProbeActor"/> ref; when null the
/// legacy role-only ServiceLevel seam is used until a <see cref="DbHealthProbeActor.DbHealthStatus"/> arrives.</param>
/// <param name="staleWindow">The window beyond which a DB-health sample or redundancy snapshot is
/// considered stale; defaults to 30 seconds.</param>
public static Props PropsForTests( public static Props PropsForTests(
IOpcUaAddressSpaceSink? sink = null, IOpcUaAddressSpaceSink? sink = null,
IServiceLevelPublisher? serviceLevel = null, IServiceLevelPublisher? serviceLevel = null,
bool subscribeRedundancyTopic = false, bool subscribeRedundancyTopic = false,
NodeId? localNode = null, NodeId? localNode = null,
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory = null, IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory = null,
Phase7Applier? applier = null) => Phase7Applier? applier = null,
IActorRef? dbHealthProbe = null,
TimeSpan? staleWindow = null) =>
Akka.Actor.Props.Create(() => new OpcUaPublishActor( Akka.Actor.Props.Create(() => new OpcUaPublishActor(
sink ?? NullOpcUaAddressSpaceSink.Instance, sink ?? NullOpcUaAddressSpaceSink.Instance,
serviceLevel ?? NullServiceLevelPublisher.Instance, serviceLevel ?? NullServiceLevelPublisher.Instance,
subscribeRedundancyTopic, subscribeRedundancyTopic,
localNode, localNode,
dbFactory, dbFactory,
applier)); applier,
dbHealthProbe,
staleWindow));
/// <summary>Initializes a new instance of the <see cref="OpcUaPublishActor"/> class.</summary> /// <summary>Initializes a new instance of the <see cref="OpcUaPublishActor"/> class.</summary>
/// <param name="sink">The OPC UA address space sink.</param> /// <param name="sink">The OPC UA address space sink.</param>
@@ -124,13 +150,19 @@ public sealed class OpcUaPublishActor : ReceiveActor
/// <param name="localNode">The local cluster node ID.</param> /// <param name="localNode">The local cluster node ID.</param>
/// <param name="dbFactory">The optional database context factory.</param> /// <param name="dbFactory">The optional database context factory.</param>
/// <param name="applier">The optional Phase 7 applier.</param> /// <param name="applier">The optional Phase 7 applier.</param>
/// <param name="dbHealthProbe">The optional <see cref="DbHealthProbeActor"/> ref; when null the
/// legacy role-only ServiceLevel seam is used until a <see cref="DbHealthProbeActor.DbHealthStatus"/> arrives.</param>
/// <param name="staleWindow">The window beyond which a DB-health sample or redundancy snapshot is
/// considered stale; defaults to 30 seconds.</param>
public OpcUaPublishActor( public OpcUaPublishActor(
IOpcUaAddressSpaceSink sink, IOpcUaAddressSpaceSink sink,
IServiceLevelPublisher serviceLevel, IServiceLevelPublisher serviceLevel,
bool subscribeRedundancyTopic, bool subscribeRedundancyTopic,
NodeId? localNode, NodeId? localNode,
IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory = null, IDbContextFactory<OtOpcUaConfigDbContext>? dbFactory = null,
Phase7Applier? applier = null) Phase7Applier? applier = null,
IActorRef? dbHealthProbe = null,
TimeSpan? staleWindow = null)
{ {
_sink = sink; _sink = sink;
_serviceLevel = serviceLevel; _serviceLevel = serviceLevel;
@@ -138,12 +170,15 @@ public sealed class OpcUaPublishActor : ReceiveActor
_localNode = localNode; _localNode = localNode;
_dbFactory = dbFactory; _dbFactory = dbFactory;
_applier = applier; _applier = applier;
_dbHealthProbe = dbHealthProbe;
_staleWindow = staleWindow ?? TimeSpan.FromSeconds(30);
Receive<AttributeValueUpdate>(HandleAttributeUpdate); Receive<AttributeValueUpdate>(HandleAttributeUpdate);
Receive<AlarmStateUpdate>(HandleAlarmUpdate); Receive<AlarmStateUpdate>(HandleAlarmUpdate);
Receive<RebuildAddressSpace>(HandleRebuild); Receive<RebuildAddressSpace>(HandleRebuild);
Receive<ServiceLevelChanged>(HandleServiceLevelChanged); Receive<ServiceLevelChanged>(HandleServiceLevelChanged);
Receive<RedundancyStateChanged>(HandleRedundancyStateChanged); Receive<RedundancyStateChanged>(HandleRedundancyStateChanged);
Receive<DbHealthProbeActor.DbHealthStatus>(HandleDbHealthStatus);
Receive<SubscribeAck>(_ => { /* PubSub ack */ }); Receive<SubscribeAck>(_ => { /* PubSub ack */ });
} }
@@ -319,30 +354,91 @@ public sealed class OpcUaPublishActor : ReceiveActor
} }
} }
/// <summary> /// <summary>Caches the latest redundancy snapshot and recomputes the local ServiceLevel.
/// Compute a coarse ServiceLevel from the cluster snapshot and forward to the /// The actual byte is produced by <see cref="RecomputeServiceLevel"/> — either via the
/// <see cref="IServiceLevelPublisher"/>. This is a placeholder for F10b's full health /// health-aware <see cref="ServiceLevelCalculator"/> (once a DB-health probe + sample are
/// aggregation — for now we surface "primary-leader → 240, secondary → 100, detached → 0" /// wired) or via the legacy role-only seam (back-compat / bootstrap).</summary>
/// so the local SDK at least reflects role state. The full <see cref="ServiceLevelCalculator"/>
/// path (with DB-reachable, OPC UA probe inputs) lives in <c>RedundancyStateActor</c> on
/// admin nodes; this driver-side mirror exists so each node's own SDK exposes a sensible
/// ServiceLevel without round-tripping back through the admin singleton.
/// </summary>
private void HandleRedundancyStateChanged(RedundancyStateChanged msg) private void HandleRedundancyStateChanged(RedundancyStateChanged msg)
{ {
if (_localNode is null) return; _lastSnapshot = msg;
RecomputeServiceLevel();
}
var local = msg.Nodes.FirstOrDefault(n => n.NodeId == _localNode.Value); /// <summary>Caches the latest DB-health sample and recomputes the local ServiceLevel. The
if (local is null) return; /// probe pushes these (or the actor Asks for them); either way the freshest sample feeds the
/// calculator's <c>DbReachable</c>/<c>Stale</c> inputs.</summary>
private void HandleDbHealthStatus(DbHealthProbeActor.DbHealthStatus msg)
{
_lastDbHealth = msg;
RecomputeServiceLevel();
}
byte level = local.Role switch /// <summary>
/// Computes the local OPC UA ServiceLevel and routes it through <see cref="ServiceLevelChanged"/>
/// (the dedup/publish/metric handler). The full <see cref="ServiceLevelCalculator"/> path is
/// used once a DB-health probe is wired AND a sample has arrived; until then (and when no probe
/// is supplied at all) a legacy role-only seam keeps the historical "primary-leader → 240,
/// secondary → 100, detached → 0" behaviour. The calculator does not model Detached, so a
/// detached local node is guarded to 0 before either path runs.
/// </summary>
private void RecomputeServiceLevel()
{
if (_localNode is null || _lastSnapshot is null) return;
var entry = _lastSnapshot.Nodes.FirstOrDefault(n => n.NodeId == _localNode.Value);
// The calculator does NOT model Detached — a healthy detached node would wrongly compute
// 240, so guard it (and the missing-entry case) to 0 here.
if (entry is null || entry.Role == RedundancyRole.Detached)
{ {
RedundancyRole.Primary when local.IsRoleLeaderForDriver => 240, Self.Tell(new ServiceLevelChanged(0));
RedundancyRole.Primary => 200, return;
RedundancyRole.Secondary => 100, }
RedundancyRole.Detached => 0,
_ => 0, // Legacy / back-compat seam: with no DB-health probe wired (or before the first sample
}; // arrives) fall back to the old role-only switch. This preserves historical behaviour and
Self.Tell(new ServiceLevelChanged(level)); // is the bootstrap value until the first DbHealthStatus lands.
if (_dbHealthProbe is null || _lastDbHealth is null)
{
Self.Tell(new ServiceLevelChanged(LegacyRoleOnly(entry)));
return;
}
var now = DateTime.UtcNow;
var inputs = new NodeHealthInputs(
MemberState: SafeSelfStatus(),
DbReachable: _lastDbHealth.Reachable,
OpcUaProbeOk: true, // TODO(2b): wire the real OPC UA self-probe result here.
Stale: !_lastDbHealth.Reachable
|| (now - _lastDbHealth.AsOfUtc) > _staleWindow
|| (now - entry.AsOfUtc) > _staleWindow,
IsDriverRoleLeader: entry.IsRoleLeaderForDriver);
Self.Tell(new ServiceLevelChanged(ServiceLevelCalculator.Compute(inputs)));
}
/// <summary>The legacy role-only ServiceLevel switch (primary-leader → 240, primary → 200,
/// secondary → 100, _ → 0). Preserved as the back-compat / bootstrap seam.</summary>
private static byte LegacyRoleOnly(NodeRedundancyState entry) => entry.Role switch
{
RedundancyRole.Primary when entry.IsRoleLeaderForDriver => 240,
RedundancyRole.Primary => 200,
RedundancyRole.Secondary => 100,
_ => 0,
};
/// <summary>Reads this node's cluster <see cref="MemberStatus"/>, returning
/// <see cref="MemberStatus.Removed"/> if the cluster is unavailable (so the calculator treats it
/// as untrusted → 0 rather than throwing).</summary>
private MemberStatus SafeSelfStatus()
{
try
{
return _cluster.SelfMember.Status;
}
catch
{
return MemberStatus.Removed;
}
} }
} }
@@ -5,6 +5,7 @@ using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Runtime.Health;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
@@ -157,6 +158,181 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
duration: TimeSpan.FromMilliseconds(500)); duration: TimeSpan.FromMilliseconds(500));
} }
/// <summary>Verifies that the calculator path computes 250 for a healthy primary role-leader
/// (basis 240 from DB-reachable + probe-ok + fresh, +10 driver-role-leader bonus).</summary>
[Fact]
public void Calculator_path_healthy_primary_leader_publishes_250()
{
var publisher = new RecordingPublisher();
var local = NodeId.Parse("primary-node");
var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null))));
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
serviceLevel: publisher, localNode: local, dbHealthProbe: probe));
actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null));
actor.Tell(new RedundancyStateChanged(
Nodes: new[]
{
new NodeRedundancyState(local, RedundancyRole.Primary,
IsClusterLeader: true, IsRoleLeaderForDriver: true, DateTime.UtcNow),
},
CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
duration: TimeSpan.FromMilliseconds(500));
}
/// <summary>Verifies that the calculator path computes 240 for a healthy non-leader secondary
/// (basis 240 from DB-reachable + probe-ok + fresh, no leader bonus). Documented change from the
/// legacy role-only path, which mapped Secondary → 100.</summary>
[Fact]
public void Calculator_path_healthy_secondary_publishes_240()
{
var publisher = new RecordingPublisher();
var local = NodeId.Parse("secondary-node");
var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null))));
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
serviceLevel: publisher, localNode: local, dbHealthProbe: probe));
actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null));
actor.Tell(new RedundancyStateChanged(
Nodes: new[]
{
new NodeRedundancyState(local, RedundancyRole.Secondary,
IsClusterLeader: false, IsRoleLeaderForDriver: false, DateTime.UtcNow),
},
CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)240),
duration: TimeSpan.FromMilliseconds(500));
}
/// <summary>Verifies that the calculator path computes 100 when the DB is unreachable
/// ((false,_,true) basis, stale via !DbReachable) for a non-leader secondary (no bonus).</summary>
[Fact]
public void Calculator_path_db_unreachable_publishes_100()
{
var publisher = new RecordingPublisher();
var local = NodeId.Parse("secondary-node");
var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(false, DateTime.UtcNow, "down"))));
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
serviceLevel: publisher, localNode: local, dbHealthProbe: probe));
actor.Tell(new DbHealthProbeActor.DbHealthStatus(false, DateTime.UtcNow, "down"));
actor.Tell(new RedundancyStateChanged(
Nodes: new[]
{
new NodeRedundancyState(local, RedundancyRole.Secondary,
IsClusterLeader: false, IsRoleLeaderForDriver: false, DateTime.UtcNow),
},
CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)100),
duration: TimeSpan.FromMilliseconds(500));
}
/// <summary>Verifies that the calculator path computes 200 for a stale snapshot when the DB is
/// reachable + fresh but the redundancy entry's AsOfUtc is older than the stale window
/// ((true,_,true) basis) for a non-leader secondary (no bonus).</summary>
[Fact]
public void Calculator_path_stale_snapshot_publishes_200()
{
var publisher = new RecordingPublisher();
var local = NodeId.Parse("secondary-node");
var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null))));
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
serviceLevel: publisher, localNode: local, dbHealthProbe: probe,
staleWindow: TimeSpan.FromSeconds(2)));
actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null));
actor.Tell(new RedundancyStateChanged(
Nodes: new[]
{
new NodeRedundancyState(local, RedundancyRole.Secondary,
IsClusterLeader: false, IsRoleLeaderForDriver: false,
DateTime.UtcNow - TimeSpan.FromMinutes(1)),
},
CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)200),
duration: TimeSpan.FromMilliseconds(500));
}
/// <summary>Verifies that a detached local node publishes 0 (the calculator does not model
/// Detached, so the handler guards it before the calculator path). The node first goes healthy
/// (250) so the dedup'd transition down to 0 is observable.</summary>
[Fact]
public void Calculator_path_detached_publishes_0()
{
var publisher = new RecordingPublisher();
var local = NodeId.Parse("detached-node");
var probe = Sys.ActorOf(Akka.Actor.Props.Create(() =>
new StubDbHealth(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null))));
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
serviceLevel: publisher, localNode: local, dbHealthProbe: probe));
actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null));
// First go healthy primary-leader (250) so the transition down to 0 is not dedup'd against
// the initial 0.
actor.Tell(new RedundancyStateChanged(
Nodes: new[]
{
new NodeRedundancyState(local, RedundancyRole.Primary,
IsClusterLeader: true, IsRoleLeaderForDriver: true, DateTime.UtcNow),
},
CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)250),
duration: TimeSpan.FromMilliseconds(500));
// Now detach — expect the guard to drive ServiceLevel down to 0.
actor.Tell(new RedundancyStateChanged(
Nodes: new[]
{
new NodeRedundancyState(local, RedundancyRole.Detached,
IsClusterLeader: false, IsRoleLeaderForDriver: false, DateTime.UtcNow),
},
CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)0),
duration: TimeSpan.FromMilliseconds(500));
}
/// <summary>Verifies the legacy back-compat seam: with no DB-health probe wired, the handler
/// falls back to the old role-only switch (Primary + leader → 240).</summary>
[Fact]
public void Legacy_path_no_db_probe_keeps_role_only()
{
var publisher = new RecordingPublisher();
var local = NodeId.Parse("primary-node");
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(
serviceLevel: publisher, localNode: local));
actor.Tell(new RedundancyStateChanged(
Nodes: new[]
{
new NodeRedundancyState(local, RedundancyRole.Primary,
IsClusterLeader: true, IsRoleLeaderForDriver: true, DateTime.UtcNow),
},
CorrelationId.NewId()));
AwaitAssert(() => publisher.Levels.ShouldContain((byte)240),
duration: TimeSpan.FromMilliseconds(500));
}
/// <summary>Stub DB-health probe actor that answers <see cref="DbHealthProbeActor.GetStatus"/>
/// with a fixed status, so the calculator path is deterministic without a real timer.</summary>
private sealed class StubDbHealth : Akka.Actor.ReceiveActor
{
/// <summary>Initializes a new instance of the <see cref="StubDbHealth"/> class.</summary>
/// <param name="status">The fixed DB-health status to reply with.</param>
public StubDbHealth(DbHealthProbeActor.DbHealthStatus status) =>
Receive<DbHealthProbeActor.GetStatus>(_ => Sender.Tell(status));
}
/// <summary>Test implementation of IOpcUaAddressSpaceSink that records calls.</summary> /// <summary>Test implementation of IOpcUaAddressSpaceSink that records calls.</summary>
private sealed class RecordingSink : IOpcUaAddressSpaceSink private sealed class RecordingSink : IOpcUaAddressSpaceSink
{ {