Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/WarmRedundancyRevisionTests.cs
T
Joseph Doherty 7829b937d1 feat(717): Warm non-transparent redundancy, default-off (T1-T10)
OpcUa:Redundancy:Mode (Unset default = today's server exactly; None; Warm; Hot and unknown values fail start).
- T1 options + validator + effective-URI check + boot banner; Warm needs the driver role.
- T2 metadata in OnNodeManagerStartedAsync: Warm -> NonTransparentRedundancyType, read-only
  ServerUriArray [self, peers], RedundancySupport=Warm, boot ServiceLevel 1; None -> RedundancySupport=None
  and the SDK's orphan i=11314 deleted; Unset untouched.
- T3 RedundancyStateChanged.Revision (up-number, incarnation tick, sequence); subscribers drop older
  revisions under Warm (OpcUaPublishActor, DriverHostActor); unrevisioned never makes a node Active.
- T4 WarmRoleAuthority + ComputeWarm bands (Standby 90/50, Active 250/200/100), PromoteWhilePrimaryUnreachable.
- T5 IStandbyProjection + DeferredStandbyProjection; OpcUaPublishActor runs promotion/demotion in the spec
  4.7 order (Active level last and retried; Standby level first, data degraded regardless).
- T6 EquipmentVariableState read projection (BadOutOfService/null); Warm Standby refuses writes BadInvalidState.
- T7 D4 mechanism adopted after T0: ordinary-tag marker on MonitoredItemNotification.Message validated by
  (ClientHandle, DataValue); first-delivery decision keyed by (SubscriptionId, SequenceNumber); the SDK's
  cached copy is never mutated.
- T8 queue current/BadOutOfService to live ordinary-tag items ignoring filters.
- T9 alarm stamp/deliver split (suppressed on Standby), Part 9 methods refused on Standby, promotion
  refresh via ISubscription.ConditionRefresh2Async on every live event item.
- T10 deploy-time RedundancyMode cross-check + six instruments.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-13 03:39:50 -05:00

155 lines
7.7 KiB
C#

using Akka.Actor;
using Akka.Cluster;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Cluster.Redundancy;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Redundancy;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// Gitea #717 spec §4.2 (ordered snapshots) and §4.3 (Warm bands): the revision the singleton stamps, its order,
/// the subscriber gate, the wire shape of the extended message, and the Warm ServiceLevel truth table.
/// </summary>
public sealed class WarmRedundancyRevisionTests : ControlPlaneActorTestBase
{
[Fact]
public void The_singleton_stamps_an_increasing_revision_on_every_publish_heartbeats_included()
{
var probe = CreateTestProbe();
Sys.ActorOf(RedundancyStateActor.Props(msg => probe.Ref.Tell(msg), TimeSpan.FromMilliseconds(200)));
var first = probe.ExpectMsg<RedundancyStateChanged>(TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken);
var second = probe.ExpectMsg<RedundancyStateChanged>(TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken);
first.Revision.ShouldNotBeNull();
second.Revision.ShouldNotBeNull();
second.Revision!.CompareTo(first.Revision).ShouldBeGreaterThan(0);
second.Revision.Sequence.ShouldBe(first.Revision!.Sequence + 1);
first.Revision.PublisherUpNumber.ShouldBeGreaterThan(0L, "the publisher's Akka up-number");
}
/// <summary>Akka.NET keeps <c>Member.UpNumber</c> internal; an upgrade that renames it must fail here, not degrade
/// every revision to up-number 0 silently.</summary>
[Fact]
public void Akka_member_up_number_is_readable()
{
AwaitCondition(() => Akka.Cluster.Cluster.Get(Sys).SelfMember.Status == MemberStatus.Up, TimeSpan.FromSeconds(10),
cancellationToken: TestContext.Current.CancellationToken);
RedundancyStateActor.ReadUpNumber(Akka.Cluster.Cluster.Get(Sys).SelfMember).ShouldNotBeNull().ShouldBeGreaterThan(0);
}
[Fact]
public void A_singleton_restarted_in_the_same_process_outranks_its_predecessor()
{
var a = RedundancyStateActor.NextIncarnationTick();
var b = RedundancyStateActor.NextIncarnationTick();
b.ShouldBeGreaterThan(a, "strictly increasing even within one tick");
new SnapshotRevision(1, b, 1).CompareTo(new SnapshotRevision(1, a, 500)).ShouldBeGreaterThan(0);
}
[Fact]
public void Revisions_order_by_up_number_then_incarnation_then_sequence()
{
var baseline = new SnapshotRevision(2, 100, 50);
new SnapshotRevision(3, 1, 1).CompareTo(baseline).ShouldBeGreaterThan(0, "a handover to a younger member outranks");
new SnapshotRevision(1, 999, 999).CompareTo(baseline).ShouldBeLessThan(0, "a departed older member's late publish loses");
new SnapshotRevision(2, 101, 1).CompareTo(baseline).ShouldBeGreaterThan(0);
new SnapshotRevision(2, 100, 51).CompareTo(baseline).ShouldBeGreaterThan(0);
new SnapshotRevision(2, 100, 50).CompareTo(baseline).ShouldBe(0);
}
[Fact]
public void The_gate_accepts_only_newer_revisions_and_never_advances_on_an_unrevisioned_snapshot()
{
var gate = new SnapshotRevisionGate();
gate.Offer(Snap(null)).ShouldBe(SnapshotRejection.Unrevisioned);
gate.LastAccepted.ShouldBeNull();
gate.Offer(Snap(new(2, 1, 5))).ShouldBe(SnapshotRejection.None);
gate.Offer(Snap(new(1, 9, 9))).ShouldBe(SnapshotRejection.OlderRevision, "an old incarnation's late heartbeat");
gate.Offer(Snap(new(2, 1, 5))).ShouldBe(SnapshotRejection.OlderRevision, "a duplicate");
gate.Offer(Snap(new(2, 1, 6))).ShouldBe(SnapshotRejection.None);
gate.LastAccepted.ShouldBe(new SnapshotRevision(2, 1, 6));
}
[Fact]
public void The_extended_message_round_trips_and_a_pre_717_payload_decodes_with_no_revision()
{
var msg = Snap(new SnapshotRevision(3, 42, 7));
var serializer = Sys.Serialization.FindSerializerFor(msg);
var manifest = Akka.Serialization.Serialization.ManifestFor(serializer, msg);
var round = (RedundancyStateChanged)Sys.Serialization.Deserialize(serializer.ToBinary(msg), serializer.Identifier, manifest);
round.Revision.ShouldBe(msg.Revision);
round.Nodes.Single().NodeId.ShouldBe(msg.Nodes.Single().NodeId);
// A pre-#717 publisher's payload: the same message without the field.
var legacy = Snap(null);
var legacyBytes = serializer.ToBinary(legacy);
var text = System.Text.Encoding.UTF8.GetString(legacyBytes);
var stripped = System.Text.Encoding.UTF8.GetBytes(text.Replace(",\"Revision\":null", string.Empty, StringComparison.Ordinal));
var decoded = (RedundancyStateChanged)Sys.Serialization.Deserialize(stripped, serializer.Identifier, manifest);
decoded.Revision.ShouldBeNull();
decoded.Nodes.Count.ShouldBe(1);
}
private static RedundancyStateChanged Snap(SnapshotRevision? revision) =>
new(new[] { new NodeRedundancyState(NodeId.Parse("a:1"), RedundancyRole.Primary, true, true, DateTime.UtcNow) },
CorrelationId.NewId(), revision);
}
/// <summary>Gitea #717 spec §4.3: the Warm ServiceLevel bands (pure; no actor system).</summary>
public sealed class WarmServiceLevelBandTests
{
public static IEnumerable<object[]> AllInputs()
{
foreach (var member in new[] { MemberStatus.Up, MemberStatus.Joining, MemberStatus.Leaving, MemberStatus.Down })
foreach (var db in new[] { true, false })
foreach (var probe in new[] { true, false })
foreach (var stale in new[] { true, false })
foreach (var listening in new[] { true, false })
yield return [member, db, probe, stale, listening];
}
[Theory]
[MemberData(nameof(AllInputs))]
public void Every_standby_level_is_below_every_active_level_that_serves_data(
MemberStatus member, bool db, bool probe, bool stale, bool listening)
{
var h = new NodeHealthInputs(member, db, probe, stale, IsDriverPrimary: false, listening);
var standby = ServiceLevelCalculator.ComputeWarm(h, isActive: false);
var active = ServiceLevelCalculator.ComputeWarm(h, isActive: true);
standby.ShouldBeLessThanOrEqualTo(ServiceLevelCalculator.WarmStandbyHealthy);
if (active > 0) active.ShouldBeGreaterThanOrEqualTo(ServiceLevelCalculator.WarmActiveCritical);
if (active > 0) standby.ShouldBeLessThan(active);
}
[Theory]
[InlineData(true, true, false, true, 250)]
[InlineData(true, true, true, true, 200)]
[InlineData(false, true, true, true, 100)]
[InlineData(false, true, false, true, 100)]
[InlineData(true, false, false, true, 100)]
[InlineData(true, true, false, false, 90)]
[InlineData(true, true, true, false, 50)]
[InlineData(false, true, false, false, 50)]
[InlineData(true, false, false, false, 50)]
public void Warm_bands_match_the_spec_table(bool db, bool probe, bool stale, bool active, int expected)
{
ServiceLevelCalculator.ComputeWarm(new NodeHealthInputs(MemberStatus.Up, db, probe, stale, IsDriverPrimary: false), active)
.ShouldBe((byte)expected);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void Down_or_a_failed_endpoint_is_zero_in_either_role(bool active)
{
ServiceLevelCalculator.ComputeWarm(new NodeHealthInputs(MemberStatus.Down, true, true, false, false), active).ShouldBe((byte)0);
ServiceLevelCalculator.ComputeWarm(new NodeHealthInputs(MemberStatus.Up, true, true, false, false, EndpointListening: false), active)
.ShouldBe((byte)0);
}
}