d7fa31f0b8
When a replicated site pair cold-starts together, the node that wins the Deployment Manager singleton loads its own (stale) store, and the peer's rows arrive by LocalDb sync about a second later. The boot load has already run and the startup reconcile rightly reports no gap (the rows are in the store by the time it asks central), so only the instance sweep starts those instances - at its plain 60 s cadence. Reproduced on the primary rig: boot load without the instance at 05:03:16, sweep start at 05:04:16. The env2 drill read this as "never started" because the operator redeployed at +49 s, before the first sweep tick at +60 s. Fix: when boot-time Instance Actor creation finishes, the singleton runs one sweep at once, then sweeps every InstanceSweepSettleIntervalSeconds (default 5) for InstanceSweepSettleWindowSeconds (default 120), then falls back to InstanceSweepIntervalSeconds. Startup never waits for the peer, so a site whose peer is down for good still starts alone from its own store. LocalDb 0.3.7 exposes no "snapshot applied" signal, so the fix rides the arrival-path-agnostic sweep with all its existing guards. Tests: PostBootSettleSweepTests (4) + validator theory. The two positive tests fail with the fix disabled; the window-close test fails when the settle timer is never cancelled. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
319 lines
12 KiB
C#
319 lines
12 KiB
C#
using Microsoft.Extensions.Options;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests;
|
|
|
|
/// <summary>
|
|
/// <see cref="SiteRuntimeOptions"/> values size the staggered Instance-Actor
|
|
/// startup batches, the dedicated script-execution thread scheduler, and the
|
|
/// site-wide stream buffer. A zero/negative count crashes actor construction with
|
|
/// an opaque <see cref="ArgumentOutOfRangeException"/>; these tests assert the
|
|
/// <see cref="SiteRuntimeOptionsValidator"/> rejects a bad
|
|
/// <c>ScadaBridge:SiteRuntime</c> section with a clear, key-naming message.
|
|
/// </summary>
|
|
public class SiteRuntimeOptionsValidatorTests
|
|
{
|
|
private static ValidateOptionsResult Validate(SiteRuntimeOptions options) =>
|
|
new SiteRuntimeOptionsValidator().Validate(Options.DefaultName, options);
|
|
|
|
/// <summary>Verifies that default options are valid.</summary>
|
|
[Fact]
|
|
public void DefaultOptions_AreValid()
|
|
{
|
|
var result = Validate(new SiteRuntimeOptions());
|
|
Assert.True(result.Succeeded, result.FailureMessage);
|
|
}
|
|
|
|
/// <summary>
|
|
/// ScadaBridge#135: the post-boot settle sweep cadence and window feed Akka timers, so a
|
|
/// negative value must be rejected with a key-naming message; 0 (disable) is valid.
|
|
/// </summary>
|
|
/// <param name="interval">InstanceSweepSettleIntervalSeconds.</param>
|
|
/// <param name="window">InstanceSweepSettleWindowSeconds.</param>
|
|
/// <param name="expectedKey">The key the failure must name, or null when valid.</param>
|
|
[Theory]
|
|
[InlineData(-1, 120, "InstanceSweepSettleIntervalSeconds")]
|
|
[InlineData(5, -1, "InstanceSweepSettleWindowSeconds")]
|
|
[InlineData(0, 0, null)]
|
|
public void InstanceSweepSettleOptions_RejectNegativeValues(int interval, int window, string? expectedKey)
|
|
{
|
|
var result = Validate(new SiteRuntimeOptions
|
|
{
|
|
InstanceSweepSettleIntervalSeconds = interval,
|
|
InstanceSweepSettleWindowSeconds = window,
|
|
});
|
|
|
|
if (expectedKey == null)
|
|
{
|
|
Assert.True(result.Succeeded, result.FailureMessage);
|
|
return;
|
|
}
|
|
|
|
Assert.True(result.Failed);
|
|
Assert.Contains(expectedKey, result.FailureMessage);
|
|
}
|
|
|
|
/// <summary>Verifies that zero startup batch size is rejected.</summary>
|
|
[Fact]
|
|
public void ZeroStartupBatchSize_IsRejected()
|
|
{
|
|
var result = Validate(new SiteRuntimeOptions { StartupBatchSize = 0 });
|
|
|
|
Assert.True(result.Failed);
|
|
Assert.Contains("StartupBatchSize", result.FailureMessage);
|
|
}
|
|
|
|
/// <summary>Verifies that zero script execution thread count is rejected.</summary>
|
|
[Fact]
|
|
public void ZeroScriptExecutionThreadCount_IsRejected()
|
|
{
|
|
var result = Validate(new SiteRuntimeOptions { ScriptExecutionThreadCount = 0 });
|
|
|
|
Assert.True(result.Failed);
|
|
Assert.Contains("ScriptExecutionThreadCount", result.FailureMessage);
|
|
}
|
|
|
|
/// <summary>Verifies that zero tag subscribe retry interval ms is rejected.</summary>
|
|
[Fact]
|
|
public void ZeroTagSubscribeRetryIntervalMs_IsRejected()
|
|
{
|
|
var result = Validate(new SiteRuntimeOptions { TagSubscribeRetryIntervalMs = 0 });
|
|
|
|
Assert.True(result.Failed);
|
|
Assert.Contains("TagSubscribeRetryIntervalMs", result.FailureMessage);
|
|
}
|
|
|
|
/// <summary>Verifies that negative stuck script grace ms is rejected.</summary>
|
|
[Fact]
|
|
public void NegativeStuckScriptGraceMs_IsRejected()
|
|
{
|
|
var result = Validate(new SiteRuntimeOptions { StuckScriptGraceMs = -1 });
|
|
|
|
Assert.True(result.Failed);
|
|
Assert.Contains("StuckScriptGraceMs", result.FailureMessage);
|
|
}
|
|
|
|
/// <summary>Verifies that zero stream buffer size is rejected.</summary>
|
|
[Fact]
|
|
public void ZeroStreamBufferSize_IsRejected()
|
|
{
|
|
var result = Validate(new SiteRuntimeOptions { StreamBufferSize = 0 });
|
|
|
|
Assert.True(result.Failed);
|
|
Assert.Contains("StreamBufferSize", result.FailureMessage);
|
|
}
|
|
|
|
/// <summary>Verifies that negative startup batch delay ms is rejected.</summary>
|
|
[Fact]
|
|
public void NegativeStartupBatchDelayMs_IsRejected()
|
|
{
|
|
var result = Validate(new SiteRuntimeOptions { StartupBatchDelayMs = -1 });
|
|
|
|
Assert.True(result.Failed);
|
|
Assert.Contains("StartupBatchDelayMs", result.FailureMessage);
|
|
}
|
|
|
|
/// <summary>Verifies that zero alarm publish queue capacity is rejected.</summary>
|
|
[Fact]
|
|
public void ZeroAlarmPublishQueueCapacity_IsRejected()
|
|
{
|
|
var result = Validate(new SiteRuntimeOptions { AlarmPublishQueueCapacity = 0 });
|
|
|
|
Assert.True(result.Failed);
|
|
Assert.Contains("AlarmPublishQueueCapacity", result.FailureMessage);
|
|
}
|
|
|
|
// ── Script-pool options ──────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// The ceiling is the upper bound of the instance-scaled pool; below the floor it would
|
|
/// silently shrink the configured pool instead of growing it.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ScriptExecutionMaxThreadCountBelowThreadCount_IsRejected()
|
|
{
|
|
var result = Validate(new SiteRuntimeOptions
|
|
{
|
|
ScriptExecutionThreadCount = 8,
|
|
ScriptExecutionMaxThreadCount = 4
|
|
});
|
|
|
|
Assert.True(result.Failed);
|
|
Assert.Contains("ScriptExecutionMaxThreadCount", result.FailureMessage);
|
|
}
|
|
|
|
/// <summary>Verifies that equal script execution thread count and max is accepted.</summary>
|
|
[Fact]
|
|
public void EqualScriptExecutionThreadCountAndMax_IsAccepted()
|
|
{
|
|
var result = Validate(new SiteRuntimeOptions
|
|
{
|
|
ScriptExecutionThreadCount = 8,
|
|
ScriptExecutionMaxThreadCount = 8
|
|
});
|
|
|
|
Assert.True(result.Succeeded, result.FailureMessage);
|
|
}
|
|
|
|
/// <summary>A zero gate would park every Expression trigger on the node forever.</summary>
|
|
[Fact]
|
|
public void ZeroTriggerEvalMaxConcurrency_IsRejected()
|
|
{
|
|
var result = Validate(new SiteRuntimeOptions { TriggerEvalMaxConcurrency = 0 });
|
|
|
|
Assert.True(result.Failed);
|
|
Assert.Contains("TriggerEvalMaxConcurrency", result.FailureMessage);
|
|
}
|
|
|
|
/// <summary>Verifies that zero trigger eval timeout seconds is rejected.</summary>
|
|
[Fact]
|
|
public void ZeroTriggerEvalTimeoutSeconds_IsRejected()
|
|
{
|
|
var result = Validate(new SiteRuntimeOptions { TriggerEvalTimeoutSeconds = 0 });
|
|
|
|
Assert.True(result.Failed);
|
|
Assert.Contains("TriggerEvalTimeoutSeconds", result.FailureMessage);
|
|
}
|
|
|
|
/// <summary>A zero cap would shed every trigger, silently disabling all scripts.</summary>
|
|
[Fact]
|
|
public void ZeroTraceArmWindowSeconds_IsRejected()
|
|
{
|
|
// Flow Debugger design 2026-09-05 §5.3: the arm window must be positive.
|
|
var result = Validate(new SiteRuntimeOptions { TraceArmWindowSeconds = 0 });
|
|
|
|
Assert.True(result.Failed);
|
|
Assert.Contains("TraceArmWindowSeconds", result.FailureMessage);
|
|
Assert.Equal(30, new SiteRuntimeOptions().TraceArmWindowSeconds);
|
|
}
|
|
|
|
/// <summary>A zero cap would drop every log line AND the closing row that reports the drops.</summary>
|
|
[Fact]
|
|
public void ZeroMaxLogLinesPerRun_IsRejected()
|
|
{
|
|
// Flow Debugger step logs (design 2026-09-09 D7): the per-run cap must be positive.
|
|
var result = Validate(new SiteRuntimeOptions { MaxLogLinesPerRun = 0 });
|
|
|
|
Assert.True(result.Failed);
|
|
Assert.Contains("MaxLogLinesPerRun", result.FailureMessage);
|
|
Assert.Equal(200, new SiteRuntimeOptions().MaxLogLinesPerRun);
|
|
}
|
|
|
|
/// <summary>Verifies that zero max concurrent runs per script is rejected.</summary>
|
|
[Fact]
|
|
public void ZeroMaxConcurrentRunsPerScript_IsRejected()
|
|
{
|
|
var result = Validate(new SiteRuntimeOptions { MaxConcurrentRunsPerScript = 0 });
|
|
|
|
Assert.True(result.Failed);
|
|
Assert.Contains("MaxConcurrentRunsPerScript", result.FailureMessage);
|
|
}
|
|
// --- Lever 1 (2026-08-28): tag-subscribe backoff + in-flight ceiling ---
|
|
|
|
/// <summary>Verifies that tag subscribe retry max interval ms below floor is rejected.</summary>
|
|
[Fact]
|
|
public void TagSubscribeRetryMaxIntervalMs_BelowFloor_IsRejected()
|
|
{
|
|
var result = Validate(new SiteRuntimeOptions
|
|
{
|
|
TagSubscribeRetryIntervalMs = 5000,
|
|
TagSubscribeRetryMaxIntervalMs = 4999,
|
|
});
|
|
|
|
Assert.True(result.Failed);
|
|
Assert.Contains("TagSubscribeRetryMaxIntervalMs", result.FailureMessage);
|
|
}
|
|
|
|
/// <summary>Verifies that tag subscribe retry max interval ms equal to floor is valid.</summary>
|
|
[Fact]
|
|
public void TagSubscribeRetryMaxIntervalMs_EqualToFloor_IsValid()
|
|
{
|
|
var result = Validate(new SiteRuntimeOptions
|
|
{
|
|
TagSubscribeRetryIntervalMs = 5000,
|
|
TagSubscribeRetryMaxIntervalMs = 5000,
|
|
});
|
|
Assert.True(result.Succeeded, result.FailureMessage);
|
|
}
|
|
|
|
/// <summary>Verifies that zero tag subscribe in flight ceiling ms is rejected.</summary>
|
|
[Fact]
|
|
public void ZeroTagSubscribeInFlightCeilingMs_IsRejected()
|
|
{
|
|
var result = Validate(new SiteRuntimeOptions { TagSubscribeInFlightCeilingMs = 0 });
|
|
|
|
Assert.True(result.Failed);
|
|
Assert.Contains("TagSubscribeInFlightCeilingMs", result.FailureMessage);
|
|
}
|
|
|
|
/// <summary>Verifies that script file access defaults to disabled and is valid.</summary>
|
|
[Fact]
|
|
public void ScriptFileAccess_DefaultsToDisabled_AndIsValid()
|
|
{
|
|
var options = new SiteRuntimeOptions();
|
|
Assert.Empty(options.ScriptFileAccess.AllowedRoots);
|
|
Assert.True(Validate(options).Succeeded);
|
|
}
|
|
|
|
/// <summary>Verifies that script file access relative root is rejected.</summary>
|
|
[Fact]
|
|
public void ScriptFileAccess_RelativeRoot_IsRejected()
|
|
{
|
|
var options = new SiteRuntimeOptions();
|
|
options.ScriptFileAccess.AllowedRoots.Add("recipes");
|
|
|
|
var result = Validate(options);
|
|
|
|
Assert.True(result.Failed);
|
|
Assert.Contains("ScriptFileAccess:AllowedRoots:0", result.FailureMessage);
|
|
}
|
|
|
|
/// <summary>Verifies that script file access absolute root is accepted.</summary>
|
|
[Fact]
|
|
public void ScriptFileAccess_AbsoluteRoot_IsAccepted()
|
|
{
|
|
var options = new SiteRuntimeOptions();
|
|
options.ScriptFileAccess.AllowedRoots.Add(Path.GetTempPath());
|
|
Assert.True(Validate(options).Succeeded);
|
|
}
|
|
|
|
/// <summary>Verifies that script file access max file bytes out of range is rejected.</summary>
|
|
/// <param name="max">The max value.</param>
|
|
[Theory]
|
|
[InlineData(0L)]
|
|
[InlineData(64L * 1024 * 1024 + 1)]
|
|
public void ScriptFileAccess_MaxFileBytesOutOfRange_IsRejected(long max)
|
|
{
|
|
var options = new SiteRuntimeOptions();
|
|
options.ScriptFileAccess.MaxFileBytes = max;
|
|
|
|
var result = Validate(options);
|
|
|
|
Assert.True(result.Failed);
|
|
Assert.Contains("ScriptFileAccess:MaxFileBytes", result.FailureMessage);
|
|
}
|
|
|
|
/// <summary>Verifies that zero secret mirror status refresh interval is rejected.</summary>
|
|
[Fact]
|
|
public void ZeroSecretMirrorStatusRefreshInterval_IsRejected()
|
|
{
|
|
var result = Validate(new SiteRuntimeOptions { SecretMirrorStatusRefreshInterval = TimeSpan.Zero });
|
|
|
|
Assert.True(result.Failed);
|
|
Assert.Contains("SecretMirrorStatusRefreshInterval", result.FailureMessage);
|
|
}
|
|
|
|
/// <summary>Verifies that secret mirror status stale after under two refreshes is rejected.</summary>
|
|
[Fact]
|
|
public void SecretMirrorStatusStaleAfter_UnderTwoRefreshes_IsRejected()
|
|
{
|
|
var result = Validate(new SiteRuntimeOptions
|
|
{
|
|
SecretMirrorStatusRefreshInterval = TimeSpan.FromMinutes(10),
|
|
SecretMirrorStatusStaleAfter = TimeSpan.FromMinutes(15),
|
|
});
|
|
|
|
Assert.True(result.Failed);
|
|
Assert.Contains("SecretMirrorStatusStaleAfter", result.FailureMessage);
|
|
}
|
|
}
|