fix(opcuaclient): #676 re-review — trailing read skips refs delivered to while in flight

1. A per-ref delivery counter (bumped under the delivery lock by every publish path) is
   captured before the trailing read is issued; a ref delivered to since is skipped.
   Without source timestamps the read's sample could not be ordered against a notification
   that landed mid-read, and publishing it regressed the node. Test injects the
   notification inside the mocked read, with and without a read source time (red before).
2. Delivery-lock comment corrected (it serialises across subscriptions, which the SDK
   dispatches on separate workers); PublishCommsLoss now publishes under the lock too.
3. CancelSessionWork cancels without disposing, so a keep-alive thread reading the old
   token cannot hit ObjectDisposedException.
This commit is contained in:
Joseph Doherty
2026-10-11 11:44:12 -04:00
parent 30a9d5f094
commit 27bf4826d6
3 changed files with 94 additions and 23 deletions
@@ -361,14 +361,25 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// Review of 3b88056f — serialises "decide whether this upstream value may be published" with "publish it" across
/// the three paths that publish upstream data: live notifications, the recovery read and the trailing reconcile
/// read. Without it a path could pass its newer-than check, lose the CPU to another path publishing a NEWER value,
/// and then deliver its older one last — regressing the node. One driver-wide lock rather than one per ref: SDK
/// notifications for a session arrive on its publish thread one message at a time, so the only contention is a
/// recovery/trailing read overlapping a notification (seconds after an outage), and an uncontended lock costs tens
/// of nanoseconds per notification. Subscribers are invoked under it — the host's handler only <c>Tell</c>s an
/// actor — so a subscriber must never block on, or call back into, this driver.
/// and then deliver its older one last — regressing the node. It also covers the comms-loss Bad fan
/// (<see cref="PublishCommsLoss"/>). One driver-wide lock rather than one per ref: it also serialises delivery
/// ACROSS subscriptions — with several subscriptions the SDK dispatches each subscription's messages on its own
/// worker, so their notifications no longer run in parallel. That is the cost, and it is small: each critical
/// section is a dictionary update plus a subscriber call that only <c>Tell</c>s an actor (tens of nanoseconds to
/// a few microseconds), nothing awaits under it, and per-subscription message order is unaffected. Because
/// subscribers are invoked under it, a subscriber must never block on, or call back into, this driver.
/// </summary>
private readonly Lock _deliveryLock = new();
/// <summary>
/// Re-review of 30a9d5f0 — per RawPath, a counter bumped (under <see cref="_deliveryLock"/>) every time a value is
/// delivered for it. The trailing re-read captures it before issuing its read and skips any ref delivered to since:
/// without source timestamps the read's value cannot be ordered against a notification that landed while the read
/// was in flight, and publishing it would regress the node to the value the read sampled.
/// </summary>
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _deliverySeq =
new(StringComparer.Ordinal);
/// <summary>Review of 3b88056f — cancels the post-recovery background work (trailing read, replay-guard disarm timer)
/// of the current session; cancelled and replaced whenever the session is released, and on dispose.</summary>
private CancellationTokenSource _sessionWorkCts = new();
@@ -1077,9 +1088,11 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// <summary>Cancel the current session's background work and start a fresh token for the next one.</summary>
private void CancelSessionWork()
{
// Cancelled but deliberately NOT disposed (re-review of 30a9d5f0): the keep-alive thread may be reading the old
// source's Token concurrently, and a disposed source throws ObjectDisposedException there. Nothing here is
// timer-backed (no CancelAfter), so an undisposed source holds no unmanaged resource.
var old = Interlocked.Exchange(ref _sessionWorkCts, new CancellationTokenSource());
try { old.Cancel(); } catch (ObjectDisposedException) { }
old.Dispose();
old.Cancel();
}
/// <summary>Upper bound on the best-effort wire calls a teardown makes against a possibly-dead upstream.</summary>
@@ -1884,6 +1897,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
}
RecordUpstreamGood(fullRef, snapshot);
_lastValues[fullRef] = snapshot;
_deliverySeq.AddOrUpdate(fullRef, 1, (_, n) => n + 1);
_awaitingRecovery.TryRemove(fullRef, out _);
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, fullRef, snapshot));
}
@@ -3030,18 +3044,23 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// </summary>
private void PublishCommsLoss(DateTime now)
{
foreach (var rs in _subscriptions.Values)
// Under the delivery lock (re-review of 30a9d5f0) so the Bad cannot interleave with a recovery publish.
lock (_deliveryLock)
{
foreach (var pair in rs.ItemHandlers)
foreach (var rs in _subscriptions.Values)
{
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, now, now)));
foreach (var pair in rs.ItemHandlers)
{
var fullRef = RefOf(pair.Item);
_lastValues.TryGetValue(fullRef, out var last);
_awaitingRecovery[fullRef] = 0;
_deliverySeq.AddOrUpdate(fullRef, 1, (_, n) => n + 1);
// 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, now, now)));
}
}
}
AnnotateAlarmQuality(StatusBadNoCommunication, now);
@@ -3105,6 +3124,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
private void PublishUpstreamLocked(string fullRef, DataValueSnapshot value)
{
_lastValues[fullRef] = value;
_deliverySeq.AddOrUpdate(fullRef, 1, (_, n) => n + 1);
RecordUpstreamGood(fullRef, value);
if (value.SourceTimestampUtc is { } readAt && StatusCode.IsGood(value.StatusCode)
&& Interlocked.Read(ref _replayGuardUntilTicks) != 0)
@@ -3146,6 +3166,9 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
.Distinct(StringComparer.Ordinal)
.ToList();
if (refs.Count == 0) return;
// Captured BEFORE the read is issued: a ref delivered to while the read is in flight has a value at least as
// new as anything the read could have sampled, so the read must not overwrite it.
var seqAtIssue = refs.Select(r => _deliverySeq.GetValueOrDefault(r)).ToArray();
var (results, transportOk) = await ReadCoreAsync(refs, ct, session).ConfigureAwait(false);
if (!transportOk) return; // a new outage — the keep-alive reports it
var published = 0;
@@ -3154,6 +3177,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
if (!Current()) return;
lock (_deliveryLock)
{
if (_deliverySeq.GetValueOrDefault(refs[i]) != seqAtIssue[i]) continue;
if (!IsReconcileRepair(refs[i], results[i])) continue;
BeforeReconcilePublishForTest?.Invoke(refs[i]);
_awaitingRecovery.TryRemove(refs[i], out _);