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:
Joseph Doherty
2026-10-11 09:11:34 -04:00
parent db6446ce73
commit 99c13e4459
5 changed files with 147 additions and 5 deletions
@@ -80,11 +80,11 @@ public static class OtOpcUaTelemetry
/// Session/publish-level events of an OPC UA Client driver instance (#276): the operator's view of a
/// flapping upstream. Tagged <see cref="PollOverrunDriverInstanceTag"/> (<c>driver_instance</c>) +
/// <c>event</c> = <c>publish_response|notification|data_change|bad_status_value|publish_error|
/// keepalive_miss|keepalive_recovered</c>.
/// keepalive_miss|keepalive_recovered|stale_notification_dropped</c>.
/// </summary>
public static readonly Counter<long> OpcUaClientEvents =
Meter.CreateCounter<long>("otopcua.driver.opcuaclient.events", unit: "{event}",
description: "OPC UA Client driver session/publish events (event=publish_response|notification|data_change|bad_status_value|publish_error|keepalive_miss|keepalive_recovered).");
description: "OPC UA Client driver session/publish events (event=publish_response|notification|data_change|bad_status_value|publish_error|keepalive_miss|keepalive_recovered|stale_notification_dropped).");
// ---------------- VirtualTag / ScriptedAlarm engines ----------------
@@ -24,6 +24,8 @@ internal sealed class OpcUaClientDiagnostics
public const string KeepAliveMiss = "keepalive_miss";
/// <summary>A keep-alive came back Good after a bad one on the same session (an in-place recovery, #670).</summary>
public const string KeepAliveRecovered = "keepalive_recovered";
/// <summary>A notification older than the newest Good source time already published for its ref was dropped (#670 F2).</summary>
public const string StaleNotificationDropped = "stale_notification_dropped";
private readonly string _driverInstanceId;
@@ -294,6 +294,15 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// <summary>#670 — the last value delivered per subscribed RawPath, so an outage can publish
/// <c>BadNoCommunication</c> with the value retained (the same shape the host's own comms-loss fan uses).</summary>
/// <summary>
/// #670 follow-up F2 — per RawPath, the newest SOURCE timestamp (ticks) of a Good value that came from the
/// upstream (a notification or the recovery read). A notification older than it is a late replay — the SDK can
/// deliver a missed notification seconds after a same-session recovery — and is dropped rather than regressing
/// the node. The driver's own outage Bad never writes it (its stamp is the detection time, not upstream data).
/// </summary>
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _lastGoodSourceTicks =
new(StringComparer.Ordinal);
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, DataValueSnapshot> _lastValues =
new(StringComparer.Ordinal);
@@ -985,6 +994,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
// The host re-subscribes after a reinit and the new subscriptions deliver initial values; nothing of the
// previous connection's value bookkeeping applies to them.
_lastValues.Clear();
_lastGoodSourceTicks.Clear();
_awaitingRecovery.Clear();
// #619 re-review: the last-told alarm states (and ack targets) deliberately SURVIVE a shutdown. A
// ReinitializeAsync (comms-loss watchdog / Force Reconnect) is followed by the host re-subscribing the
@@ -1724,11 +1734,30 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
StatusCode: dv.StatusCode.Code,
SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp,
ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? DateTime.UtcNow : dv.ServerTimestamp);
// F2: never let a late, older notification regress the node. Equal timestamps pass (a status-only change).
if (snapshot.SourceTimestampUtc is { } src
&& _lastGoodSourceTicks.TryGetValue(fullRef, out var newest) && src.Ticks < newest)
{
_diag.Record(OpcUaClientDiagnostics.StaleNotificationDropped);
_logger.LogDebug(
"OpcUaClientDriver '{DriverInstanceId}': dropped a late notification for {Ref} (source {Source:O} is " +
"older than the newest Good source {Newest:O}).", _driverInstanceId, fullRef, src,
new DateTime(newest, DateTimeKind.Utc));
return;
}
RecordUpstreamGood(fullRef, snapshot);
_lastValues[fullRef] = snapshot;
_awaitingRecovery.TryRemove(fullRef, out _);
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, fullRef, snapshot));
}
/// <summary>#670 F2 — remember the newest Good upstream source time for <paramref name="fullRef"/>.</summary>
private void RecordUpstreamGood(string fullRef, DataValueSnapshot snapshot)
{
if (snapshot.SourceTimestampUtc is not { } src || !StatusCode.IsGood(snapshot.StatusCode)) return;
_lastGoodSourceTicks.AddOrUpdate(fullRef, src.Ticks, (_, prior) => Math.Max(prior, src.Ticks));
}
/// <summary>
/// #670 — advance <see cref="DriverHealth.LastSuccessfulRead"/> on upstream traffic (a notification), at most
/// once per second. The state is left as it is: a notification is evidence of comms, but the keep-alive
@@ -2705,8 +2734,11 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
var fullRef = RefOf(pair.Item);
_lastValues.TryGetValue(fullRef, out var last);
_awaitingRecovery[fullRef] = 0;
// F1: stamped with the DETECTION time, as the host's own comms-loss fan does. The recorder historizes
// SourceTimestampUtc ?? ServerTimestampUtc, so the last Good's source time here put the Bad at the
// Good row's (TagId, SourceTimeUtc) key, where the insert-if-absent historian dropped it.
RaiseDataChange(new DataChangeEventArgs(rs.Handle, fullRef, new DataValueSnapshot(
last?.Value, StatusBadNoCommunication, last?.SourceTimestampUtc, now)));
last?.Value, StatusBadNoCommunication, now, now)));
}
}
AnnotateAlarmQuality(StatusBadNoCommunication, now);
@@ -2742,6 +2774,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
if (!Current()) return;
if (!_awaitingRecovery.TryRemove(refs[i], out _)) continue; // a notification got there first
_lastValues[refs[i]] = results[i];
RecordUpstreamGood(refs[i], results[i]);
foreach (var rs in _subscriptions.Values)
if (rs.ItemHandlers.Any(p => RefOf(p.Item) == refs[i]))
RaiseDataChange(new DataChangeEventArgs(rs.Handle, refs[i], results[i]));