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:
@@ -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 _);
|
||||
|
||||
Reference in New Issue
Block a user