diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index bc7f4f6f..4b3e65ae 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -59,7 +59,7 @@ public sealed class OpcUaPublishActor : ReceiveActor private readonly Phase7Applier? _applier; private readonly IActorRef? _dbHealthProbe; private readonly TimeSpan _staleWindow; - private TimeSpan _probeFreshnessWindow; + private readonly TimeSpan _probeFreshnessWindow; private readonly Akka.Cluster.Cluster _cluster = Akka.Cluster.Cluster.Get(Context.System); private readonly ILoggingAdapter _log = Context.GetLogger(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs index b14b6c23..4dc5d16e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorTests.cs @@ -355,6 +355,50 @@ public sealed class OpcUaPublishActorTests : RuntimeActorTestBase duration: TimeSpan.FromMilliseconds(500)); } + /// Verifies branch (3) of OpcUaProbeOk(): a peer's NEGATIVE verdict about this node + /// that has aged past _probeFreshnessWindow is given the benefit of the doubt (the peer's + /// verdict aged out → don't demote), so the node still publishes the HEALTHY level. With a 1ms + /// freshness window and a 30ms wait before the recompute, the cached Ok==false verdict is + /// reliably stale by the time the level is computed, so OpcUaProbeOk() returns true and a + /// healthy primary-leader computes inputs (true, true, false) → 240, +10 leader → 250 — NOT the 0 + /// that a still-fresh negative verdict would produce (cf. ). + [Fact] + public void Stale_probe_verdict_is_not_demoted_publishes_healthy() + { + 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, + staleWindow: TimeSpan.FromSeconds(30), + probeFreshnessWindow: TimeSpan.FromMilliseconds(1))); + + // Seed a healthy DB sample and a NEGATIVE peer verdict about me. (No RedundancyStateChanged yet, + // so no level is computed from these — the verdict just gets stamped with the receive time.) + actor.Tell(new DbHealthProbeActor.DbHealthStatus(true, DateTime.UtcNow, null)); + actor.Tell(new PeerOpcUaProbeActor.OpcUaProbeResult(local, Ok: false)); + + // Let the verdict age WELL past the 1ms freshness window before any level is computed. The 30ms + // wait is >> 1ms, so the verdict is reliably stale by the time OpcUaProbeOk() runs — no reliance + // on sub-ms clock ties. + ExpectNoMsg(TimeSpan.FromMilliseconds(30)); + + // Now trigger the recompute with a fresh healthy primary-leader snapshot. By now _probeAboutMe is + // >1ms old, so branch (3) ignores the negative verdict and OpcUaProbeOk() returns true. + actor.Tell(new RedundancyStateChanged( + Nodes: new[] + { + new NodeRedundancyState(local, RedundancyRole.Primary, + IsClusterLeader: true, IsRoleLeaderForDriver: true, DateTime.UtcNow), + }, + CorrelationId.NewId())); + + // Healthy (250), NOT 0 — proves an aged negative verdict does not demote. + AwaitAssert(() => publisher.Levels.ShouldContain((byte)250), + duration: TimeSpan.FromMilliseconds(500)); + } + /// Verifies that with no peer probe result ever received, OpcUaProbeOk() defaults /// to true (benefit of the doubt / single-node). A healthy primary-leader thus computes /// inputs (true, true, false) → 240, +10 leader → 250.