7829b937d1
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
155 lines
7.7 KiB
C#
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);
|
|
}
|
|
}
|