Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ServiceLevelCalculatorTests.cs
T
Joseph Doherty acfb4fbaca
v2-ci / unit-tests (push) Has been cancelled
fix(redundancy): a failed OPC UA endpoint drops ServiceLevel to 0 and steps the Primary down (lmxopcua#762)
IOpcUaEndpointState (review-remediation lane C3) recorded whether the SDK started, but only the
health check read it: a fused node whose endpoint failed (port 4840 taken, ...) still advertised
250/240 and stayed driver Primary, so the Primary-gated data plane ran on a node no client could
reach while the reachable peer refused writes as Secondary.

- Move IOpcUaEndpointState/OpcUaEndpointState/OpcUaEndpointStatus from Host to Commons.OpcUa so
  the Runtime can consume it.
- ServiceLevelCalculator: new NodeHealthInputs.EndpointListening (default true). false -> 0,
  ahead of every other input including the +10 Primary bonus.
- OpcUaPublishActor: takes the endpoint state, feeds EndpointListening (Failed only; NotStarted /
  Stopped are boot/shutdown windows) on the DB-backed and DB-less paths, and the legacy role-only
  seam returns 0 for a failed endpoint too.
- New OpcUaEndpointStepDownActor (one per driver node): a Primary (ClusterActiveNode.SelfIsActive,
  the election's own primitive) with a Failed endpoint gracefully Leaves its mesh - the manual
  failover mechanism - so the peer becomes the oldest Up driver member, hosts the singletons and is
  Primary. The election itself is not bent. Guarded on a peer having recently reported its own
  endpoint listening (EndpointReport on the new opcua-endpoint-state DPS topic), so a pair with
  both endpoints failed does not bounce the role and restart nodes forever, and a lone node never
  shuts itself down.
- Runtime now references ZB.MOM.WW.Health.Akka (ClusterActiveNode).
- Docs: Redundancy.md tier table + new "Endpoint start failure: Primary step-down" section;
  cluster-topology rule; HealthEndpoints / OpcUaEndpointHealthCheck comments (F-C1 landed).

Tests: ServiceLevelCalculatorTests (+5), OpcUaPublishActorEndpointStateTests (7: DB-less,
DB-backed, legacy seam, NotStarted/Stopped do not demote, a later failure demotes on the next
tick), OpcUaEndpointStepDownActorTests (3, real two-node mesh: holds while the peer is not
listening then leaves and the peer becomes Primary; failed Secondary and healthy Primary never
leave). Each was mutation-checked: removing the calculator/legacy guard, the Leave, the
peer-listening guard or the Primary guard fails the corresponding tests.
2026-11-09 12:16:37 -05:00

120 lines
5.0 KiB
C#

using Akka.Cluster;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Cluster.Redundancy;
namespace ZB.MOM.WW.OtOpcUa.Cluster.Tests;
/// <summary>
/// Unit tests for <see cref="ServiceLevelCalculator"/> — the pure ServiceLevel tiering logic.
/// Moved from ControlPlane.Tests to Cluster.Tests because the type lives in Core.Cluster.
/// See code-reviews/Cluster/findings.md Cluster-005.
/// </summary>
public sealed class ServiceLevelCalculatorTests
{
/// <summary>Non-Up member statuses produce ServiceLevel 0 regardless of other inputs.</summary>
[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, IsDriverPrimary: true));
sl.ShouldBe((byte)0);
}
/// <summary>Up + DB reachable + probe ok + not stale + not leader = tier 240 (healthy follower).</summary>
[Fact]
public void Fully_healthy_non_leader_returns_240()
{
var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up,
DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverPrimary: false));
sl.ShouldBe((byte)240);
}
/// <summary>Up + DB reachable + probe ok + not stale + is leader = tier 250 (healthy leader, +10 bonus).</summary>
[Fact]
public void Fully_healthy_role_leader_returns_250()
{
var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up,
DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverPrimary: true));
sl.ShouldBe((byte)250);
}
/// <summary>DB reachable but data stale = tier 200 (stale).</summary>
[Fact]
public void Db_reachable_but_stale_returns_200()
{
var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up,
DbReachable: true, OpcUaProbeOk: true, Stale: true, IsDriverPrimary: false));
sl.ShouldBe((byte)200);
}
/// <summary>DB unreachable AND stale = tier 100 (critically degraded).</summary>
[Fact]
public void Db_unreachable_and_stale_returns_100()
{
var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up,
DbReachable: false, OpcUaProbeOk: false, Stale: true, IsDriverPrimary: false));
sl.ShouldBe((byte)100);
}
/// <summary>OPC UA probe failed while not stale falls through to the catch-all 0.</summary>
[Fact]
public void Opcua_probe_fail_when_not_stale_returns_zero()
{
var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up,
DbReachable: true, OpcUaProbeOk: false, Stale: false, IsDriverPrimary: false));
sl.ShouldBe((byte)0);
}
/// <summary>Joining is treated identically to Up for grading (node is joining the cluster).</summary>
[Fact]
public void Joining_member_is_treated_like_Up()
{
var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Joining,
DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverPrimary: false));
sl.ShouldBe((byte)240);
}
/// <summary>Leader bonus is clamped to 255 even if a hypothetical basis would overflow.</summary>
[Fact]
public void Result_is_clamped_to_255()
{
// basis 240 + 10 = 250, already within byte range; confirms Clamp is in path
var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up,
DbReachable: true, OpcUaProbeOk: true, Stale: false, IsDriverPrimary: true));
((int)sl).ShouldBeLessThanOrEqualTo(255);
}
/// <summary>
/// lmxopcua#762: a node whose own OPC UA endpoint failed to start computes 0 — whatever every other input
/// says, and in particular without the +10 Primary bonus that used to keep a dead endpoint at 250.
/// </summary>
[Theory]
[InlineData(true, true, false, true)] // would be 250
[InlineData(true, true, false, false)] // would be 240
[InlineData(true, true, true, true)] // would be 210
[InlineData(false, true, true, false)] // would be 100
public void Endpoint_not_listening_returns_zero(bool dbReachable, bool probeOk, bool stale, bool primary)
{
var healthy = ServiceLevelCalculator.Compute(new(MemberStatus.Up,
DbReachable: dbReachable, OpcUaProbeOk: probeOk, Stale: stale, IsDriverPrimary: primary));
healthy.ShouldBeGreaterThan((byte)0, "positive control: the same inputs with a listening endpoint must not already be 0");
var sl = ServiceLevelCalculator.Compute(new(MemberStatus.Up,
DbReachable: dbReachable, OpcUaProbeOk: probeOk, Stale: stale, IsDriverPrimary: primary,
EndpointListening: false));
sl.ShouldBe((byte)0);
}
/// <summary>The endpoint input defaults to listening, so callers with no endpoint fact keep the old tiering.</summary>
[Fact]
public void Endpoint_listening_defaults_to_true()
{
new NodeHealthInputs(MemberStatus.Up, true, true, false, true).EndpointListening.ShouldBeTrue();
}
}