diff --git a/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json b/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json index 854a47bf..17ddfd7e 100644 --- a/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json +++ b/archreview/plans/R2-09-driver-fleet-batch-plan.md.tasks.json @@ -65,7 +65,7 @@ { "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", - "status": "pending", + "status": "completed", "blockedBy": [] }, { diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ConnectionBackoff.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ConnectionBackoff.cs new file mode 100644 index 00000000..95022c48 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ConnectionBackoff.cs @@ -0,0 +1,93 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// 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: +/// +/// — the pure schedule (base, 2×, 4×, … saturating at a +/// cap) used by PollGroupEngine to stretch its retry cadence under sustained +/// failure. +/// The instance API ( / / +/// ) — a per-device connect-attempt throttle so a dead device +/// is not hammered with a full reconnect on every data call / poll tick. +/// +/// +/// +/// +/// Not internally synchronized. The instance methods mutate plain fields; every +/// intended call site already serializes on its per-device gate (the driver's +/// ConnectGate / connect semaphore / probe lock), so adding lock-free interlocked +/// state would be needless complexity. Callers MUST invoke the instance under that gate. +/// +/// +/// is pure + thread-safe and may be called from anywhere. +/// +/// +public sealed class ConnectionBackoff +{ + private readonly TimeSpan _baseDelay; + private readonly TimeSpan _maxDelay; + private int _consecutiveFailures; + private DateTime _nextAttemptUtc = DateTime.MinValue; + + /// Initializes a new per-device attempt throttle. + /// The initial backoff window after the first failure. + /// The upper bound the exponential growth saturates at. + public ConnectionBackoff(TimeSpan baseDelay, TimeSpan maxDelay) + { + _baseDelay = baseDelay; + _maxDelay = maxDelay; + } + + /// + /// Capped exponential backoff schedule. ≤ 0 returns + /// ; each subsequent failure doubles the wait up to + /// . 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 ComputeBackoffDelay. + /// + /// The base interval (returned when there are no failures). + /// The number of consecutive failures observed. + /// The maximum delay the schedule saturates at. + /// The computed backoff delay. + 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); + } + + /// + /// Returns true when 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 true. + /// + /// The current UTC time. + /// true when an attempt is permitted. + public bool ShouldAttempt(DateTime nowUtc) => nowUtc >= _nextAttemptUtc; + + /// + /// Records a failed attempt: increments the consecutive-failure count and opens a backoff + /// window ending from . + /// + /// The current UTC time (the window is measured from here). + public void RecordFailure(DateTime nowUtc) + { + _consecutiveFailures++; + _nextAttemptUtc = nowUtc + ComputeDelay(_baseDelay, _consecutiveFailures, _maxDelay); + } + + /// + /// 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. + /// + public void RecordSuccess() + { + _consecutiveFailures = 0; + _nextAttemptUtc = DateTime.MinValue; + } +} diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ConnectionBackoffTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ConnectionBackoffTests.cs new file mode 100644 index 00000000..3af88cf0 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ConnectionBackoffTests.cs @@ -0,0 +1,98 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests; + +/// +/// Covers — 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 schedule and the +/// per-device attempt-throttle instance. +/// +[Trait("Category", "Unit")] +public sealed class ConnectionBackoffTests +{ + private static readonly TimeSpan Base = TimeSpan.FromSeconds(1); + private static readonly TimeSpan Cap = TimeSpan.FromSeconds(30); + + /// Zero (or negative) consecutive failures returns the base interval unchanged. + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void ComputeDelay_NoFailures_ReturnsBaseInterval(int failures) + => ConnectionBackoff.ComputeDelay(Base, failures, Cap).ShouldBe(Base); + + /// The delay doubles per consecutive failure (1×, 2×, 4×, 8×) until it saturates the cap. + [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)); + + /// Growth saturates at the cap and never exceeds it, even at a large failure count (overflow guard). + [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); + + /// A fresh throttle permits the first attempt immediately. + [Fact] + public void ShouldAttempt_FreshInstance_AllowsImmediately() + { + var backoff = new ConnectionBackoff(Base, Cap); + backoff.ShouldAttempt(DateTime.UtcNow).ShouldBeTrue(); + } + + /// After a failure the throttle blocks inside the backoff window and reopens once it elapses. + [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 + } + + /// Consecutive failures widen the window (1s then 2s). + [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(); + } + + /// Success resets immediately — recovery is never delayed by a residual window. + [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(); + } +}