feat(audit): M5.4 ParentExecutionId tag-cascade for alarm + nested calls (T4)
This commit is contained in:
@@ -571,7 +571,20 @@ public class AlarmActor : ReceiveActor
|
|||||||
/// Passes the firing alarm's level/priority/message so the script can
|
/// Passes the firing alarm's level/priority/message so the script can
|
||||||
/// branch on severity via the <c>Alarm</c> global.
|
/// branch on severity via the <c>Alarm</c> global.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void SpawnAlarmExecution(AlarmLevel level, int priority, string message)
|
/// <param name="level">The firing alarm severity level.</param>
|
||||||
|
/// <param name="priority">The firing alarm priority.</param>
|
||||||
|
/// <param name="message">The firing alarm message.</param>
|
||||||
|
/// <param name="parentExecutionId">
|
||||||
|
/// 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
|
||||||
|
/// <c>ParentExecutionId</c> 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 <c>null</c> (the on-trigger run is a
|
||||||
|
/// root). The parameter exists so a future firing-id can flow without
|
||||||
|
/// touching the actor wiring.
|
||||||
|
/// </param>
|
||||||
|
private void SpawnAlarmExecution(
|
||||||
|
AlarmLevel level, int priority, string message, Guid? parentExecutionId = null)
|
||||||
{
|
{
|
||||||
if (_onTriggerCompiledScript == null) return;
|
if (_onTriggerCompiledScript == null) return;
|
||||||
|
|
||||||
@@ -591,7 +604,9 @@ public class AlarmActor : ReceiveActor
|
|||||||
_options,
|
_options,
|
||||||
_logger,
|
_logger,
|
||||||
// M2.5 (#9): per-script timeout from the on-trigger script (null = global).
|
// 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);
|
Context.ActorOf(props, executionId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ public class AlarmExecutionActor : ReceiveActor
|
|||||||
/// <param name="options">Site runtime configuration options, including the execution timeout.</param>
|
/// <param name="options">Site runtime configuration options, including the execution timeout.</param>
|
||||||
/// <param name="logger">Logger for execution diagnostics.</param>
|
/// <param name="logger">Logger for execution diagnostics.</param>
|
||||||
/// <param name="executionTimeoutSeconds">M2.5 (#9): the on-trigger script's per-script execution timeout in seconds. Null or non-positive falls back to the global <see cref="SiteRuntimeOptions.ScriptExecutionTimeoutSeconds"/>.</param>
|
/// <param name="executionTimeoutSeconds">M2.5 (#9): the on-trigger script's per-script execution timeout in seconds. Null or non-positive falls back to the global <see cref="SiteRuntimeOptions.ScriptExecutionTimeoutSeconds"/>.</param>
|
||||||
|
/// <param name="parentExecutionId">
|
||||||
|
/// 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
|
||||||
|
/// <see cref="ScriptRuntimeContext"/> as its <c>ParentExecutionId</c> 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.
|
||||||
|
/// </param>
|
||||||
public AlarmExecutionActor(
|
public AlarmExecutionActor(
|
||||||
string alarmName,
|
string alarmName,
|
||||||
string instanceName,
|
string instanceName,
|
||||||
@@ -42,7 +50,9 @@ public class AlarmExecutionActor : ReceiveActor
|
|||||||
ILogger logger,
|
ILogger logger,
|
||||||
// M2.5 (#9): per-script execution timeout override (seconds) for the
|
// M2.5 (#9): per-script execution timeout override (seconds) for the
|
||||||
// alarm on-trigger script. Null or non-positive falls back to the global.
|
// 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 self = Self;
|
||||||
var parent = Context.Parent;
|
var parent = Context.Parent;
|
||||||
@@ -51,7 +61,7 @@ public class AlarmExecutionActor : ReceiveActor
|
|||||||
alarmName, instanceName, level, priority, message,
|
alarmName, instanceName, level, priority, message,
|
||||||
compiledScript, instanceActor,
|
compiledScript, instanceActor,
|
||||||
sharedScriptLibrary, options, self, parent, logger,
|
sharedScriptLibrary, options, self, parent, logger,
|
||||||
executionTimeoutSeconds);
|
executionTimeoutSeconds, parentExecutionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ExecuteAlarmScript(
|
private static void ExecuteAlarmScript(
|
||||||
@@ -67,7 +77,8 @@ public class AlarmExecutionActor : ReceiveActor
|
|||||||
IActorRef self,
|
IActorRef self,
|
||||||
IActorRef parent,
|
IActorRef parent,
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
int? executionTimeoutSeconds)
|
int? executionTimeoutSeconds,
|
||||||
|
Guid? parentExecutionId)
|
||||||
{
|
{
|
||||||
// M2.5 (#9): per-script timeout overrides the global default. A null or
|
// M2.5 (#9): per-script timeout overrides the global default. A null or
|
||||||
// non-positive per-script value (≤ 0) falls back to the global.
|
// non-positive per-script value (≤ 0) falls back to the global.
|
||||||
@@ -95,7 +106,14 @@ public class AlarmExecutionActor : ReceiveActor
|
|||||||
options.MaxScriptCallDepth,
|
options.MaxScriptCallDepth,
|
||||||
timeout,
|
timeout,
|
||||||
instanceName,
|
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
|
var globals = new ScriptGlobals
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -247,6 +247,59 @@ public class ScriptRuntimeContext
|
|||||||
_siteEventLogger = siteEventLogger;
|
_siteEventLogger = siteEventLogger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 (M5.4): this run's own per-execution id. Exposed so a
|
||||||
|
/// nested <c>Scripts.CallShared</c> can record it as the spawned shared
|
||||||
|
/// script's <c>ParentExecutionId</c>, forming a true execution tree.
|
||||||
|
/// </summary>
|
||||||
|
internal Guid ExecutionId => _executionId;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal Guid? ParentExecutionId => _parentExecutionId;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 (M5.4 — ParentExecutionId tag-cascade): builds a child
|
||||||
|
/// <see cref="ScriptRuntimeContext"/> for an inline <c>Scripts.CallShared</c>
|
||||||
|
/// 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
|
||||||
|
/// <see cref="_executionId"/> and records THIS run's <see cref="_executionId"/>
|
||||||
|
/// as its <c>ParentExecutionId</c>, so <c>B → CallShared(C)</c> yields
|
||||||
|
/// <c>C.ParentExecutionId == B.ExecutionId</c>. Every other dependency
|
||||||
|
/// (actors, gateways, audit writer, site id, source node, call-depth) is
|
||||||
|
/// carried over verbatim from this context.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="childCallDepth">The recursion depth of the shared-script call.</param>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// M2.12 (#25): fire-and-forget emission of a <c>script</c> Error site event
|
/// M2.12 (#25): fire-and-forget emission of a <c>script</c> Error site event
|
||||||
/// for a recursion-limit violation. Mirrors the call shape used by
|
/// for a recursion-limit violation. Mirrors the call shape used by
|
||||||
@@ -366,7 +419,14 @@ public class ScriptRuntimeContext
|
|||||||
scriptName,
|
scriptName,
|
||||||
ScriptArgs.Normalize(parameters),
|
ScriptArgs.Normalize(parameters),
|
||||||
nextDepth,
|
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
|
// Ask the Instance Actor, which routes to the appropriate Script Actor
|
||||||
var result = await _instanceActor.Ask<ScriptCallResult>(request, _askTimeout);
|
var result = await _instanceActor.Ask<ScriptCallResult>(request, _askTimeout);
|
||||||
@@ -526,8 +586,14 @@ public class ScriptRuntimeContext
|
|||||||
throw new InvalidOperationException(msg);
|
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(
|
return await _library.ExecuteAsync(
|
||||||
scriptName, _context, ScriptArgs.Normalize(parameters), cancellationToken);
|
scriptName, childContext, ScriptArgs.Normalize(parameters), cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 (M5.4 — ParentExecutionId tag-cascade): nested
|
||||||
|
/// <c>CallScript</c> / <c>CallShared</c> invocations and alarm on-trigger runs
|
||||||
|
/// must form a true execution tree, where each spawned run records its
|
||||||
|
/// immediate spawner's <c>ExecutionId</c> as its <c>ParentExecutionId</c>.
|
||||||
|
///
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>
|
||||||
|
/// A nested <c>CallScript</c> (actor-routed) emits a
|
||||||
|
/// <see cref="ScriptCallRequest"/> whose <c>ParentExecutionId</c> is the
|
||||||
|
/// CALLING run's OWN <c>ExecutionId</c> — NOT the inherited grandparent — so
|
||||||
|
/// <c>A → CallScript(B)</c> yields <c>B.Parent == A.ExecutionId</c>.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// A nested <c>CallShared</c> (inline) runs in a child context that mints a
|
||||||
|
/// fresh <c>ExecutionId</c> and records the caller's <c>ExecutionId</c> as its
|
||||||
|
/// parent — so <c>B → CallShared(C)</c> yields <c>C.Parent == B.ExecutionId</c>
|
||||||
|
/// (and NOT B's inherited parent A), proving a multi-level tree.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// The alarm on-trigger plumbing carries a <c>parentExecutionId</c> into the
|
||||||
|
/// script context — null today (the run is a root) but threaded so a future
|
||||||
|
/// firing id can flow.
|
||||||
|
/// </description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public class ParentExecutionTreeTests : TestKit
|
||||||
|
{
|
||||||
|
private const string InstanceName = "Plant.Pump42";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory <see cref="IAuditWriter"/> capturing every emitted event
|
||||||
|
/// (mirrors <c>ExecutionCorrelationContextTests.CapturingAuditWriter</c>).
|
||||||
|
/// </summary>
|
||||||
|
private sealed class CapturingAuditWriter : IAuditWriter
|
||||||
|
{
|
||||||
|
public List<AuditRowProjection.AuditRowValues> 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<ScriptCompilationService>.Instance);
|
||||||
|
return new SharedScriptLibrary(
|
||||||
|
compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a context whose <c>CallScript</c> Ask targets <paramref name="instanceActor"/>
|
||||||
|
/// (a probe), so the forwarded <see cref="ScriptCallRequest"/> can be captured.
|
||||||
|
/// </summary>
|
||||||
|
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<ScriptCallRequest>(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<ScriptCallRequest>(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<IExternalSystemClient>();
|
||||||
|
client
|
||||||
|
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
|
||||||
|
.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<IExternalSystemClient>();
|
||||||
|
client
|
||||||
|
.Setup(c => c.CallAsync("ERP", "GetOrder", It.IsAny<IReadOnlyDictionary<string, object?>?>(), It.IsAny<CancellationToken>()))
|
||||||
|
.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<ScriptCompilationService>.Instance);
|
||||||
|
var sharedLibrary = new SharedScriptLibrary(
|
||||||
|
compilationService, NullLogger<SharedScriptLibrary>.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<AlarmActor>.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<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||||
|
var request = instanceProbe.ExpectMsg<ScriptCallRequest>(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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user