- Core.Abstractions-004: guard DriverTypeRegistry.Register with a Lock so concurrent registrations are atomic. - Core.Abstractions-005: narrow PollGroupEngine catch blocks to non-fatal exceptions, add optional onError callback, tolerate disposed-CTS races. - Core.Abstractions-006: document the deliberate int-vs-uint asymmetry on IHistoryProvider.ReadEventsAsync / IHistorianDataSource.ReadEventsAsync. - Core.Abstractions-007: pin the gaps with PollGroupEngine + DriverHealth contract tests. - Core.Abstractions-008: correct XML docs on DriverHealth.LastError and the optional / required asymmetry on the history-read surfaces. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
255 lines
12 KiB
C#
255 lines
12 KiB
C#
using System.Collections;
|
|
using System.Collections.Concurrent;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
/// <summary>
|
|
/// Shared poll-based subscription engine for drivers whose underlying protocol has no
|
|
/// native push model (Modbus, AB CIP, S7, FOCAS). Owns one background Task per subscription
|
|
/// that periodically invokes the supplied reader, diffs each snapshot against the last
|
|
/// known value, and dispatches a change callback per changed tag. Extracted from
|
|
/// <c>ModbusDriver</c> (AB CIP PR 1) so poll-based drivers don't each re-ship the loop,
|
|
/// floor logic, and lifecycle plumbing.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>The engine is read-path agnostic: it calls the supplied <c>reader</c> delegate
|
|
/// and trusts the driver to map protocol errors into <see cref="DataValueSnapshot.StatusCode"/>.
|
|
/// Callbacks fire on: (a) the first poll after subscribe (initial-data push per the OPC UA
|
|
/// Part 4 convention), (b) any subsequent poll where the boxed value or status code differs
|
|
/// from the previously-seen snapshot.</para>
|
|
///
|
|
/// <para>Exceptions thrown by the reader on the initial poll or any subsequent poll are
|
|
/// caught — the loop continues on the next tick. When an <c>onError</c> callback is supplied
|
|
/// to the constructor the caught exception is routed to it so the driver's health surface
|
|
/// can record the failure. Without an <c>onError</c> callback the exception is silently
|
|
/// swallowed (preserves the original behaviour for drivers that have not opted in yet).</para>
|
|
///
|
|
/// <para>Programmer errors and obviously-fatal exceptions (<see cref="OutOfMemoryException"/>,
|
|
/// <see cref="ThreadAbortException"/>, <see cref="StackOverflowException"/>,
|
|
/// <see cref="AccessViolationException"/>) are NOT caught — they propagate and tear the poll
|
|
/// loop down rather than spin a silently-broken subscription.</para>
|
|
/// </remarks>
|
|
public sealed class PollGroupEngine : IAsyncDisposable
|
|
{
|
|
private readonly Func<IReadOnlyList<string>, CancellationToken, Task<IReadOnlyList<DataValueSnapshot>>> _reader;
|
|
private readonly Action<ISubscriptionHandle, string, DataValueSnapshot> _onChange;
|
|
private readonly Action<Exception>? _onError;
|
|
private readonly TimeSpan _minInterval;
|
|
private readonly ConcurrentDictionary<long, SubscriptionState> _subscriptions = new();
|
|
private long _nextId;
|
|
|
|
/// <summary>Default floor for publishing intervals — matches the Modbus 100 ms cap.</summary>
|
|
public static readonly TimeSpan DefaultMinInterval = TimeSpan.FromMilliseconds(100);
|
|
|
|
/// <param name="reader">Driver-supplied batch reader; snapshots MUST be returned in the same
|
|
/// order as the input references.</param>
|
|
/// <param name="onChange">Callback invoked per changed tag — the driver forwards to its own
|
|
/// <see cref="ISubscribable.OnDataChange"/> event.</param>
|
|
/// <param name="minInterval">Interval floor; anything below is clamped. Defaults to 100 ms
|
|
/// per <see cref="DefaultMinInterval"/>.</param>
|
|
/// <param name="onError">Optional error sink — invoked once per caught reader exception (or
|
|
/// internal contract-violation throw) so the owning driver can route the failure to its
|
|
/// health surface (Core.Abstractions-005). Defensive: an <c>onError</c> handler that
|
|
/// itself throws is silently absorbed so a buggy forwarder cannot crash the poll loop.</param>
|
|
public PollGroupEngine(
|
|
Func<IReadOnlyList<string>, CancellationToken, Task<IReadOnlyList<DataValueSnapshot>>> reader,
|
|
Action<ISubscriptionHandle, string, DataValueSnapshot> onChange,
|
|
TimeSpan? minInterval = null,
|
|
Action<Exception>? onError = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(reader);
|
|
ArgumentNullException.ThrowIfNull(onChange);
|
|
_reader = reader;
|
|
_onChange = onChange;
|
|
_onError = onError;
|
|
_minInterval = minInterval ?? DefaultMinInterval;
|
|
}
|
|
|
|
/// <summary>Register a new polled subscription and start its background loop.</summary>
|
|
public ISubscriptionHandle Subscribe(IReadOnlyList<string> fullReferences, TimeSpan publishingInterval)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(fullReferences);
|
|
var id = Interlocked.Increment(ref _nextId);
|
|
var cts = new CancellationTokenSource();
|
|
var interval = publishingInterval < _minInterval ? _minInterval : publishingInterval;
|
|
var handle = new PollSubscriptionHandle(id);
|
|
var state = new SubscriptionState(handle, [.. fullReferences], interval, cts);
|
|
_subscriptions[id] = state;
|
|
state.LoopTask = Task.Run(() => PollLoopAsync(state, cts.Token));
|
|
return handle;
|
|
}
|
|
|
|
/// <summary>Cancel the background loop for a handle returned by <see cref="Subscribe"/>.</summary>
|
|
/// <returns><c>true</c> when the handle was known to the engine and has been torn down.</returns>
|
|
public bool Unsubscribe(ISubscriptionHandle handle)
|
|
{
|
|
if (handle is PollSubscriptionHandle h && _subscriptions.TryRemove(h.Id, out var state))
|
|
{
|
|
StopState(state);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private static void StopState(SubscriptionState state)
|
|
{
|
|
try { state.Cts.Cancel(); } catch { }
|
|
// Await the loop task (with a generous timeout) before disposing the CTS so:
|
|
// (a) no _onChange callback fires after the caller considers the engine torn down, and
|
|
// (b) the CTS is not disposed while Task.Delay is still holding a reference to its token,
|
|
// which can turn OperationCanceledException into ObjectDisposedException.
|
|
var task = state.LoopTask;
|
|
if (task is not null)
|
|
{
|
|
try { task.Wait(TimeSpan.FromSeconds(5)); } catch { }
|
|
}
|
|
state.Cts.Dispose();
|
|
}
|
|
|
|
/// <summary>Snapshot of active subscription count — exposed for driver diagnostics.</summary>
|
|
public int ActiveSubscriptionCount => _subscriptions.Count;
|
|
|
|
private async Task PollLoopAsync(SubscriptionState state, CancellationToken ct)
|
|
{
|
|
// Initial-data push: every subscribed tag fires once at subscribe time regardless of
|
|
// whether it has changed, satisfying OPC UA Part 4 initial-value semantics.
|
|
try { await PollOnceAsync(state, forceRaise: true, ct).ConfigureAwait(false); }
|
|
catch (OperationCanceledException) { return; }
|
|
catch (Exception ex) when (!IsFatal(ex))
|
|
{
|
|
// first-read error tolerated — loop continues; forward to driver health surface.
|
|
ReportError(ex);
|
|
}
|
|
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
try { await Task.Delay(state.Interval, ct).ConfigureAwait(false); }
|
|
catch (OperationCanceledException) { return; }
|
|
// Defensive: the CTS may be disposed by Unsubscribe/DisposeAsync between the
|
|
// cancellation check above and the Task.Delay touching the token. Treat that race
|
|
// as a normal cancellation rather than a fatal exception.
|
|
catch (ObjectDisposedException) { return; }
|
|
|
|
try { await PollOnceAsync(state, forceRaise: false, ct).ConfigureAwait(false); }
|
|
catch (OperationCanceledException) { return; }
|
|
catch (Exception ex) when (!IsFatal(ex))
|
|
{
|
|
// transient poll error — loop continues, driver health surface logs it
|
|
// via the supplied onError callback (Core.Abstractions-005).
|
|
ReportError(ex);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Programmer-error / process-fatal exception classification: anything that cannot be
|
|
/// safely "swallowed and retry on the next tick" must escape the poll loop instead.
|
|
/// </summary>
|
|
private static bool IsFatal(Exception ex)
|
|
=> ex is OutOfMemoryException
|
|
or StackOverflowException
|
|
or AccessViolationException
|
|
or ThreadAbortException;
|
|
|
|
/// <summary>
|
|
/// Forward a caught exception to the optional <c>onError</c> callback. Defensive
|
|
/// against an <c>onError</c> implementation that itself throws — that would crash the
|
|
/// poll loop and re-introduce the silent-stall failure mode this method exists to prevent.
|
|
/// </summary>
|
|
private void ReportError(Exception ex)
|
|
{
|
|
if (_onError is null) return;
|
|
try { _onError(ex); }
|
|
catch { /* never let a buggy error sink stop the poll loop */ }
|
|
}
|
|
|
|
private async Task PollOnceAsync(SubscriptionState state, bool forceRaise, CancellationToken ct)
|
|
{
|
|
var snapshots = await _reader(state.TagReferences, ct).ConfigureAwait(false);
|
|
|
|
// Core.Abstractions-002: validate the reader contract before indexing. A reader that
|
|
// returns fewer snapshots than references would silently stall the subscription; surface
|
|
// the violation immediately with a descriptive exception instead.
|
|
if (snapshots.Count != state.TagReferences.Count)
|
|
throw new InvalidOperationException(
|
|
$"Reader contract violation: expected {state.TagReferences.Count} snapshots but received {snapshots.Count}. " +
|
|
"The reader delegate must return one snapshot per input reference in input order.");
|
|
|
|
for (var i = 0; i < state.TagReferences.Count; i++)
|
|
{
|
|
var tagRef = state.TagReferences[i];
|
|
var current = snapshots[i];
|
|
var lastSeen = state.LastValues.TryGetValue(tagRef, out var prev) ? prev : default;
|
|
|
|
if (forceRaise || ValuesAreDifferent(lastSeen?.Value, current.Value) || lastSeen?.StatusCode != current.StatusCode)
|
|
{
|
|
state.LastValues[tagRef] = current;
|
|
_onChange(state.Handle, tagRef, current);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns <c>true</c> when <paramref name="previous"/> and <paramref name="current"/>
|
|
/// represent different values. Array values are compared structurally
|
|
/// (element-by-element) so that a driver producing a fresh array instance on every poll
|
|
/// does not trigger spurious change events when the contents are identical.
|
|
/// </summary>
|
|
private static bool ValuesAreDifferent(object? previous, object? current)
|
|
{
|
|
if (previous is Array prevArr && current is Array currArr)
|
|
return !StructuralComparisons.StructuralEqualityComparer.Equals(prevArr, currArr);
|
|
|
|
return !Equals(previous, current);
|
|
}
|
|
|
|
/// <summary>Cancel every active subscription and await all loop tasks. Idempotent.</summary>
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
// Cancel all loops first so they can all start winding down in parallel.
|
|
foreach (var state in _subscriptions.Values)
|
|
{
|
|
try { state.Cts.Cancel(); } catch { }
|
|
}
|
|
|
|
// Await every loop task before disposing CTSs, ensuring no callback fires after disposal.
|
|
var waitTasks = _subscriptions.Values
|
|
.Select(s => s.LoopTask ?? Task.CompletedTask)
|
|
.ToArray();
|
|
if (waitTasks.Length > 0)
|
|
{
|
|
try { await Task.WhenAll(waitTasks).WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); }
|
|
catch { }
|
|
}
|
|
|
|
foreach (var state in _subscriptions.Values)
|
|
{
|
|
state.Cts.Dispose();
|
|
}
|
|
_subscriptions.Clear();
|
|
}
|
|
|
|
private sealed record SubscriptionState(
|
|
PollSubscriptionHandle Handle,
|
|
IReadOnlyList<string> TagReferences,
|
|
TimeSpan Interval,
|
|
CancellationTokenSource Cts)
|
|
{
|
|
public ConcurrentDictionary<string, DataValueSnapshot> LastValues { get; }
|
|
= new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
/// <summary>
|
|
/// The background poll-loop task. Assigned immediately after creation in
|
|
/// <see cref="Subscribe"/>; awaited during <see cref="Unsubscribe"/> /
|
|
/// <see cref="DisposeAsync"/> so disposal is deterministic and no
|
|
/// <c>_onChange</c> callback can fire after the caller tears down the subscription.
|
|
/// </summary>
|
|
public Task? LoopTask { get; set; }
|
|
}
|
|
|
|
private sealed record PollSubscriptionHandle(long Id) : ISubscriptionHandle
|
|
{
|
|
public string DiagnosticId => $"poll-sub-{Id}";
|
|
}
|
|
}
|