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
251 lines
12 KiB
C#
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);
|
|
}
|
|
}
|