feat(core): PollGroupEngine v2 — failure backoff + caller-token-filtered OCE exit (CONV-1/STAB-8; STAB-14-class engine guard)
This commit is contained in:
@@ -35,6 +35,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
||||
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;
|
||||
|
||||
@@ -52,11 +53,17 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
||||
/// 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)
|
||||
Action<Exception>? onError = null,
|
||||
TimeSpan? backoffCap = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(reader);
|
||||
ArgumentNullException.ThrowIfNull(onChange);
|
||||
@@ -64,6 +71,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
||||
_onChange = onChange;
|
||||
_onError = onError;
|
||||
_minInterval = minInterval ?? DefaultMinInterval;
|
||||
_backoffCap = backoffCap;
|
||||
}
|
||||
|
||||
/// <summary>Register a new polled subscription and start its background loop.</summary>
|
||||
@@ -116,31 +124,54 @@ public sealed class PollGroupEngine : IAsyncDisposable
|
||||
|
||||
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); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
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)
|
||||
{
|
||||
try { await Task.Delay(state.Interval, ct).ConfigureAwait(false); }
|
||||
// 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); }
|
||||
catch (OperationCanceledException) { 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, driver health surface logs it
|
||||
// via the supplied onError callback.
|
||||
// transient poll error — loop continues (with backoff when configured); driver
|
||||
// health surface logs it via the supplied onError callback.
|
||||
consecutiveFailures++;
|
||||
ReportError(ex);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user