Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/WarmStandbyUnitTests.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

251 lines
12 KiB
C#

using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Gitea #717 spec §4.6 (the T0-adopted mechanism) at unit level: the ordinary-tag marker and its validation, the
/// Standby rewrite, the (SubscriptionId, SequenceNumber) decision record and its bounds, and the redundancy option
/// rules of spec §3.
/// </summary>
public sealed class WarmStandbyUnitTests
{
private static DataValue Good(double v, int secondsAgo = 0) =>
new(new Variant(v), StatusCodes.Good, (DateTimeUtc)DateTime.UtcNow.AddSeconds(-secondsAgo), DateTimeUtc.Now);
private static MonitoredItemNotification Marked(uint handle, DataValue value)
{
var n = new MonitoredItemNotification { ClientHandle = handle, Value = value };
n.Message = new OrdinaryTagMarker(handle, value);
return n;
}
private static NotificationMessage Message(uint seq, params MonitoredItemNotification[] items) => new()
{
SequenceNumber = seq,
PublishTime = DateTimeUtc.Now,
NotificationData = new List<ExtensionObject>
{
new(new DataChangeNotification { MonitoredItems = items.ToList() }),
},
};
private static List<MonitoredItemNotification> Items(NotificationMessage m)
{
m.NotificationData[0].TryGetValue<DataChangeNotification>(out var d).ShouldBeTrue();
return Enumerable.Range(0, d!.MonitoredItems.Count).Select(i => d.MonitoredItems[i]).ToList();
}
// ------------------------------------------------------------------ marker + rewrite
[Fact]
public void A_marked_ordinary_tag_notification_is_rewritten_and_the_input_is_not_mutated()
{
var value = Good(5);
var tag = Marked(1, value);
var other = new MonitoredItemNotification { ClientHandle = 2, Value = Good(6) };
var input = Message(7, tag, other);
var projected = StandbyNotificationRewriter.Project(input, out var rewritten);
rewritten.ShouldBe(1);
ReferenceEquals(projected, input).ShouldBeFalse();
var items = Items(projected);
items[0].ClientHandle.ShouldBe(1u);
items[0].Value.StatusCode.ShouldBe(StatusCodes.BadOutOfService);
items[0].Value.WrappedValue.IsNull.ShouldBeTrue();
items[0].Value.ServerTimestamp.ShouldBe(value.ServerTimestamp);
ReferenceEquals(items[1], other).ShouldBeTrue("a non-ordinary notification is passed through untouched");
projected.SequenceNumber.ShouldBe(7u);
// The SDK-owned input (the per-delivery clone) is untouched.
Items(input)[0].Value.StatusCode.ShouldBe(StatusCodes.Good);
ReferenceEquals(Items(input)[0], tag).ShouldBeTrue();
}
[Fact]
public void A_stale_marker_from_a_recycled_payload_is_ignored()
{
// 2.0.1 pools MonitoredItemNotification payloads and Initialize() resets only ClientHandle and Value, so a
// recycled object can carry a marker made for another notification. Handle or value mismatch ⇒ not ordinary.
var otherHandle = new MonitoredItemNotification { ClientHandle = 3, Value = Good(5) };
otherHandle.Message = new OrdinaryTagMarker(1, otherHandle.Value);
var otherValue = new MonitoredItemNotification { ClientHandle = 1, Value = Good(6) };
otherValue.Message = new OrdinaryTagMarker(1, Good(5, secondsAgo: 10));
OrdinaryTagMarker.IsOrdinaryTag(otherHandle).ShouldBeFalse();
OrdinaryTagMarker.IsOrdinaryTag(otherValue).ShouldBeFalse();
var input = Message(1, otherHandle, otherValue);
ReferenceEquals(StandbyNotificationRewriter.Project(input, out var rewritten), input).ShouldBeTrue();
rewritten.ShouldBe(0);
}
[Fact]
public void Bad_and_unmarked_notifications_are_never_rewritten()
{
var bad = Marked(1, new DataValue(Variant.Null, StatusCodes.BadNoCommunication, DateTimeUtc.Now, DateTimeUtc.Now));
var unmarked = new MonitoredItemNotification { ClientHandle = 2, Value = Good(1) };
var input = Message(1, bad, unmarked);
ReferenceEquals(StandbyNotificationRewriter.Project(input, out _), input).ShouldBeTrue();
}
[Fact]
public void Uncertain_ordinary_tag_values_are_projected_too()
{
var uncertain = Marked(1, new DataValue(new Variant(1.0), StatusCodes.UncertainLastUsableValue, DateTimeUtc.Now, DateTimeUtc.Now));
Items(StandbyNotificationRewriter.Project(Message(1, uncertain), out _))[0].Value.StatusCode.ShouldBe(StatusCodes.BadOutOfService);
}
// ------------------------------------------------------------------ SDK-field contract (regression pin)
/// <summary>
/// The mechanism stands on two SDK 2.0.1 behaviours of <see cref="MonitoredItemNotification.Message"/>: the SDK's
/// per-delivery <c>CoreUtils.Clone</c> COPIES it, and the binary encoder NEVER writes it. An SDK upgrade that
/// changes either breaks D4 silently (no marker ⇒ no rewrite) or leaks a server object onto the wire — this
/// fails loudly instead.
/// </summary>
[Fact]
public void Sdk_contract_the_marker_survives_the_delivery_clone_and_is_never_encoded()
{
var value = Good(5);
var marked = Message(9, Marked(1, value));
var clone = CoreUtils.Clone(marked);
ReferenceEquals(clone, marked).ShouldBeFalse();
var cloned = Items(clone!)[0];
ReferenceEquals(cloned, Items(marked)[0]).ShouldBeFalse("the clone is deep");
cloned.Message.ShouldBeOfType<OrdinaryTagMarker>("the SDK clone must copy MonitoredItemNotification.Message");
OrdinaryTagMarker.IsOrdinaryTag(cloned).ShouldBeTrue("the copied marker still validates against the clone");
var context = ServiceMessageContext.Create(DefaultTelemetry.Create(_ => { }));
var unmarked = Message(9, new MonitoredItemNotification { ClientHandle = 1, Value = value });
unmarked.PublishTime = marked.PublishTime;
var withMarker = BinaryEncoder.EncodeMessage(marked, context);
var without = BinaryEncoder.EncodeMessage(unmarked, context);
withMarker.ShouldBe(without, "the helper field must never reach the wire");
var decoded = BinaryDecoder.DecodeMessage<NotificationMessage>(withMarker, context);
Items(decoded!)[0].Message.ShouldBeNull();
}
// ------------------------------------------------------------------ decision record
[Fact]
public void The_first_delivery_records_the_decision_and_every_later_delivery_reuses_it()
{
var record = new FirstDeliveryRecord(standby: false);
record.Decide(1, 10).ShouldBeFalse();
record.SetStandby(true);
record.Decide(1, 10).ShouldBeFalse("a Republish of a message first delivered Active stays Active (D4)");
record.Decide(1, 11).ShouldBeTrue("a first delivery after the flip is Standby");
record.SetStandby(false);
record.Decide(1, 11).ShouldBeTrue("a Republish of a message first delivered Standby stays projected");
record.Decide(2, 11).ShouldBeFalse("keyed by subscription too");
}
[Fact]
public void The_record_is_pruned_by_available_sequence_numbers_and_bounded()
{
var record = new FirstDeliveryRecord(standby: true);
for (uint s = 1; s <= 5; s++) record.Decide(1, s);
record.Prune(1, [3, 4], justDelivered: 5);
record.Count.ShouldBe(3);
for (uint s = 100; s < 100 + FirstDeliveryRecord.MaxPerSubscription + 50; s++) record.Decide(1, s);
record.Count.ShouldBe(FirstDeliveryRecord.MaxPerSubscription);
record.Decide(2, 1);
record.SweepDead(new HashSet<uint> { 2 });
record.SubscriptionCount.ShouldBe(1);
}
[Fact]
public void Dead_subscriptions_are_evicted_least_recently_used_beyond_the_bound()
{
var record = new FirstDeliveryRecord(standby: false);
for (uint sub = 1; sub <= FirstDeliveryRecord.MaxSubscriptions; sub++) record.Decide(sub, 1);
record.Decide(1, 2); // touch subscription 1 so 2 is the least recently used
record.Decide(FirstDeliveryRecord.MaxSubscriptions + 1, 1);
record.SubscriptionCount.ShouldBe(FirstDeliveryRecord.MaxSubscriptions);
}
/// <summary>
/// A role flip and a first delivery race (spec §4.6 item 2): the flip waits for a decision in progress, so the
/// decision is ordered strictly before or after it — never torn. Driven by a deterministic barrier, not sleeps.
/// </summary>
[Fact]
public async Task A_flip_waits_for_a_decision_in_progress_and_orders_strictly_against_it()
{
for (var round = 0; round < 50; round++)
{
var record = new FirstDeliveryRecord(standby: false);
using var inside = new ManualResetEventSlim();
using var release = new ManualResetEventSlim();
record.DecisionBarrier = () => { inside.Set(); release.Wait(TimeSpan.FromSeconds(10)); };
var ct = TestContext.Current.CancellationToken;
var decision = Task.Run(() => record.Decide(1, (uint)round + 1), ct);
inside.Wait(TimeSpan.FromSeconds(10), ct).ShouldBeTrue();
var flip = Task.Run(() => record.SetStandby(true), ct);
(await Task.WhenAny(flip, Task.Delay(100, ct))).ShouldNotBe(flip, "the flip must wait for the decision in progress");
release.Set();
(await decision).ShouldBeFalse("the decision read the state before the flip");
await flip;
record.DecisionBarrier = null;
record.Decide(1, (uint)round + 1).ShouldBeFalse("and recorded it");
record.Decide(1, (uint)round + 1000).ShouldBeTrue("a later first delivery sees the flip");
}
}
// ------------------------------------------------------------------ WarmStandbyState
[Fact]
public void The_Off_state_never_projects_suppresses_or_refuses()
{
var off = new WarmStandbyState(false);
off.DataProjected = true;
off.AlarmsSuppressed = true;
off.DataProjected.ShouldBeFalse();
off.AlarmsSuppressed.ShouldBeFalse();
off.RefusesMutations.ShouldBeFalse();
off.Delivery.ShouldBeNull();
var warm = new WarmStandbyState(true);
warm.DataProjected.ShouldBeTrue("a Warm node boots Standby");
warm.AlarmsSuppressed.ShouldBeTrue();
warm.RefusesMutations.ShouldBeTrue();
warm.Delivery!.Standby.ShouldBeTrue();
}
// ------------------------------------------------------------------ OpcUa:Redundancy rules (spec §3)
[Theory]
[InlineData("Unset", new string[0], true)]
[InlineData("", new string[0], true)]
[InlineData("none", new string[0], true)]
[InlineData("None", new[] { "urn:peer" }, false)]
[InlineData("Warm", new[] { "urn:peer" }, true)]
[InlineData("WARM", new[] { "urn:peer", "urn:peer2" }, true)]
[InlineData("Warm", new string[0], false)]
[InlineData("Warm", new[] { "urn:peer", "urn:peer" }, false)]
[InlineData("Warm", new[] { "urn:self" }, false)]
[InlineData("Hot", new[] { "urn:peer" }, false)]
[InlineData("2", new[] { "urn:peer" }, false)]
[InlineData("Transparent", new[] { "urn:peer" }, false)]
public void Redundancy_option_rules(string mode, string[] peers, bool valid)
{
var options = new OpcUaRedundancyOptions { Mode = mode };
options.Check("urn:self", peers).Count.ShouldBe(valid ? 0 : 1, string.Join("; ", options.Check("urn:self", peers)));
}
[Fact]
public void Redundancy_defaults_are_Unset_with_promote_on()
{
var options = new OpcUaApplicationHostOptions();
options.Redundancy.ParsedMode.ShouldBe(OpcUaRedundancyMode.Unset);
options.Redundancy.PromoteWhilePrimaryUnreachable.ShouldBeTrue();
options.Redundancy.ToSettings().ShouldBe(WarmRedundancySettings.Unset);
}
}