197 lines
9.5 KiB
C#
197 lines
9.5 KiB
C#
using System.Diagnostics;
|
|
using Akka.Actor;
|
|
using Akka.TestKit.Xunit2;
|
|
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
|
using Microsoft.CodeAnalysis.Scripting;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
|
|
|
/// <summary>
|
|
/// WP3.1 test groups 1 and 2 — the regression pin for arch-review finding #4 (High).
|
|
///
|
|
/// <para><b>The finding.</b> Trigger-expression evaluation used to be queued onto the same
|
|
/// fixed 8-thread <see cref="ScriptExecutionScheduler"/> that runs blocking script bodies. Eight
|
|
/// scripts blocked in synchronous I/O therefore stalled EVERY Expression trigger on the node —
|
|
/// scripts and alarms alike — for an unbounded time. Worse, the evaluation's 2 s timeout was
|
|
/// constructed INSIDE the queued body, so it did not start until the evaluation was dequeued:
|
|
/// the operator saw neither a raise nor a timeout, just silence.</para>
|
|
///
|
|
/// <para><b>The fix these tests pin.</b> Evaluations are non-blocking by construction
|
|
/// (<see cref="TriggerExpressionGlobals"/> exposes only reads over an in-memory snapshot, and
|
|
/// the trust gate has already denied I/O), so they run as plain async work on the shared .NET
|
|
/// thread pool behind <see cref="TriggerEvalGate"/> — never on the blocking pool. And their
|
|
/// deadline is armed at ENQUEUE, so gate-wait time burns the same budget.</para>
|
|
/// </summary>
|
|
public class TriggerEvalStarvationTests : TestKit, IDisposable
|
|
{
|
|
private readonly SharedScriptLibrary _sharedLibrary;
|
|
|
|
public TriggerEvalStarvationTests()
|
|
{
|
|
var compilationService = new ScriptCompilationService(
|
|
NullLogger<ScriptCompilationService>.Instance);
|
|
_sharedLibrary = new SharedScriptLibrary(
|
|
compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
|
StarvationHooks.Gate = new SemaphoreSlim(0);
|
|
}
|
|
|
|
void IDisposable.Dispose()
|
|
{
|
|
StarvationHooks.Gate.Release(32);
|
|
Shutdown();
|
|
}
|
|
|
|
private static Script<object?> CompileRaw(string code)
|
|
{
|
|
var options = ScriptOptions.Default
|
|
.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly,
|
|
typeof(StarvationHooks).Assembly)
|
|
.WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");
|
|
var script = CSharpScript.Create<object?>(code, options, typeof(ScriptGlobals));
|
|
script.Compile();
|
|
return script;
|
|
}
|
|
|
|
private static Script<object?> CompileTriggerExpression(string expression)
|
|
{
|
|
var options = ScriptOptions.Default
|
|
.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly)
|
|
.WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");
|
|
var script = CSharpScript.Create<object?>(expression, options, typeof(TriggerExpressionGlobals));
|
|
script.Compile();
|
|
return script;
|
|
}
|
|
|
|
/// <summary>
|
|
/// GROUP 1 — the finding-#4 regression pin. Eight script bodies blocked on a semaphore
|
|
/// occupy every thread of an 8-thread pool; an Expression-triggered alarm must still raise
|
|
/// in well under 2 s.
|
|
///
|
|
/// <para>On the pre-WP3.1 wiring this test does not merely fail slowly — it never
|
|
/// completes: the evaluation sits in the same FIFO behind eight bodies that only unblock
|
|
/// after the assertion window, and its 2 s timeout has not even started ticking.</para>
|
|
/// </summary>
|
|
[Fact]
|
|
public void EightBlockedScripts_DoNotDelayAnAlarmsExpressionEvaluation()
|
|
{
|
|
using var scheduler = new ScriptExecutionScheduler(8);
|
|
using var evalGate = new TriggerEvalGate(4);
|
|
|
|
// Occupy every thread of the blocking pool with a real script run.
|
|
var wedge = CompileRaw(
|
|
"ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors.StarvationHooks.Gate.Wait(); return null;");
|
|
for (var i = 0; i < 8; i++)
|
|
{
|
|
var name = $"Blocker{i}";
|
|
var config = new ResolvedScript { CanonicalName = name, TriggerType = "Call" };
|
|
var instance = CreateTestProbe().Ref;
|
|
var actor = ActorOf(Props.Create(() => new ScriptActor(
|
|
name, "Inst1", instance, wedge, config, _sharedLibrary, new SiteRuntimeOptions(),
|
|
NullLogger<ScriptActor>.Instance, null, null, null, null, scheduler, evalGate)));
|
|
actor.Tell(new ScriptCallRequest(name, null, 0, $"corr-block-{i}"), ActorRefs.NoSender);
|
|
}
|
|
|
|
AwaitAssert(
|
|
() => Assert.Equal(8, scheduler.BusyThreadCount),
|
|
TimeSpan.FromSeconds(15));
|
|
|
|
// Now fire an Expression-triggered alarm through the SAME scheduler seam.
|
|
var instanceProbe = CreateTestProbe();
|
|
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
|
"ExprAlarm", "Inst1", instanceProbe.Ref,
|
|
new ResolvedAlarm
|
|
{
|
|
CanonicalName = "ExprAlarm",
|
|
TriggerType = "Expression",
|
|
TriggerConfiguration = "{\"expression\":\"true\"}",
|
|
PriorityLevel = 900
|
|
},
|
|
null, _sharedLibrary, new SiteRuntimeOptions(), NullLogger<AlarmActor>.Instance,
|
|
CompileTriggerExpression("true"), null, null, null, null, scheduler, evalGate)));
|
|
|
|
var stopwatch = Stopwatch.StartNew();
|
|
alarm.Tell(new AttributeValueChanged(
|
|
"Inst1", "Temp", "Temp", 99.0, "Good", DateTimeOffset.UtcNow));
|
|
|
|
var raised = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(2));
|
|
stopwatch.Stop();
|
|
|
|
Assert.Equal(AlarmState.Active, raised.State);
|
|
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(2),
|
|
$"alarm raised only after {stopwatch.Elapsed} — the evaluation queued behind blocked script bodies");
|
|
|
|
// The pool really was saturated for the whole window; the eval simply never used it.
|
|
Assert.Equal(8, scheduler.BusyThreadCount);
|
|
}
|
|
|
|
/// <summary>
|
|
/// GROUP 2 — the evaluation deadline is measured from ENQUEUE, so time spent waiting on a
|
|
/// saturated <see cref="TriggerEvalGate"/> burns the same budget. With the gate fully
|
|
/// occupied for the whole assertion window, a queued evaluation must still resolve (as
|
|
/// false) at its timeout rather than stalling indefinitely — and the trigger must drain
|
|
/// rather than park, so the next change still evaluates.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task SaturatedEvalGate_ResolvesTheQueuedEvaluationAtItsEnqueueAnchoredDeadline()
|
|
{
|
|
using var scheduler = new ScriptExecutionScheduler(1);
|
|
using var evalGate = new TriggerEvalGate(1);
|
|
var options = new SiteRuntimeOptions { TriggerEvalTimeoutSeconds = 1 };
|
|
|
|
var instanceProbe = CreateTestProbe();
|
|
var alarm = ActorOf(Props.Create(() => new AlarmActor(
|
|
"ExprAlarm", "Inst1", instanceProbe.Ref,
|
|
new ResolvedAlarm
|
|
{
|
|
CanonicalName = "ExprAlarm",
|
|
TriggerType = "Expression",
|
|
TriggerConfiguration = "{\"expression\":\"true\"}",
|
|
PriorityLevel = 500
|
|
},
|
|
null, _sharedLibrary, options, NullLogger<AlarmActor>.Instance,
|
|
CompileTriggerExpression("true"), null, null, null, null, scheduler, evalGate)));
|
|
|
|
// 1. Free gate: the expression evaluates true and the alarm raises.
|
|
alarm.Tell(new AttributeValueChanged("Inst1", "A", "A", 1, "Good", DateTimeOffset.UtcNow));
|
|
var raised = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(10));
|
|
Assert.Equal(AlarmState.Active, raised.State);
|
|
|
|
// 2. Occupy the only permit for the whole of the next step.
|
|
await evalGate.WaitAsync(CancellationToken.None);
|
|
Assert.Equal(0, evalGate.AvailablePermits);
|
|
|
|
// 3. The next evaluation can never acquire the gate. Its deadline was armed at
|
|
// enqueue, so it cancels at ~1 s, is treated as false, and clears the alarm.
|
|
// Pre-WP3.1 the clock started at dequeue, so this would hang forever.
|
|
var stopwatch = Stopwatch.StartNew();
|
|
alarm.Tell(new AttributeValueChanged("Inst1", "A", "A", 2, "Good", DateTimeOffset.UtcNow));
|
|
var cleared = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(10));
|
|
stopwatch.Stop();
|
|
|
|
Assert.Equal(AlarmState.Normal, cleared.State);
|
|
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(6),
|
|
$"queued evaluation took {stopwatch.Elapsed} to resolve — the deadline is not enqueue-anchored");
|
|
Assert.Equal(0, evalGate.AvailablePermits); // still held: it genuinely never ran
|
|
|
|
// 4. Not parked: releasing the gate and sending another change evaluates again.
|
|
evalGate.Release();
|
|
alarm.Tell(new AttributeValueChanged("Inst1", "A", "A", 3, "Good", DateTimeOffset.UtcNow));
|
|
var reRaised = instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(10));
|
|
Assert.Equal(AlarmState.Active, reRaised.State);
|
|
}
|
|
}
|
|
|
|
/// <summary>Test hook used to block script-execution worker threads deterministically.</summary>
|
|
public static class StarvationHooks
|
|
{
|
|
/// <summary>Gate the blocking test scripts wait on; reset per test.</summary>
|
|
public static SemaphoreSlim Gate = new(0);
|
|
}
|