diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptions.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptions.cs index 598a58b8..391a961a 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptions.cs @@ -112,13 +112,34 @@ public class SiteRuntimeOptions public int ConfigFetchTimeoutSeconds { get; set; } = 30; /// - /// Fixed interval (ms) at which an Instance Actor re-sends a tag-subscribe - /// request that either failed or whose response was lost (S4/UA6). The retry is - /// armed on every send and cancelled on the first Success reply, so it closes - /// both the failed-response and the lost-response gaps. Default: 5000ms. + /// Floor interval (ms) at which an Instance Actor re-sends a tag-subscribe request + /// that either failed or whose response was lost (S4/UA6). The retry is armed on + /// every send and cancelled on the first Success reply. As of Lever 1 (2026-08-28, + /// MxGateway subscribe-storm prevention) it is the FLOOR of an exponential backoff: + /// each failed/lost attempt doubles the interval (±20 % jitter) up to + /// , and a Success or a connection + /// returning to Good quality resets it here. Default: 5000ms. /// public int TagSubscribeRetryIntervalMs { get; set; } = 5000; + /// + /// Ceiling (ms) of the tag-subscribe retry backoff. Must be >= + /// ; equal makes the interval fixed + /// (pre-Lever-1 behaviour). Default: 60000ms. + /// + public int TagSubscribeRetryMaxIntervalMs { get; set; } = 60000; + + /// + /// How long (ms) an outstanding tag-subscribe request blocks a new one for the + /// same connection. While a request is in flight and younger than this, a retry + /// tick only re-arms the watchdog instead of issuing a duplicate subscribe — this + /// is what stops a slow gateway from receiving a fresh N-tag subscribe every + /// interval on top of the one it is still processing. Once older than this the + /// reply is presumed lost and a new request is allowed. Size it above the + /// gateway's per-command deadline (MxGateway default 30 s). Default: 45000ms. + /// + public int TagSubscribeInFlightCeilingMs { get; set; } = 45000; + /// /// Grace period (ms) after a script's execution timeout elapses before the /// stuck-script watchdog declares the script's dedicated thread blocked and diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptionsValidator.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptionsValidator.cs index 53e81c69..fac982c8 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptionsValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/SiteRuntimeOptionsValidator.cs @@ -79,6 +79,16 @@ public sealed class SiteRuntimeOptionsValidator : OptionsValidatorBase= options.TagSubscribeRetryIntervalMs, + $"ScadaBridge:SiteRuntime:TagSubscribeRetryMaxIntervalMs must be >= TagSubscribeRetryIntervalMs " + + $"(was {options.TagSubscribeRetryMaxIntervalMs} vs {options.TagSubscribeRetryIntervalMs}); " + + "it is the ceiling of the tag-subscribe retry backoff."); + + builder.RequireThat(options.TagSubscribeInFlightCeilingMs > 0, + $"ScadaBridge:SiteRuntime:TagSubscribeInFlightCeilingMs must be greater than 0 " + + $"(was {options.TagSubscribeInFlightCeilingMs}); it bounds how long an unanswered tag-subscribe " + + "blocks a duplicate for the same connection."); + builder.RequireThat(options.StuckScriptGraceMs >= 0, $"ScadaBridge:SiteRuntime:StuckScriptGraceMs must be >= 0 " + $"(was {options.StuckScriptGraceMs}); a negative grace throws inside the stuck-script watchdog's delay."); diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/SiteRuntimeOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/SiteRuntimeOptionsValidatorTests.cs index cb6b0356..e9f130eb 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/SiteRuntimeOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/SiteRuntimeOptionsValidatorTests.cs @@ -144,4 +144,38 @@ public class SiteRuntimeOptionsValidatorTests Assert.True(result.Failed); Assert.Contains("MaxConcurrentRunsPerScript", result.FailureMessage); } + // --- Lever 1 (2026-08-28): tag-subscribe backoff + in-flight ceiling --- + + [Fact] + public void TagSubscribeRetryMaxIntervalMs_BelowFloor_IsRejected() + { + var result = Validate(new SiteRuntimeOptions + { + TagSubscribeRetryIntervalMs = 5000, + TagSubscribeRetryMaxIntervalMs = 4999, + }); + + Assert.True(result.Failed); + Assert.Contains("TagSubscribeRetryMaxIntervalMs", result.FailureMessage); + } + + [Fact] + public void TagSubscribeRetryMaxIntervalMs_EqualToFloor_IsValid() + { + var result = Validate(new SiteRuntimeOptions + { + TagSubscribeRetryIntervalMs = 5000, + TagSubscribeRetryMaxIntervalMs = 5000, + }); + Assert.True(result.Succeeded, result.FailureMessage); + } + + [Fact] + public void ZeroTagSubscribeInFlightCeilingMs_IsRejected() + { + var result = Validate(new SiteRuntimeOptions { TagSubscribeInFlightCeilingMs = 0 }); + + Assert.True(result.Failed); + Assert.Contains("TagSubscribeInFlightCeilingMs", result.FailureMessage); + } }