Files
lmxopcua/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/PollGroupEngine.cs
T

294 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 TimeSpan? _backoffCap;
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);
/// <summary>Constructs the engine with the driver-supplied reader, change callback, and optional interval floor / error sink.</summary>
/// <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. Defensive: an <c>onError</c> handler that
/// itself throws is silently absorbed so a buggy forwarder cannot crash the poll loop.</param>
/// <param name="backoffCap">Optional cap enabling capped-exponential failure backoff
/// (05/STAB-8). When supplied, sustained reader failures stretch the retry cadence
/// (interval, 2×, 4×, … saturating at this cap) instead of hammering a dead device every
/// tick; a successful poll resets to the base interval. <c>null</c> (default) keeps the
/// byte-identical fixed-interval cadence for consumers that have not opted in.</param>
public PollGroupEngine(
Func<IReadOnlyList<string>, CancellationToken, Task<IReadOnlyList<DataValueSnapshot>>> reader,
Action<ISubscriptionHandle, string, DataValueSnapshot> onChange,
TimeSpan? minInterval = null,
Action<Exception>? onError = null,
TimeSpan? backoffCap = null)
{
ArgumentNullException.ThrowIfNull(reader);
ArgumentNullException.ThrowIfNull(onChange);
_reader = reader;
_onChange = onChange;
_onError = onError;
_minInterval = minInterval ?? DefaultMinInterval;
_backoffCap = backoffCap;
}
/// <summary>Register a new polled subscription and start its background loop.</summary>
/// <param name="fullReferences">The list of tag references to poll.</param>
/// <param name="publishingInterval">The desired polling interval; will be clamped to the configured minimum.</param>
/// <returns>A handle identifying the new subscription, for later use with <see cref="Unsubscribe"/>.</returns>
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>
/// <param name="handle">The subscription handle to cancel.</param>
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)
{
var consecutiveFailures = 0;
// 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);
consecutiveFailures = 0;
}
// Only a teardown OCE (the loop's OWN token) exits the loop. A reader that throws OCE with
// the loop token un-cancelled — a driver-internal timeout CTS (the STAB-14/STAB-15 class) —
// must NOT tear the loop down: it falls through to the generic handler, is reported, and the
// loop retries with backoff. This is the guard that makes the S7 fork migration safe.
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
catch (Exception ex) when (!IsFatal(ex))
{
// first-read error tolerated — loop continues; forward to driver health surface.
consecutiveFailures++;
ReportError(ex);
}
while (!ct.IsCancellationRequested)
{
// Fixed cadence by default; when a backoff cap is configured, sustained failures
// stretch the delay (capped exponential) and a success snaps it back to Interval.
var delay = _backoffCap is null
? state.Interval
: ConnectionBackoff.ComputeDelay(state.Interval, consecutiveFailures, _backoffCap.Value);
try { await Task.Delay(delay, 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);
consecutiveFailures = 0;
}
// Only teardown (the loop token) exits; a reader OCE with ct un-cancelled backs off
// like any other failure instead of killing the loop (STAB-14 engine guard).
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
catch (Exception ex) when (!IsFatal(ex))
{
// transient poll error — loop continues (with backoff when configured); driver
// health surface logs it via the supplied onError callback.
consecutiveFailures++;
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);
// 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>
/// <returns>A task that represents the asynchronous operation.</returns>
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)
{
/// <summary>Gets the cache of last-seen values per tag reference.</summary>
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
{
/// <inheritdoc />
public string DiagnosticId => $"poll-sub-{Id}";
}
}