feat(site-runtime): TagSubscribeRetryMaxIntervalMs + TagSubscribeInFlightCeilingMs options (Lever 1, validated)

This commit is contained in:
Joseph Doherty
2026-08-28 14:19:50 -04:00
parent e39cca093b
commit 6f7c6ad877
3 changed files with 69 additions and 4 deletions
@@ -112,13 +112,34 @@ public class SiteRuntimeOptions
public int ConfigFetchTimeoutSeconds { get; set; } = 30;
/// <summary>
/// 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
/// <see cref="TagSubscribeRetryMaxIntervalMs"/>, and a Success or a connection
/// returning to Good quality resets it here. Default: 5000ms.
/// </summary>
public int TagSubscribeRetryIntervalMs { get; set; } = 5000;
/// <summary>
/// Ceiling (ms) of the tag-subscribe retry backoff. Must be >=
/// <see cref="TagSubscribeRetryIntervalMs"/>; equal makes the interval fixed
/// (pre-Lever-1 behaviour). Default: 60000ms.
/// </summary>
public int TagSubscribeRetryMaxIntervalMs { get; set; } = 60000;
/// <summary>
/// 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.
/// </summary>
public int TagSubscribeInFlightCeilingMs { get; set; } = 45000;
/// <summary>
/// Grace period (ms) after a script's execution timeout elapses before the
/// stuck-script watchdog declares the script's dedicated thread blocked and
@@ -79,6 +79,16 @@ public sealed class SiteRuntimeOptionsValidator : OptionsValidatorBase<SiteRunti
$"(was {options.TagSubscribeRetryIntervalMs}); a zero interval hot-loops the tag-subscribe " +
"retry and a negative one throws inside the Instance Actor's retry scheduling.");
builder.RequireThat(options.TagSubscribeRetryMaxIntervalMs >= 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.");
@@ -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);
}
}