9fb52153fd
Deferred flake-pattern sweep of tests/ for the class fixed inc4caebe9andcfa6acbf— a bounded wait on observable A followed by a bare assert on an observable B that the product only reaches strictly after A. Three clear instances, each reproduced deterministically by delaying only the later step and each re-verified green with that same delay still injected. AlarmOnTriggerRuns_ShedAtTheSameCap_WithAnAlarmScopedSiteEvent gated on the rate-limited shed site event and then asserted the shed COUNT bare. AlarmActor.ShedAlarmRun increments the counter and only then emits the event, and the event fires on the first shed only — so the gate observed Flap(4)'s shed and ordered nothing with respect to Flap(5)'s, which is a separate mailbox message with no observable of its own (an alarm on-trigger run has no Ask caller to reply to, unlike ScriptActor.ShedRun, whose sibling test is correctly ordered by its ScriptCallResult and is left alone). Deferring Flap(5) by 2 s failed it with "Expected: 2 / Actual: 1". The count is now ACCUMULATED across polls rather than re-read, because SiteHealthCollector.CollectReport DRAINS the interval counters — a poll loop that simply re-read it would consume the first shed and never reach 2. EndToEnd_GrpcStubError_RowStays_Pending_NextTick_Succeeds gated on the central row arriving and then asserted bare that the site SQLite row had left Pending. SiteAuditTelemetryActor pushes via IngestAuditEventsAsync (which is what writes the central row) and calls MarkForwardedAsync only after parsing the ack. Delaying just that post-push step failed it with "Assert.DoesNotContain() Failure: Filter matched in collection". PreSnapshotBuffer_IsCapped_DropsOldest_AndCountsTheDrops gated on "Count >= cap" and then asserted "Count == cap + 1" bare — a gate strictly weaker than the assertion it guards, so it ordered nothing with respect to the last event of a FlushBuffer loop that delivers one at a time. Parking that loop after its 19,999th delivery failed it with "Expected: 20001 / Actual: 20000". Also hardens GrpcCentralTransportTests.WaitUntil, which returned silently on timeout; today's single caller re-asserts immediately, so this only sharpens the message rather than fixing a live flake. Cleared with evidence, not guessed: SiteAlarmLiveCacheService's LingerStop removes the site entry inside one lock, so IsLive and GetCurrentAlarms flip atomically; and SiteReconciliationActor walks response.Gap with a sequential foreach in which the asserted "Gone" log precedes the awaited "Good" row, the inverse of this class. Test-only; every ordering named above is correct as written.
224 lines
10 KiB
C#
224 lines
10 KiB
C#
using Akka.Actor;
|
|
using Akka.TestKit;
|
|
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.Flattening;
|
|
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
|
|
|
/// <summary>
|
|
/// WP3.1 test group 6 — the per-script in-flight cap and its shed policy.
|
|
///
|
|
/// <para>Before WP3.1 every trigger spawned another run unconditionally: a trigger firing
|
|
/// faster than its script completes produced unbounded fan-out onto a bounded thread pool.
|
|
/// The cap (<see cref="SiteRuntimeOptions.MaxConcurrentRunsPerScript"/>, default 4) sheds the
|
|
/// NEWEST run instead. Keeping the four already queued/running — which are closest to their
|
|
/// own deadlines and already charged against them — is the policy that never reorders runs
|
|
/// and needs no queue at all: the scheduler's FIFO already IS the queue.</para>
|
|
///
|
|
/// <para>A shed is always counted on the health collector, emits a site event rate-limited to
|
|
/// one per script per minute (so a hot trigger cannot flood <c>site_events</c>), and — for an
|
|
/// Ask-based <c>CallScript</c> — replies with an explicit error rather than letting a nested
|
|
/// call or inbound-API route hang to its Ask timeout.</para>
|
|
/// </summary>
|
|
public class ScriptRunShedTests : TestKit, IDisposable
|
|
{
|
|
private readonly SharedScriptLibrary _sharedLibrary;
|
|
private readonly ScriptExecutionScheduler _scheduler = new(8);
|
|
|
|
public ScriptRunShedTests()
|
|
{
|
|
var compilationService = new ScriptCompilationService(
|
|
NullLogger<ScriptCompilationService>.Instance);
|
|
_sharedLibrary = new SharedScriptLibrary(
|
|
compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
|
ShedHooks.Gate = new SemaphoreSlim(0);
|
|
}
|
|
|
|
void IDisposable.Dispose()
|
|
{
|
|
ShedHooks.Gate.Release(64);
|
|
Shutdown();
|
|
_scheduler.Dispose();
|
|
}
|
|
|
|
private static Script<object?> BlockingScript() => CompileRaw(
|
|
"ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors.ShedHooks.Gate.Wait(); return null;");
|
|
|
|
private static Script<object?> CompileRaw(string code)
|
|
{
|
|
var options = ScriptOptions.Default
|
|
.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly,
|
|
typeof(ShedHooks).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 SiteRuntimeOptions Options() => new()
|
|
{
|
|
MaxConcurrentRunsPerScript = 4,
|
|
// Long enough that nothing times out inside the test window — the cap, not the
|
|
// deadline, must be what refuses the fifth run.
|
|
ScriptExecutionTimeoutSeconds = 120,
|
|
StuckScriptGraceMs = 120_000
|
|
};
|
|
|
|
[Fact]
|
|
public void FifthConcurrentRun_IsShed_Counted_EventedOnce_AndAnsweredWithAnError()
|
|
{
|
|
var siteLog = new FakeSiteEventLogger();
|
|
var health = new SiteHealthCollector();
|
|
var instance = CreateTestProbe().Ref;
|
|
var options = Options();
|
|
|
|
var actor = ActorOfAsTestActorRef<ScriptActor>(
|
|
Props.Create(() => new ScriptActor(
|
|
"Hot", "Inst1", instance, BlockingScript(),
|
|
new ResolvedScript { CanonicalName = "Hot", TriggerType = "Call" },
|
|
_sharedLibrary, options, NullLogger<ScriptActor>.Instance,
|
|
null, null, health, new SingleServiceProvider(siteLog), _scheduler, null)),
|
|
"shed-" + Guid.NewGuid().ToString("N"));
|
|
|
|
// Fill the cap: four runs, all blocked in their bodies.
|
|
for (var i = 0; i < 4; i++)
|
|
actor.Tell(new ScriptCallRequest("Hot", null, 0, $"corr-{i}"), ActorRefs.NoSender);
|
|
|
|
AwaitAssert(() =>
|
|
{
|
|
Assert.Equal(4, actor.UnderlyingActor.RunsInFlight);
|
|
Assert.Equal(4, _scheduler.BusyThreadCount);
|
|
}, TimeSpan.FromSeconds(15));
|
|
|
|
// Fifth: shed. The Ask caller is answered explicitly instead of hanging.
|
|
var caller = CreateTestProbe();
|
|
actor.Tell(new ScriptCallRequest("Hot", null, 0, "corr-shed-1"), caller.Ref);
|
|
|
|
var result = caller.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
|
|
Assert.False(result.Success);
|
|
Assert.Equal("corr-shed-1", result.CorrelationId);
|
|
Assert.Contains("shed", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
|
|
Assert.Contains("in flight", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
|
|
|
|
// Still exactly four in flight — the shed run was never launched.
|
|
Assert.Equal(4, actor.UnderlyingActor.RunsInFlight);
|
|
|
|
// Sixth: counted again, but the Warning site event is rate-limited to one per minute.
|
|
actor.Tell(new ScriptCallRequest("Hot", null, 0, "corr-shed-2"), caller.Ref);
|
|
var second = caller.ExpectMsg<ScriptCallResult>(TimeSpan.FromSeconds(10));
|
|
Assert.False(second.Success);
|
|
|
|
AwaitAssert(() =>
|
|
{
|
|
var shedEvents = siteLog.OfType("script")
|
|
.Where(r => r.Severity == "Warning" && r.Message.Contains("shed"))
|
|
.ToArray();
|
|
Assert.Single(shedEvents);
|
|
Assert.Equal("ScriptActor:Hot", shedEvents[0].Source);
|
|
Assert.Equal("Inst1", shedEvents[0].InstanceId);
|
|
}, TimeSpan.FromSeconds(5));
|
|
|
|
// Both sheds were counted on the health report even though only one was evented.
|
|
Assert.Equal(2, health.CollectReport("site-1").ScriptRunShedCount);
|
|
|
|
// One completion frees a slot, and the next trigger launches again.
|
|
ShedHooks.Gate.Release();
|
|
AwaitAssert(() => Assert.Equal(3, actor.UnderlyingActor.RunsInFlight), TimeSpan.FromSeconds(15));
|
|
|
|
actor.Tell(new ScriptCallRequest("Hot", null, 0, "corr-after"), ActorRefs.NoSender);
|
|
AwaitAssert(() => Assert.Equal(4, actor.UnderlyingActor.RunsInFlight), TimeSpan.FromSeconds(15));
|
|
}
|
|
|
|
[Fact]
|
|
public void AlarmOnTriggerRuns_ShedAtTheSameCap_WithAnAlarmScopedSiteEvent()
|
|
{
|
|
var siteLog = new FakeSiteEventLogger();
|
|
var health = new SiteHealthCollector();
|
|
var instanceProbe = CreateTestProbe();
|
|
var options = Options();
|
|
|
|
var alarm = ActorOfAsTestActorRef<AlarmActor>(
|
|
Props.Create(() => new AlarmActor(
|
|
"Flapper", "Inst1", instanceProbe.Ref,
|
|
new ResolvedAlarm
|
|
{
|
|
CanonicalName = "Flapper",
|
|
TriggerType = "ValueMatch",
|
|
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}",
|
|
PriorityLevel = 100
|
|
},
|
|
BlockingScript(), _sharedLibrary, options, NullLogger<AlarmActor>.Instance,
|
|
null, null, health, new SingleServiceProvider(siteLog), null, _scheduler, null)),
|
|
"alarm-shed-" + Guid.NewGuid().ToString("N"));
|
|
|
|
// Each raise edge spawns one on-trigger run; clear between raises to re-arm the edge.
|
|
void Flap(int cycle)
|
|
{
|
|
alarm.Tell(new AttributeValueChanged(
|
|
"Inst1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow.AddSeconds(cycle)));
|
|
alarm.Tell(new AttributeValueChanged(
|
|
"Inst1", "Status", "Status", "Normal", "Good", DateTimeOffset.UtcNow.AddSeconds(cycle)));
|
|
}
|
|
|
|
for (var i = 0; i < 4; i++) Flap(i);
|
|
AwaitAssert(() => Assert.Equal(4, alarm.UnderlyingActor.RunsInFlight), TimeSpan.FromSeconds(15));
|
|
|
|
// Fifth raise is shed — there is no Ask caller on this path, so it surfaces purely as
|
|
// a counter plus the rate-limited Warning event.
|
|
Flap(4);
|
|
Flap(5);
|
|
|
|
// The shed COUNT assertion lives inside the awaited block, not after it.
|
|
// ShedAlarmRun increments the counter and only then emits the (rate-limited)
|
|
// site event, and that event fires on the FIRST shed only — so an awaited
|
|
// gate on the event observes Flap(4)'s shed and orders nothing whatsoever
|
|
// with respect to Flap(5)'s, which is a separate mailbox message with no
|
|
// observable of its own on this path (an alarm on-trigger run has no Ask
|
|
// caller to reply to — AlarmActor.ShedAlarmRun). Asserting the count bare
|
|
// after the gate assumed both sheds had been dequeued by the time the
|
|
// first one's event landed; under load the second can still be pending and
|
|
// the count reads 1. Reproduced deterministically by deferring Flap(5) by
|
|
// 2 s: the bare form failed with "Expected: 2 / Actual: 1", while the
|
|
// ScriptActor sibling above — which IS ordered, because ShedRun replies
|
|
// ScriptCallResult after incrementing — kept passing.
|
|
//
|
|
// The count is ACCUMULATED across polls rather than re-read, because
|
|
// SiteHealthCollector.CollectReport DRAINS the interval counters
|
|
// (Interlocked.Exchange(ref _scriptRunShedCount, 0)) — a poll loop that
|
|
// simply re-read it would consume the first shed and never see 2.
|
|
var shedCounted = 0;
|
|
AwaitAssert(() =>
|
|
{
|
|
shedCounted += health.CollectReport("site-1").ScriptRunShedCount;
|
|
|
|
var shedEvents = siteLog.OfType("script")
|
|
.Where(r => r.Severity == "Warning" && r.Message.Contains("shed"))
|
|
.ToArray();
|
|
|
|
// Both sheds counted, even though the event is rate-limited to one.
|
|
Assert.Equal(2, shedCounted);
|
|
Assert.Single(shedEvents);
|
|
Assert.Equal("AlarmActor:Flapper", shedEvents[0].Source);
|
|
|
|
// ...and neither shed run was ever launched: still exactly four in flight.
|
|
Assert.Equal(4, alarm.UnderlyingActor.RunsInFlight);
|
|
}, TimeSpan.FromSeconds(10));
|
|
}
|
|
}
|
|
|
|
/// <summary>Test hook used to hold script runs in flight while the cap is exercised.</summary>
|
|
public static class ShedHooks
|
|
{
|
|
/// <summary>Gate the blocking test scripts wait on; reset per test.</summary>
|
|
public static SemaphoreSlim Gate = new(0);
|
|
}
|