feat(core): ConnectionBackoff primitive — shared capped-exponential backoff + attempt throttle (05/STAB-8; seam for R2-01 S7 wiring)
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Shared capped-exponential backoff for poll-based / lazy-reconnect drivers, extracted from
|
||||
/// the S7 driver's bespoke poll fork (05/STAB-8, CONV-1). Provides two surfaces:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="ComputeDelay"/> — the pure schedule (base, 2×, 4×, … saturating at a
|
||||
/// cap) used by <c>PollGroupEngine</c> to stretch its retry cadence under sustained
|
||||
/// failure.</item>
|
||||
/// <item>The instance API (<see cref="ShouldAttempt"/> / <see cref="RecordFailure"/> /
|
||||
/// <see cref="RecordSuccess"/>) — a per-device connect-attempt throttle so a dead device
|
||||
/// is not hammered with a full reconnect on every data call / poll tick.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Not internally synchronized.</b> The instance methods mutate plain fields; every
|
||||
/// intended call site already serializes on its per-device gate (the driver's
|
||||
/// <c>ConnectGate</c> / connect semaphore / probe lock), so adding lock-free interlocked
|
||||
/// state would be needless complexity. Callers MUST invoke the instance under that gate.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="ComputeDelay"/> is pure + thread-safe and may be called from anywhere.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ConnectionBackoff
|
||||
{
|
||||
private readonly TimeSpan _baseDelay;
|
||||
private readonly TimeSpan _maxDelay;
|
||||
private int _consecutiveFailures;
|
||||
private DateTime _nextAttemptUtc = DateTime.MinValue;
|
||||
|
||||
/// <summary>Initializes a new per-device attempt throttle.</summary>
|
||||
/// <param name="baseDelay">The initial backoff window after the first failure.</param>
|
||||
/// <param name="maxDelay">The upper bound the exponential growth saturates at.</param>
|
||||
public ConnectionBackoff(TimeSpan baseDelay, TimeSpan maxDelay)
|
||||
{
|
||||
_baseDelay = baseDelay;
|
||||
_maxDelay = maxDelay;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Capped exponential backoff schedule. <paramref name="consecutiveFailures"/> ≤ 0 returns
|
||||
/// <paramref name="baseInterval"/>; each subsequent failure doubles the wait up to
|
||||
/// <paramref name="cap"/>. Computed in ticks with an overflow guard so a large failure
|
||||
/// count saturates at the cap rather than wrapping negative. Ported verbatim from the S7
|
||||
/// poll fork's <c>ComputeBackoffDelay</c>.
|
||||
/// </summary>
|
||||
/// <param name="baseInterval">The base interval (returned when there are no failures).</param>
|
||||
/// <param name="consecutiveFailures">The number of consecutive failures observed.</param>
|
||||
/// <param name="cap">The maximum delay the schedule saturates at.</param>
|
||||
/// <returns>The computed backoff delay.</returns>
|
||||
public static TimeSpan ComputeDelay(TimeSpan baseInterval, int consecutiveFailures, TimeSpan cap)
|
||||
{
|
||||
if (consecutiveFailures <= 0) return baseInterval;
|
||||
// Cap the shift to avoid overflow — at 30 the result already saturates the cap for any
|
||||
// reasonable base interval.
|
||||
var shift = Math.Min(consecutiveFailures - 1, 30);
|
||||
var ticks = baseInterval.Ticks << shift;
|
||||
if (ticks <= 0 || ticks > cap.Ticks) return cap;
|
||||
return TimeSpan.FromTicks(ticks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> when <paramref name="nowUtc"/> is at or past the end of the current
|
||||
/// backoff window (i.e. a connect / create attempt is due). A fresh instance and a
|
||||
/// just-reset instance always return <c>true</c>.
|
||||
/// </summary>
|
||||
/// <param name="nowUtc">The current UTC time.</param>
|
||||
/// <returns><c>true</c> when an attempt is permitted.</returns>
|
||||
public bool ShouldAttempt(DateTime nowUtc) => nowUtc >= _nextAttemptUtc;
|
||||
|
||||
/// <summary>
|
||||
/// Records a failed attempt: increments the consecutive-failure count and opens a backoff
|
||||
/// window ending <see cref="ComputeDelay"/> from <paramref name="nowUtc"/>.
|
||||
/// </summary>
|
||||
/// <param name="nowUtc">The current UTC time (the window is measured from here).</param>
|
||||
public void RecordFailure(DateTime nowUtc)
|
||||
{
|
||||
_consecutiveFailures++;
|
||||
_nextAttemptUtc = nowUtc + ComputeDelay(_baseDelay, _consecutiveFailures, _maxDelay);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a successful attempt: resets the failure count and clears the window so the next
|
||||
/// attempt is permitted immediately. Recovery is never delayed by a residual backoff window.
|
||||
/// </summary>
|
||||
public void RecordSuccess()
|
||||
{
|
||||
_consecutiveFailures = 0;
|
||||
_nextAttemptUtc = DateTime.MinValue;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user