From 2f471dd7a151977dfa27f40d6f3e3198e26070e7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 28 Aug 2026 14:20:07 -0400 Subject: [PATCH] =?UTF-8?q?feat(site-runtime):=20pure=20NextTagSubscribeIn?= =?UTF-8?q?terval=20backoff=20step=20(double,=20=C2=B120%=20jitter,=20clam?= =?UTF-8?q?p)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Actors/InstanceActor.cs | 20 ++++++++ .../InstanceActorTagSubscribeBackoffTests.cs | 51 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTagSubscribeBackoffTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs index e0a3aec5..d73356ca 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs @@ -1224,6 +1224,26 @@ public class InstanceActor : ReceiveActor SendTagSubscribe(connectionName); } + /// + /// Pure backoff step for the tag-subscribe retry (Lever 1, 2026-08-28): double the + /// interval just used, apply ±20 % jitter from (a + /// uniform sample in [0,1]; 0.5 = no jitter) so N instances on one connection do not + /// re-storm in lockstep, then clamp to [floor, ceiling]. A ceiling below the floor + /// degrades to a fixed floor interval rather than shrinking below it — same contract + /// as DataConnectionActor.NextTagResolutionInterval. + /// + internal static TimeSpan NextTagSubscribeInterval(TimeSpan current, TimeSpan floor, TimeSpan max, double jitterUnit) + { + var ceiling = max < floor ? floor : max; + if (current < floor) + current = floor; + + var doubledMs = current.TotalMilliseconds * 2; + var jittered = doubledMs * (0.8 + 0.4 * Math.Clamp(jitterUnit, 0.0, 1.0)); + var clamped = Math.Clamp(jittered, floor.TotalMilliseconds, ceiling.TotalMilliseconds); + return TimeSpan.FromMilliseconds(Math.Round(clamped)); + } + /// /// Sends (or re-sends) the tag-subscribe request for one connection and arms a /// per-connection retry timer (S4/UA6). Arming on send means the retry fires diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTagSubscribeBackoffTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTagSubscribeBackoffTests.cs new file mode 100644 index 00000000..aaebb22e --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTagSubscribeBackoffTests.cs @@ -0,0 +1,51 @@ +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; + +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; + +/// +/// Lever 1 (2026-08-28): the pure backoff step behind the Instance Actor's tag-subscribe +/// retry. Doubles with jitter, never below the floor, never above the ceiling; a ceiling +/// below the floor degrades to a fixed interval rather than shrinking. +/// +public class InstanceActorTagSubscribeBackoffTests +{ + private static readonly TimeSpan Floor = TimeSpan.FromSeconds(5); + private static readonly TimeSpan Max = TimeSpan.FromSeconds(60); + + [Fact] + public void Doubles_WithNoJitter() + { + var next = InstanceActor.NextTagSubscribeInterval(Floor, Floor, Max, jitterUnit: 0.5); + Assert.Equal(TimeSpan.FromSeconds(10), next); + } + + [Fact] + public void Jitter_SpansPlusMinusTwentyPercent() + { + var low = InstanceActor.NextTagSubscribeInterval(Floor, Floor, Max, jitterUnit: 0.0); + var high = InstanceActor.NextTagSubscribeInterval(Floor, Floor, Max, jitterUnit: 1.0); + Assert.Equal(TimeSpan.FromSeconds(8), low); // 10 s × 0.8 + Assert.Equal(TimeSpan.FromSeconds(12), high); // 10 s × 1.2 + } + + [Fact] + public void NeverExceedsCeiling_EvenWithPositiveJitter() + { + var next = InstanceActor.NextTagSubscribeInterval(TimeSpan.FromSeconds(40), Floor, Max, jitterUnit: 1.0); + Assert.Equal(Max, next); + } + + [Fact] + public void NeverBelowFloor_EvenWithNegativeJitter() + { + var next = InstanceActor.NextTagSubscribeInterval(TimeSpan.FromSeconds(1), Floor, Max, jitterUnit: 0.0); + Assert.True(next >= Floor, $"{next} < floor"); + } + + [Fact] + public void CeilingBelowFloor_DegradesToFixedFloor() + { + var next = InstanceActor.NextTagSubscribeInterval(Floor, Floor, TimeSpan.FromSeconds(1), jitterUnit: 0.5); + Assert.Equal(Floor, next); + } +}