using ZB.MOM.WW.ScadaBridge.SiteRuntime; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// /// WP3.1 test group 5 — the blocking script pool is no longer a fixed 8 threads forever. It /// scales with the number of running instances between a configured floor and ceiling, and it /// is deliberately GROW-ONLY: undeploying instances leaves idle threads (which cost nothing /// measurable) rather than paying for drain/steal complexity. /// public class ScriptPoolSizingTests { private static SiteRuntimeOptions Options(int floor = 8, int ceiling = 32) => new() { ScriptExecutionThreadCount = floor, ScriptExecutionMaxThreadCount = ceiling }; [Theory] // At or below floor * 8 instances the result is exactly the pre-WP3.1 fixed size — // existing configurations are byte-for-byte unchanged in behaviour. [InlineData(0, 8)] [InlineData(1, 8)] [InlineData(64, 8)] // Past that, one thread per 8 instances, rounding up. [InlineData(65, 9)] [InlineData(72, 9)] [InlineData(200, 25)] // …clamped at the ceiling. [InlineData(256, 32)] [InlineData(10_000, 32)] public void ComputeTargetThreads_AppliesFloorRatioAndCeiling(int instances, int expected) => Assert.Equal(expected, ScriptExecutionScheduler.ComputeTargetThreads(instances, Options())); [Fact] public void ComputeTargetThreads_HonoursAnOverriddenFloorAndCeiling() { var options = Options(floor: 2, ceiling: 4); Assert.Equal(2, ScriptExecutionScheduler.ComputeTargetThreads(0, options)); Assert.Equal(2, ScriptExecutionScheduler.ComputeTargetThreads(16, options)); Assert.Equal(3, ScriptExecutionScheduler.ComputeTargetThreads(17, options)); Assert.Equal(4, ScriptExecutionScheduler.ComputeTargetThreads(1000, options)); } [Fact] public void ComputeTargetThreads_NeverReturnsLessThanOne_EvenWithADegenerateFloor() { // The validator rejects these, but a directly-constructed options object must still // not produce a zero-thread scheduler. var options = new SiteRuntimeOptions { ScriptExecutionThreadCount = 0, ScriptExecutionMaxThreadCount = 0 }; Assert.Equal(1, ScriptExecutionScheduler.ComputeTargetThreads(0, options)); Assert.Equal(1, ScriptExecutionScheduler.ComputeTargetThreads(500, options)); } [Fact] public void EnsureCapacity_GrowsOnce_IsIdempotent_AndNeverShrinks() { using var scheduler = new ScriptExecutionScheduler(2); Assert.Equal(2, scheduler.MaximumConcurrencyLevel); Assert.Equal(5, scheduler.EnsureCapacity(5)); Assert.Equal(5, scheduler.MaximumConcurrencyLevel); // Idempotent: asking for the same target again changes nothing. Assert.Equal(5, scheduler.EnsureCapacity(5)); Assert.Equal(5, scheduler.MaximumConcurrencyLevel); // Grow-only: a smaller target is a no-op, not a shrink. Assert.Equal(5, scheduler.EnsureCapacity(1)); Assert.Equal(5, scheduler.MaximumConcurrencyLevel); } [Fact] public async Task EnsureCapacity_WidensTheGauges_SoTheWholePoolIsObservable() { using var scheduler = new ScriptExecutionScheduler(1); scheduler.EnsureCapacity(3); using var gate = new ManualResetEventSlim(false); var blocking = Enumerable.Range(0, 3) .Select(_ => Task.Factory.StartNew(() => gate.Wait(), CancellationToken.None, TaskCreationOptions.None, scheduler)) .ToArray(); // All three grown workers report busy — the bookkeeping widened with the pool. await WaitUntilAsync(() => scheduler.BusyThreadCount == 3); Assert.Equal(0, scheduler.QueueDepth); Assert.NotNull(scheduler.OldestBusyAge); gate.Set(); await Task.WhenAll(blocking); await WaitUntilAsync(() => scheduler.BusyThreadCount == 0); Assert.Null(scheduler.OldestBusyAge); } private static async Task WaitUntilAsync(Func condition) { for (var i = 0; i < 200 && !condition(); i++) await Task.Delay(25); Assert.True(condition(), "condition not met within timeout"); } }