feat(core): ConnectionBackoff primitive — shared capped-exponential backoff + attempt throttle (05/STAB-8; seam for R2-01 S7 wiring)

This commit is contained in:
Joseph Doherty
2026-07-13 11:48:49 -04:00
parent 4231afb268
commit b8cf3cce0b
3 changed files with 192 additions and 1 deletions
@@ -65,7 +65,7 @@
{ {
"id": "B3.1", "id": "B3.1",
"subject": "B3: ConnectionBackoff primitive in Core.Abstractions (static ComputeDelay ported from S7 + instance attempt-throttle) + ConnectionBackoffTests \u2014 STAB-8 seam, hand-off to R2-01", "subject": "B3: ConnectionBackoff primitive in Core.Abstractions (static ComputeDelay ported from S7 + instance attempt-throttle) + ConnectionBackoffTests \u2014 STAB-8 seam, hand-off to R2-01",
"status": "pending", "status": "completed",
"blockedBy": [] "blockedBy": []
}, },
{ {
@@ -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;
}
}
@@ -0,0 +1,98 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
/// <summary>
/// Covers <see cref="ConnectionBackoff"/> — the shared capped-exponential backoff extracted
/// from the S7 poll fork (05/STAB-8; the seam plan R2-01 wires into the S7 connect throttle).
/// Two surfaces: the static <see cref="ConnectionBackoff.ComputeDelay"/> schedule and the
/// per-device attempt-throttle instance.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ConnectionBackoffTests
{
private static readonly TimeSpan Base = TimeSpan.FromSeconds(1);
private static readonly TimeSpan Cap = TimeSpan.FromSeconds(30);
/// <summary>Zero (or negative) consecutive failures returns the base interval unchanged.</summary>
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void ComputeDelay_NoFailures_ReturnsBaseInterval(int failures)
=> ConnectionBackoff.ComputeDelay(Base, failures, Cap).ShouldBe(Base);
/// <summary>The delay doubles per consecutive failure (1×, 2×, 4×, 8×) until it saturates the cap.</summary>
[Theory]
[InlineData(1, 1)]
[InlineData(2, 2)]
[InlineData(3, 4)]
[InlineData(4, 8)]
[InlineData(5, 16)]
public void ComputeDelay_DoublesPerFailure(int failures, int expectedSeconds)
=> ConnectionBackoff.ComputeDelay(Base, failures, Cap)
.ShouldBe(TimeSpan.FromSeconds(expectedSeconds));
/// <summary>Growth saturates at the cap and never exceeds it, even at a large failure count (overflow guard).</summary>
[Theory]
[InlineData(6)] // 32s would exceed 30s cap
[InlineData(30)]
[InlineData(1000)] // shift saturates; ticks overflow guard returns cap
public void ComputeDelay_SaturatesAtCap(int failures)
=> ConnectionBackoff.ComputeDelay(Base, failures, Cap).ShouldBe(Cap);
/// <summary>A fresh throttle permits the first attempt immediately.</summary>
[Fact]
public void ShouldAttempt_FreshInstance_AllowsImmediately()
{
var backoff = new ConnectionBackoff(Base, Cap);
backoff.ShouldAttempt(DateTime.UtcNow).ShouldBeTrue();
}
/// <summary>After a failure the throttle blocks inside the backoff window and reopens once it elapses.</summary>
[Fact]
public void RecordFailure_BlocksWithinWindow_ReopensAfter()
{
var backoff = new ConnectionBackoff(Base, Cap);
var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
backoff.RecordFailure(t0); // window = 1s (first failure)
backoff.ShouldAttempt(t0).ShouldBeFalse(); // still inside window
backoff.ShouldAttempt(t0.AddMilliseconds(500)).ShouldBeFalse();
backoff.ShouldAttempt(t0.AddSeconds(1)).ShouldBeTrue(); // window elapsed
}
/// <summary>Consecutive failures widen the window (1s then 2s).</summary>
[Fact]
public void RecordFailure_ConsecutiveFailures_WidenWindow()
{
var backoff = new ConnectionBackoff(Base, Cap);
var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
backoff.RecordFailure(t0); // 1s
backoff.RecordFailure(t0.AddSeconds(1)); // 2nd failure → 2s window
backoff.ShouldAttempt(t0.AddSeconds(2)).ShouldBeFalse(); // within the 2s window
backoff.ShouldAttempt(t0.AddSeconds(3)).ShouldBeTrue();
}
/// <summary>Success resets immediately — recovery is never delayed by a residual window.</summary>
[Fact]
public void RecordSuccess_ResetsWindowImmediately()
{
var backoff = new ConnectionBackoff(Base, Cap);
var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
backoff.RecordFailure(t0);
backoff.RecordFailure(t0); // deep in a widened window
backoff.ShouldAttempt(t0).ShouldBeFalse();
backoff.RecordSuccess();
backoff.ShouldAttempt(t0).ShouldBeTrue(); // reset, no residual delay
// And the schedule restarts from the base window on the next failure.
backoff.RecordFailure(t0);
backoff.ShouldAttempt(t0.AddMilliseconds(999)).ShouldBeFalse();
backoff.ShouldAttempt(t0.AddSeconds(1)).ShouldBeTrue();
}
}