From 14acab5a589fde9670f0ae716346a37edd650cbc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 04:43:59 -0400 Subject: [PATCH] feat(controlplane): ServiceLevelCalculator + ControlPlane.Tests harness --- ZB.MOM.WW.OtOpcUa.slnx | 1 + .../Redundancy/ServiceLevelCalculator.cs | 40 +++++++++++ .../ZB.MOM.WW.OtOpcUa.ControlPlane.csproj | 3 + .../Harness/ControlPlaneTestHarness.cs | 54 ++++++++++++++ .../ServiceLevelCalculatorTests.cs | 70 +++++++++++++++++++ ...B.MOM.WW.OtOpcUa.ControlPlane.Tests.csproj | 33 +++++++++ 6 files changed, 201 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Redundancy/ServiceLevelCalculator.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Harness/ControlPlaneTestHarness.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ServiceLevelCalculatorTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.csproj diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 62fd762..42b71f9 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -63,6 +63,7 @@ + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Redundancy/ServiceLevelCalculator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Redundancy/ServiceLevelCalculator.cs new file mode 100644 index 0000000..e2fff26 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Redundancy/ServiceLevelCalculator.cs @@ -0,0 +1,40 @@ +using Akka.Cluster; + +namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy; + +public readonly record struct NodeHealthInputs( + MemberStatus MemberState, + bool DbReachable, + bool OpcUaProbeOk, + bool Stale, + bool IsDriverRoleLeader); + +/// +/// Pure ServiceLevel computation per design §6. Output range 0–255, where higher = "more +/// authoritative." The OPC UA SDK exposes this as the node's ServiceLevel Variable and +/// redundant clients use it to pick which server to subscribe to. +/// +/// Tiering: +/// - Member not Up/Joining: 0 (cluster cannot trust this node). +/// - DB reachable + OPC UA probe ok + not stale: 240 (full service). +/// - Stale config (DB reachable or not, OPC UA probe state ignored): 100 or 200 depending on DB. +/// - +10 bonus when this node holds the role-leader lease for the "driver" role. +/// +public static class ServiceLevelCalculator +{ + public static byte Compute(NodeHealthInputs h) + { + if (h.MemberState is not (MemberStatus.Up or MemberStatus.Joining)) + return 0; + + var basis = (h.DbReachable, h.OpcUaProbeOk, h.Stale) switch + { + (true, true, false) => 240, + (true, _, true) => 200, + (false, _, true) => 100, + _ => 0, + }; + + return (byte)Math.Clamp(basis + (h.IsDriverRoleLeader ? 10 : 0), 0, 255); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj index 2ec6399..d30b3e2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ZB.MOM.WW.OtOpcUa.ControlPlane.csproj @@ -11,7 +11,10 @@ + + + diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Harness/ControlPlaneTestHarness.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Harness/ControlPlaneTestHarness.cs new file mode 100644 index 0000000..68b662f --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/Harness/ControlPlaneTestHarness.cs @@ -0,0 +1,54 @@ +using Akka.TestKit.Xunit2; +using Microsoft.EntityFrameworkCore; +using ZB.MOM.WW.OtOpcUa.Configuration; + +namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness; + +/// +/// Akka TestKit fixture for ControlPlane actor tests. Provides: +/// - A test ActorSystem (xunit2 TestKit) wired with PubSub/Cluster extensions. +/// - A fresh in-memory per harness instance. +/// - An the actors can hold. +/// +/// One harness per test fact — InMemory provider gives strong isolation when the database +/// name is unique (random Guid). +/// +public abstract class ControlPlaneActorTestBase : TestKit +{ + protected static string AkkaTestHocon => @" +akka { + loglevel = ""WARNING"" + actor { + provider = ""Akka.Cluster.ClusterActorRefProvider, Akka.Cluster"" + } + remote.dot-netty.tcp { + hostname = ""127.0.0.1"" + port = 0 + } + cluster { + seed-nodes = [] + roles = [""admin""] + min-nr-of-members = 1 + run-coordinated-shutdown-when-down = off + } +}"; + + protected ControlPlaneActorTestBase() : base(AkkaTestHocon) { } + + protected static IDbContextFactory NewInMemoryDbFactory(string? dbName = null) + { + dbName ??= Guid.NewGuid().ToString("N"); + return new InMemoryConfigDbFactory(dbName); + } + + private sealed class InMemoryConfigDbFactory(string dbName) : IDbContextFactory + { + public OtOpcUaConfigDbContext CreateDbContext() + { + var opts = new DbContextOptionsBuilder() + .UseInMemoryDatabase(dbName) + .Options; + return new OtOpcUaConfigDbContext(opts); + } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ServiceLevelCalculatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ServiceLevelCalculatorTests.cs new file mode 100644 index 0000000..53047cb --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ServiceLevelCalculatorTests.cs @@ -0,0 +1,70 @@ +using Akka.Cluster; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy; + +namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests; + +public sealed class ServiceLevelCalculatorTests +{ + [Theory] + [InlineData(MemberStatus.Down)] + [InlineData(MemberStatus.Removed)] + [InlineData(MemberStatus.Exiting)] + [InlineData(MemberStatus.Leaving)] + public void NotUp_returns_zero(MemberStatus status) + { + var sl = ServiceLevelCalculator.Compute(new(status, + DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverRoleLeader: true)); + sl.ShouldBe((byte)0); + } + + [Fact] + public void Fully_healthy_non_leader_returns_240() + { + var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up, + DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverRoleLeader: false)); + sl.ShouldBe((byte)240); + } + + [Fact] + public void Fully_healthy_role_leader_returns_250() + { + var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up, + DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverRoleLeader: true)); + sl.ShouldBe((byte)250); + } + + [Fact] + public void Db_reachable_but_stale_returns_200() + { + var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up, + DbReachable: true, OpcUaProbeOk: true, Stale: true, IsDriverRoleLeader: false)); + sl.ShouldBe((byte)200); + } + + [Fact] + public void Db_unreachable_and_stale_returns_100() + { + var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up, + DbReachable: false, OpcUaProbeOk: false, Stale: true, IsDriverRoleLeader: false)); + sl.ShouldBe((byte)100); + } + + [Fact] + public void Opcua_probe_fail_when_not_stale_returns_zero() + { + // (DbReachable=true, OpcUaProbeOk=false, Stale=false) falls through to the catch-all 0. + var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up, + DbReachable: true, OpcUaProbeOk: false, Stale: false, IsDriverRoleLeader: false)); + sl.ShouldBe((byte)0); + } + + [Fact] + public void Joining_member_is_treated_like_Up_for_grading() + { + var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Joining, + DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverRoleLeader: false)); + sl.ShouldBe((byte)240); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.csproj new file mode 100644 index 0000000..4d9522f --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.csproj @@ -0,0 +1,33 @@ + + + + false + true + ZB.MOM.WW.OtOpcUa.ControlPlane.Tests + true + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + +