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
@@ -5,6 +5,7 @@ using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
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.Tests.Harness;
@@ -157,6 +158,181 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase
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>
private sealed class RecordingSink : IOpcUaAddressSpaceSink
{