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,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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user