diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs index 041681a5..41447dc5 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmActor.cs @@ -571,7 +571,20 @@ public class AlarmActor : ReceiveActor /// Passes the firing alarm's level/priority/message so the script can /// branch on severity via the Alarm global. /// - private void SpawnAlarmExecution(AlarmLevel level, int priority, string message) + /// The firing alarm severity level. + /// The firing alarm priority. + /// The firing alarm message. + /// + /// Audit Log #23 (M5.4 — ParentExecutionId tag-cascade): the execution id of + /// the context that fired this alarm, recorded as the on-trigger script run's + /// ParentExecutionId so the alarm-triggered run chains under its firing + /// context in the audit tree. The alarm subsystem currently has no Guid-typed + /// firing id, so the only call sites pass null (the on-trigger run is a + /// root). The parameter exists so a future firing-id can flow without + /// touching the actor wiring. + /// + private void SpawnAlarmExecution( + AlarmLevel level, int priority, string message, Guid? parentExecutionId = null) { if (_onTriggerCompiledScript == null) return; @@ -591,7 +604,9 @@ public class AlarmActor : ReceiveActor _options, _logger, // M2.5 (#9): per-script timeout from the on-trigger script (null = global). - _onTriggerExecutionTimeoutSeconds)); + _onTriggerExecutionTimeoutSeconds, + // Audit Log #23 (M5.4): the firing context's execution id (null today). + parentExecutionId)); Context.ActorOf(props, executionId); } diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmExecutionActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmExecutionActor.cs index 623457e3..e8fa34b6 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmExecutionActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/AlarmExecutionActor.cs @@ -29,6 +29,14 @@ public class AlarmExecutionActor : ReceiveActor /// Site runtime configuration options, including the execution timeout. /// Logger for execution diagnostics. /// M2.5 (#9): the on-trigger script's per-script execution timeout in seconds. Null or non-positive falls back to the global . + /// + /// Audit Log #23 (M5.4 — ParentExecutionId tag-cascade): the execution id of + /// the context that fired this alarm, threaded into the on-trigger script's + /// as its ParentExecutionId so the + /// alarm-triggered run chains under its firing context. Null today (no + /// Guid-typed firing id exists yet) — the run is a root, but the plumbing + /// is in place for a future firing id. + /// public AlarmExecutionActor( string alarmName, string instanceName, @@ -42,7 +50,9 @@ public class AlarmExecutionActor : ReceiveActor ILogger logger, // M2.5 (#9): per-script execution timeout override (seconds) for the // alarm on-trigger script. Null or non-positive falls back to the global. - int? executionTimeoutSeconds = null) + int? executionTimeoutSeconds = null, + // Audit Log #23 (M5.4): the firing context's execution id (null today). + Guid? parentExecutionId = null) { var self = Self; var parent = Context.Parent; @@ -51,7 +61,7 @@ public class AlarmExecutionActor : ReceiveActor alarmName, instanceName, level, priority, message, compiledScript, instanceActor, sharedScriptLibrary, options, self, parent, logger, - executionTimeoutSeconds); + executionTimeoutSeconds, parentExecutionId); } private static void ExecuteAlarmScript( @@ -67,7 +77,8 @@ public class AlarmExecutionActor : ReceiveActor IActorRef self, IActorRef parent, ILogger logger, - int? executionTimeoutSeconds) + int? executionTimeoutSeconds, + Guid? parentExecutionId) { // M2.5 (#9): per-script timeout overrides the global default. A null or // non-positive per-script value (≤ 0) falls back to the global. @@ -95,7 +106,14 @@ public class AlarmExecutionActor : ReceiveActor options.MaxScriptCallDepth, timeout, instanceName, - logger); + logger, + // Audit Log #23 (M5.4 — ParentExecutionId tag-cascade): the + // alarm on-trigger run mints its own fresh ExecutionId (the + // ctor's `?? NewGuid()` fallback) and records the firing + // context's id as its ParentExecutionId — null today, so the + // run is a root, but the plumbing exists for a future + // firing id. + parentExecutionId: parentExecutionId); var globals = new ScriptGlobals { diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs index f8b2d4ab..6e214e86 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Scripts/ScriptRuntimeContext.cs @@ -247,6 +247,59 @@ public class ScriptRuntimeContext _siteEventLogger = siteEventLogger; } + /// + /// Audit Log #23 (M5.4): this run's own per-execution id. Exposed so a + /// nested Scripts.CallShared can record it as the spawned shared + /// script's ParentExecutionId, forming a true execution tree. + /// + internal Guid ExecutionId => _executionId; + + /// + /// Audit Log #23 (M5.4): the spawning execution's id for this run (null for + /// a root run). Exposed for test assertions on the execution tree. + /// + internal Guid? ParentExecutionId => _parentExecutionId; + + /// + /// Audit Log #23 (M5.4 — ParentExecutionId tag-cascade): builds a child + /// for an inline Scripts.CallShared + /// invocation. The shared script runs inline (no actor hop) but is modelled + /// as its OWN execution node in the audit tree: it mints a fresh + /// and records THIS run's + /// as its ParentExecutionId, so B → CallShared(C) yields + /// C.ParentExecutionId == B.ExecutionId. Every other dependency + /// (actors, gateways, audit writer, site id, source node, call-depth) is + /// carried over verbatim from this context. + /// + /// The recursion depth of the shared-script call. + internal ScriptRuntimeContext CreateChildContextForSharedScript(int childCallDepth) + { + return new ScriptRuntimeContext( + _instanceActor, + _self, + _sharedScriptLibrary, + childCallDepth, + _maxCallDepth, + _askTimeout, + _instanceName, + _logger, + _externalSystemClient, + _databaseGateway, + _storeAndForward, + _siteCommunicationActor, + _siteId, + _sourceScript, + _auditWriter, + _operationTrackingStore, + _cachedForwarder, + // Fresh execution id for the shared-script run (omit so the ctor mints one)… + executionId: null, + // …parented to THIS run's execution id (the spawner). + parentExecutionId: _executionId, + sourceNode: _sourceNode, + siteEventLogger: _siteEventLogger); + } + /// /// M2.12 (#25): fire-and-forget emission of a script Error site event /// for a recursion-limit violation. Mirrors the call shape used by @@ -366,7 +419,14 @@ public class ScriptRuntimeContext scriptName, ScriptArgs.Normalize(parameters), nextDepth, - correlationId); + correlationId, + // Audit Log #23 (M5.4 — ParentExecutionId tag-cascade): the child + // script run is a NEW execution spawned BY this run. Its parent is + // THIS run's own ExecutionId — NOT the inherited _parentExecutionId. + // So A → CallScript(B) yields B.ParentExecutionId == A.ExecutionId, + // building a true multi-level execution tree rather than flattening + // every nested call under the original inbound spawner. + ParentExecutionId: _executionId); // Ask the Instance Actor, which routes to the appropriate Script Actor var result = await _instanceActor.Ask(request, _askTimeout); @@ -526,8 +586,14 @@ public class ScriptRuntimeContext throw new InvalidOperationException(msg); } + // Audit Log #23 (M5.4 — ParentExecutionId tag-cascade): the shared + // script runs inline, but is modelled as its OWN execution node — a + // child context mints a fresh ExecutionId parented to the caller's + // ExecutionId, so its audit rows chain under the calling run. + var childContext = _context.CreateChildContextForSharedScript(nextDepth); + return await _library.ExecuteAsync( - scriptName, _context, ScriptArgs.Normalize(parameters), cancellationToken); + scriptName, childContext, ScriptArgs.Normalize(parameters), cancellationToken); } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ParentExecutionTreeTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ParentExecutionTreeTests.cs new file mode 100644 index 00000000..28345670 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/ParentExecutionTreeTests.cs @@ -0,0 +1,291 @@ +using Akka.Actor; +using Akka.TestKit.Xunit2; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using ZB.MOM.WW.Audit; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; +using IAuditWriter = ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services.IAuditWriter; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; + +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; + +/// +/// Audit Log #23 (M5.4 — ParentExecutionId tag-cascade): nested +/// CallScript / CallShared invocations and alarm on-trigger runs +/// must form a true execution tree, where each spawned run records its +/// immediate spawner's ExecutionId as its ParentExecutionId. +/// +/// +/// +/// A nested CallScript (actor-routed) emits a +/// whose ParentExecutionId is the +/// CALLING run's OWN ExecutionId — NOT the inherited grandparent — so +/// A → CallScript(B) yields B.Parent == A.ExecutionId. +/// +/// +/// A nested CallShared (inline) runs in a child context that mints a +/// fresh ExecutionId and records the caller's ExecutionId as its +/// parent — so B → CallShared(C) yields C.Parent == B.ExecutionId +/// (and NOT B's inherited parent A), proving a multi-level tree. +/// +/// +/// The alarm on-trigger plumbing carries a parentExecutionId into the +/// script context — null today (the run is a root) but threaded so a future +/// firing id can flow. +/// +/// +/// +public class ParentExecutionTreeTests : TestKit +{ + private const string InstanceName = "Plant.Pump42"; + + /// + /// In-memory capturing every emitted event + /// (mirrors ExecutionCorrelationContextTests.CapturingAuditWriter). + /// + private sealed class CapturingAuditWriter : IAuditWriter + { + public List Events { get; } = new(); + + public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) + { + Events.Add(evt.AsRow()); + return Task.CompletedTask; + } + } + + private static SharedScriptLibrary NewLibrary() + { + var compilationService = new ScriptCompilationService( + NullLogger.Instance); + return new SharedScriptLibrary( + compilationService, NullLogger.Instance); + } + + /// + /// Builds a context whose CallScript Ask targets + /// (a probe), so the forwarded can be captured. + /// + private static ScriptRuntimeContext CreateContext( + IActorRef instanceActor, + SharedScriptLibrary library, + IExternalSystemClient? externalSystemClient = null, + IAuditWriter? auditWriter = null, + Guid? executionId = null, + Guid? parentExecutionId = null) + { + return new ScriptRuntimeContext( + instanceActor, + ActorRefs.Nobody, + library, + currentCallDepth: 0, + maxCallDepth: 10, + askTimeout: TimeSpan.FromSeconds(5), + instanceName: InstanceName, + logger: NullLogger.Instance, + externalSystemClient: externalSystemClient, + siteId: "site-77", + sourceScript: "ScriptActor:A", + auditWriter: auditWriter, + executionId: executionId, + parentExecutionId: parentExecutionId); + } + + // ------------------------------------------------------------------------- + // Nested CallScript (actor-routed) — A → CallScript(B) + // ------------------------------------------------------------------------- + + [Fact] + public async Task CallScript_StampsCallingRunsOwnExecutionId_AsChildParent() + { + // A → CallScript(B): the child request's ParentExecutionId must be A's + // OWN ExecutionId, forming the A→B tree edge. + var probe = CreateTestProbe(); + var aExecutionId = Guid.NewGuid(); + var context = CreateContext(probe.Ref, NewLibrary(), executionId: aExecutionId); + + var call = context.CallScript("B"); + + var request = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("B", request.ScriptName); + // B's parent is A's own execution id — the A→B tree edge. + Assert.Equal(aExecutionId, request.ParentExecutionId); + + // Unblock the Ask so the test completes cleanly. + probe.Reply(new ScriptCallResult(request.CorrelationId, true, null, null)); + await call; + } + + [Fact] + public async Task CallScript_FromRoutedRun_UsesOwnExecutionId_NotInheritedParent() + { + // A 2-level tree edge: B was itself spawned (it carries a parent = A). + // When B does CallScript(C), C.Parent must be B's OWN ExecutionId — NOT + // the inherited A. This is the regression that distinguishes a true tree + // from a flattened "everything under the original spawner" model. + var probe = CreateTestProbe(); + var bExecutionId = Guid.NewGuid(); + var aExecutionId = Guid.NewGuid(); // B's inherited parent + var context = CreateContext( + probe.Ref, NewLibrary(), + executionId: bExecutionId, + parentExecutionId: aExecutionId); + + var call = context.CallScript("C"); + + var request = probe.ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal(bExecutionId, request.ParentExecutionId); + Assert.NotEqual(aExecutionId, request.ParentExecutionId); + + probe.Reply(new ScriptCallResult(request.CorrelationId, true, null, null)); + await call; + } + + // ------------------------------------------------------------------------- + // Nested CallShared (inline) — B → CallShared(C) + // ------------------------------------------------------------------------- + + [Fact] + public async Task CallShared_ChildRun_ParentIsCallersExecutionId_FreshOwnExecutionId() + { + // B → CallShared(C): the shared script C runs inline but is modelled as + // its OWN execution node — a fresh ExecutionId parented to B's + // ExecutionId. Asserted via the audit row C emits through + // Instance.ExternalSystem.Call. + var client = new Mock(); + client + .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ExternalCallResult(true, "{}", null)); + var writer = new CapturingAuditWriter(); + + var library = NewLibrary(); + Assert.True(library.CompileAndRegister( + "C", "await Instance.ExternalSystem.Call(\"ERP\", \"GetOrder\"); return null;")); + + var bExecutionId = Guid.NewGuid(); + var context = CreateContext( + ActorRefs.Nobody, library, + externalSystemClient: client.Object, + auditWriter: writer, + executionId: bExecutionId); + + await context.Scripts.CallShared("C"); + + var evt = Assert.Single(writer.Events); + // C's parent is B's execution id — the B→C tree edge. + Assert.Equal(bExecutionId, evt.ParentExecutionId); + // C minted its OWN fresh, non-empty execution id, distinct from B. + Assert.NotNull(evt.ExecutionId); + Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value); + Assert.NotEqual(bExecutionId, evt.ExecutionId!.Value); + } + + [Fact] + public async Task CallShared_FromRoutedRun_ChildParentIsCaller_NotInheritedGrandparent() + { + // Regression / multi-level: B itself carries a parent A. When B does + // CallShared(C), C.Parent must be B's OWN ExecutionId — NOT A. This is + // the A→B→C chain proving each level points at its immediate spawner. + var client = new Mock(); + client + .Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny?>(), It.IsAny())) + .ReturnsAsync(new ExternalCallResult(true, "{}", null)); + var writer = new CapturingAuditWriter(); + + var library = NewLibrary(); + Assert.True(library.CompileAndRegister( + "C", "await Instance.ExternalSystem.Call(\"ERP\", \"GetOrder\"); return null;")); + + var bExecutionId = Guid.NewGuid(); + var aExecutionId = Guid.NewGuid(); // B's inherited parent + var context = CreateContext( + ActorRefs.Nobody, library, + externalSystemClient: client.Object, + auditWriter: writer, + executionId: bExecutionId, + parentExecutionId: aExecutionId); + + await context.Scripts.CallShared("C"); + + var evt = Assert.Single(writer.Events); + Assert.Equal(bExecutionId, evt.ParentExecutionId); + Assert.NotEqual(aExecutionId, evt.ParentExecutionId); + } + + // ------------------------------------------------------------------------- + // Alarm on-trigger plumbing + // ------------------------------------------------------------------------- + + [Fact] + public void CreateChildContextForSharedScript_ParentIsCallerExecution_FreshOwnId() + { + // Unit-level proof of the child-context contract the CallShared path uses. + var bExecutionId = Guid.NewGuid(); + var context = CreateContext( + ActorRefs.Nobody, NewLibrary(), executionId: bExecutionId); + + var child = context.CreateChildContextForSharedScript(childCallDepth: 1); + + Assert.Equal(bExecutionId, child.ParentExecutionId); + Assert.NotEqual(Guid.Empty, child.ExecutionId); + Assert.NotEqual(bExecutionId, child.ExecutionId); + } + + [Fact] + public void AlarmOnTrigger_NestedCallScript_CarriesAlarmRunsOwnExecutionId_AsParent() + { + // End-to-end alarm plumbing: when an alarm fires, its on-trigger script + // runs in a ScriptRuntimeContext built by AlarmExecutionActor. With no + // Guid firing id today the alarm run is a ROOT (its own ParentExecutionId + // is null), but it still mints its OWN fresh ExecutionId. A nested + // CallScript from that on-trigger script must therefore carry the alarm + // run's OWN (non-null) ExecutionId as the child's ParentExecutionId — + // proving the alarm context is a proper execution node feeding the + // cascade and the parentExecutionId parameter is plumbed end-to-end. + var compilationService = new ScriptCompilationService( + NullLogger.Instance); + var sharedLibrary = new SharedScriptLibrary( + compilationService, NullLogger.Instance); + var options = new SiteRuntimeOptions(); + + var onTrigger = compilationService.Compile( + "OnTrigger", "await Instance.CallScript(\"Child\"); return null;"); + Assert.NotNull(onTrigger.CompiledScript); + + var alarmConfig = new ResolvedAlarm + { + CanonicalName = "HighTemp", + TriggerType = "ValueMatch", + TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}", + PriorityLevel = 1 + }; + + var instanceProbe = CreateTestProbe(); + var alarm = ActorOf(Props.Create(() => new AlarmActor( + "HighTemp", "Pump1", instanceProbe.Ref, alarmConfig, + onTrigger.CompiledScript, sharedLibrary, options, + NullLogger.Instance))); + + alarm.Tell(new AttributeValueChanged( + "Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow)); + + // The alarm raises (instance gets AlarmStateChanged) AND the on-trigger + // script fires its nested CallScript at the instance. + instanceProbe.ExpectMsg(TimeSpan.FromSeconds(5)); + var request = instanceProbe.ExpectMsg(TimeSpan.FromSeconds(5)); + + Assert.Equal("Child", request.ScriptName); + // The alarm run is a root today (its own parent is null), but its OWN + // freshly-minted ExecutionId cascades to the child — so the child's + // ParentExecutionId is a real, non-empty value, NOT null. + Assert.NotNull(request.ParentExecutionId); + Assert.NotEqual(Guid.Empty, request.ParentExecutionId!.Value); + + instanceProbe.Reply(new ScriptCallResult(request.CorrelationId, true, null, null)); + } +}