perf(runtime): split trigger evals from the blocking pool; bounded, deadline-aware execution
This commit is contained in:
@@ -20,7 +20,6 @@ public class AlarmsAccessorTests : TestKit, IDisposable
|
||||
{
|
||||
private ScriptRuntimeContext MakeContext(IActorRef instanceActor) =>
|
||||
new(
|
||||
instanceActor,
|
||||
instanceActor,
|
||||
sharedScriptLibrary: null!,
|
||||
currentCallDepth: 0,
|
||||
|
||||
-1
@@ -75,7 +75,6 @@ public class ExecutionCorrelationContextTests
|
||||
compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
|
||||
return new ScriptRuntimeContext(
|
||||
ActorRefs.Nobody,
|
||||
ActorRefs.Nobody,
|
||||
sharedScriptLibrary,
|
||||
currentCallDepth: 0,
|
||||
|
||||
@@ -83,7 +83,6 @@ public class ParentExecutionTreeTests : TestKit
|
||||
{
|
||||
return new ScriptRuntimeContext(
|
||||
instanceActor,
|
||||
ActorRefs.Nobody,
|
||||
library,
|
||||
currentCallDepth: 0,
|
||||
maxCallDepth: 10,
|
||||
|
||||
@@ -32,7 +32,6 @@ public class RecursionLimitSiteEventTests
|
||||
compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||
|
||||
return new ScriptRuntimeContext(
|
||||
ActorRefs.Nobody,
|
||||
ActorRefs.Nobody,
|
||||
sharedScriptLibrary,
|
||||
currentCallDepth: maxCallDepth, // already AT the limit
|
||||
|
||||
@@ -156,7 +156,6 @@ public class AttributeAccessorWaitAsyncTests : TestKit, IDisposable
|
||||
{
|
||||
private ScriptRuntimeContext MakeContext(IActorRef instanceActor) =>
|
||||
new(
|
||||
instanceActor,
|
||||
instanceActor,
|
||||
sharedScriptLibrary: null!,
|
||||
currentCallDepth: 0,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<bool> condition)
|
||||
{
|
||||
for (var i = 0; i < 200 && !condition(); i++)
|
||||
await Task.Delay(25);
|
||||
Assert.True(condition(), "condition not met within timeout");
|
||||
}
|
||||
}
|
||||
+85
-1
@@ -43,7 +43,7 @@ public class SiteScriptCompileCacheTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Overflow_ClearsWholesale()
|
||||
public void Overflow_StaysWithinTheBound()
|
||||
{
|
||||
SiteScriptCompileCache.Clear();
|
||||
for (var i = 0; i <= SiteScriptCompileCache.MaxEntries; i++)
|
||||
@@ -51,4 +51,88 @@ public class SiteScriptCompileCacheTests
|
||||
|
||||
Assert.True(SiteScriptCompileCache.Count <= SiteScriptCompileCache.MaxEntries);
|
||||
}
|
||||
|
||||
// ── WP3.1: approximate LRU replaced the wholesale-Clear overflow cliff ──────────
|
||||
|
||||
/// <summary>
|
||||
/// WP3.1 (test group 8): overflow must evict only the OLDEST batch, keeping hot entries.
|
||||
/// The old behaviour cleared all 1024 entries, so the very next deploy or Instance-Actor
|
||||
/// start paid a full recompile storm on actor threads.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Overflow_EvictsOldestBatch_AndKeepsRecentlyTouchedEntries()
|
||||
{
|
||||
// Note: the cache is process-wide static and other test classes compile into it
|
||||
// concurrently, so the assertions below are phrased as properties of the eviction
|
||||
// policy (what survives, what does not, nothing is wiped) rather than as exact
|
||||
// counts, which no test can own here.
|
||||
SiteScriptCompileCache.Clear();
|
||||
|
||||
// Fill to the bound. Entries 1..N are inserted oldest-first.
|
||||
for (var i = 0; i < SiteScriptCompileCache.MaxEntries; i++)
|
||||
SiteScriptCompileCache.GetOrAdd($"return {i};", typeof(ScriptGlobals), Ok);
|
||||
|
||||
// Touch entry 0 so it carries the NEWEST access stamp despite being inserted first.
|
||||
var hot = SiteScriptCompileCache.GetOrAdd("return 0;", typeof(ScriptGlobals), Ok);
|
||||
var hitsAfterTouch = SiteScriptCompileCache.Hits;
|
||||
|
||||
// Push well past the bound so at least one eviction sweep definitely runs.
|
||||
for (var i = 0; i < 200; i++)
|
||||
SiteScriptCompileCache.GetOrAdd($"return overflow{i};", typeof(ScriptGlobals), Ok);
|
||||
|
||||
// Nothing was cleared wholesale: the cache is still most of the way full and the hit
|
||||
// counter survived (Clear() would have reset it to 0).
|
||||
Assert.True(SiteScriptCompileCache.Count > SiteScriptCompileCache.MaxEntries / 2,
|
||||
$"cache collapsed to {SiteScriptCompileCache.Count} entries — this looks like a wholesale clear");
|
||||
Assert.True(SiteScriptCompileCache.Hits >= hitsAfterTouch);
|
||||
|
||||
// The touched entry survived the sweep — a hit, not a recompile. This is the LRU
|
||||
// property: recency, not insertion order, decides what stays.
|
||||
var hitsBefore = SiteScriptCompileCache.Hits;
|
||||
var again = SiteScriptCompileCache.GetOrAdd("return 0;", typeof(ScriptGlobals),
|
||||
() => throw new InvalidOperationException("hot entry was evicted — LRU is not keeping recently-used entries"));
|
||||
Assert.Same(hot, again);
|
||||
Assert.Equal(hitsBefore + 1, SiteScriptCompileCache.Hits);
|
||||
|
||||
// …and untouched old entries genuinely were evicted, so the sweep really ran.
|
||||
var recomputed = 0;
|
||||
for (var i = 1; i <= 16; i++)
|
||||
{
|
||||
var code = $"return {i};";
|
||||
SiteScriptCompileCache.GetOrAdd(code, typeof(ScriptGlobals),
|
||||
() => { recomputed++; return Ok(); });
|
||||
}
|
||||
Assert.True(recomputed > 0, "no old entry was evicted — the overflow sweep did not run");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP3.1: concurrent inserts crossing the bound together must not blow past it — the
|
||||
/// eviction sweep is double-checked under its own lock precisely so a stampede performs
|
||||
/// one sweep rather than one per racing thread.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ConcurrentGetOrAddStorm_KeepsTheBound()
|
||||
{
|
||||
SiteScriptCompileCache.Clear();
|
||||
|
||||
const int perTask = 400;
|
||||
var tasks = Enumerable.Range(0, 8).Select(t => Task.Run(() =>
|
||||
{
|
||||
for (var i = 0; i < perTask; i++)
|
||||
SiteScriptCompileCache.GetOrAdd($"return {t}_{i};", typeof(ScriptGlobals), Ok);
|
||||
}));
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// A small transient overshoot is by design: threads that lose the double-checked
|
||||
// eviction race still insert their own entry afterwards, so the ceiling is "bounded",
|
||||
// not "never exceeded by one". The property under test is that 3200 distinct inserts
|
||||
// do not accumulate — before WP3.1 this was a wholesale Clear(), and a per-entry
|
||||
// eviction bug would show up here as unbounded growth, not as a handful of extra rows.
|
||||
// (Other test classes compile into this process-wide cache concurrently, which is the
|
||||
// other reason the bound is asserted with slack rather than exactly.)
|
||||
Assert.True(SiteScriptCompileCache.Count <= SiteScriptCompileCache.MaxEntries + 512,
|
||||
$"cache grew past its bound: {SiteScriptCompileCache.Count} vs max {SiteScriptCompileCache.MaxEntries} " +
|
||||
$"after {8 * perTask} distinct inserts");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user