fix(opcuaclient): #670 live-gate follow-ups — historize the outage Bad, drop late replays
F1: the driver's outage BadNoCommunication carried the last Good sample's source timestamp. The recorder historizes SourceTimestampUtc ?? ServerTimestampUtc, so the Bad landed on the Good row's (TagId, SourceTimeUtc) key and the insert-if-absent historian dropped it - no outage marker until the host's watchdog marker ~95 s in, none at all for a short blip. Now stamped with the detection time (source + server), as the host's comms-loss fan does. F2: after a same-session recovery the SDK delivered a missed notification seconds late with an older source time and the node regressed. A notification older than the newest Good upstream source time for its ref (notification or recovery read) is dropped and counted (stale_notification_dropped); equal timestamps pass; the outage Bad never moves the comparison; a re-initialise resets it.
This commit is contained in:
+101
@@ -468,4 +468,105 @@ public sealed class OpcUaClientSessionLossTests
|
||||
public void Log<TState>(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId,
|
||||
TState state, Exception? exception, Func<TState, Exception?, string> formatter) => Count++;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #670 follow-up F1 (live gate): the outage Bad carried the last Good sample's SOURCE timestamp, so the
|
||||
/// recorder (which historizes <c>SourceTimestampUtc ?? ServerTimestampUtc</c>) wrote it at the Good row's key —
|
||||
/// the historian's insert-if-absent (TagId, SourceTimeUtc) key dropped it and no outage marker was ever stored.
|
||||
/// The Bad is a fresh observation: it is stamped with the detection time, like the host's own comms-loss fan.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void The_outage_Bad_is_stamped_with_the_detection_time_not_the_last_good_source_time()
|
||||
{
|
||||
var (drv, _, clock) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
var handle = drv.AddSubscriptionForTest([SpeedRef]);
|
||||
var goodAt = clock.Now.UtcDateTime;
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, Good(42.5f, goodAt));
|
||||
var seen = new List<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => seen.Add(e);
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(37));
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
|
||||
|
||||
var bad = seen.ShouldHaveSingleItem().Snapshot;
|
||||
bad.StatusCode.ShouldBe(BadNoCommunication);
|
||||
bad.Value.ShouldBe(42.5f, "the value is still retained");
|
||||
bad.SourceTimestampUtc.ShouldBe(clock.Now.UtcDateTime, "a Bad at the Good row's source time collides with it in history");
|
||||
bad.ServerTimestampUtc.ShouldBe(clock.Now.UtcDateTime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #670 follow-up F2 (live gate): after a same-session recovery the SDK delivered one notification late with
|
||||
/// an OLDER source time, and the node regressed to it. A notification older than the last Good source time
|
||||
/// published for its ref is dropped; an equal one (a status-only change) and a newer one pass.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_notification_older_than_the_last_good_source_time_is_dropped()
|
||||
{
|
||||
var (drv, _, clock) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
var handle = drv.AddSubscriptionForTest([SpeedRef]);
|
||||
var t0 = clock.Now.UtcDateTime;
|
||||
var seen = new List<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => seen.Add(e);
|
||||
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, Good(668, t0.AddSeconds(2)));
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, Good(667, t0.AddSeconds(1))); // late replay
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef,
|
||||
new DataValue(new Variant(668), StatusCodes.UncertainLastUsableValue, t0.AddSeconds(2), t0.AddSeconds(2))); // same ts, status-only
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, Good(669, t0.AddSeconds(3)));
|
||||
|
||||
seen.Select(e => e.Snapshot.Value).ShouldBe(new object[] { 668, 668, 669 });
|
||||
seen[1].Snapshot.StatusCode.ShouldBe((uint)StatusCodes.UncertainLastUsableValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_outage_Bad_does_not_poison_the_ordering_check()
|
||||
{
|
||||
var (drv, _, clock) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
var handle = drv.AddSubscriptionForTest([SpeedRef]);
|
||||
var t0 = clock.Now.UtcDateTime;
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, Good(1, t0));
|
||||
clock.Advance(TimeSpan.FromSeconds(60));
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication); // stamped t0+60 s
|
||||
|
||||
var seen = new List<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => seen.Add(e);
|
||||
// An upstream sample produced during the outage (server-side) and delivered on recovery: older than the Bad's
|
||||
// detection stamp, newer than the last Good — it is real data and must pass.
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, Good(2, t0.AddSeconds(30)));
|
||||
|
||||
seen.ShouldHaveSingleItem().Snapshot.Value.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_late_notification_older_than_the_recovery_read_is_dropped()
|
||||
{
|
||||
var (drv, session, clock) = NewConnectedDriver();
|
||||
using var _ = drv;
|
||||
var handle = drv.AddSubscriptionForTest([SpeedRef]);
|
||||
var t0 = clock.Now.UtcDateTime;
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, Good(666, t0));
|
||||
var readAt = t0.AddSeconds(10);
|
||||
session.Setup(s => s.ReadAsync(
|
||||
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ReadResponse
|
||||
{
|
||||
ResponseHeader = new ResponseHeader(),
|
||||
Results = [new DataValue(new Variant(670), StatusCodes.Good, readAt, readAt)],
|
||||
DiagnosticInfos = [],
|
||||
});
|
||||
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.BadNoCommunication);
|
||||
drv.RaiseKeepAliveForTest(StatusCodes.Good);
|
||||
await drv.RecoveryTaskForTest;
|
||||
|
||||
var seen = new List<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => seen.Add(e);
|
||||
drv.DeliverNotificationForTest(handle, SpeedRef, Good(667, t0.AddSeconds(5))); // replayed, older than the read
|
||||
seen.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user