feat(siteruntime): M2.12 (#25) — emit script Error site event on recursion-limit violation
Inject ISiteEventLogger into ScriptRuntimeContext (additive optional ctor param, defaulted null, all existing callers source-compatible). Add a single private EmitRecursionLimitEventAsync helper that fires-and-forgets a "script"/Error site event; called at both recursion guard sites (CallScript at ~:332 and ScriptCallHelper.CallShared at ~:499). ScriptExecutionActor threads the already-resolved siteEventLogger singleton into the context; AlarmExecutionActor leaves it null (no siteEventLogger wired there). Existing _logger.LogError + throw behaviour unchanged. Tests: RecursionLimitSiteEventTests — 5 tests covering both CallScript and CallShared (ISiteEventLogger.LogEventAsync called once with category "script", severity "Error"; null logger path does not throw).
This commit is contained in:
@@ -217,7 +217,11 @@ public class ScriptExecutionActor : ReceiveActor
|
|||||||
// and the four cached-call telemetry constructors can stamp
|
// and the four cached-call telemetry constructors can stamp
|
||||||
// it onto NotificationSubmit.SourceNode and
|
// it onto NotificationSubmit.SourceNode and
|
||||||
// SiteCallOperational.SourceNode respectively.
|
// SiteCallOperational.SourceNode respectively.
|
||||||
sourceNode: sourceNode);
|
sourceNode: sourceNode,
|
||||||
|
// M2.12 (#25): thread the singleton site event logger so
|
||||||
|
// recursion-limit violations at CallScript/CallShared emit a
|
||||||
|
// script Error site event in addition to ILogger.LogError.
|
||||||
|
siteEventLogger: siteEventLogger);
|
||||||
|
|
||||||
var globals = new ScriptGlobals
|
var globals = new ScriptGlobals
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
|||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;
|
using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
|
||||||
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
|
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||||
@@ -94,6 +95,13 @@ public class ScriptRuntimeContext
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly string? _sourceScript;
|
private readonly string? _sourceScript;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M2.12 (#25): site event logger for recording recursion-limit violations
|
||||||
|
/// to the local SQLite event log. Optional — when null the emission is
|
||||||
|
/// skipped; the existing <c>_logger.LogError</c> + throw path is unchanged.
|
||||||
|
/// </summary>
|
||||||
|
private readonly ISiteEventLogger? _siteEventLogger;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Audit Log #23: best-effort emitter for boundary-crossing actions executed
|
/// Audit Log #23: best-effort emitter for boundary-crossing actions executed
|
||||||
/// by the script. Optional — when null the helpers degrade to a no-op audit
|
/// by the script. Optional — when null the helpers degrade to a no-op audit
|
||||||
@@ -179,6 +187,13 @@ public class ScriptRuntimeContext
|
|||||||
/// <paramref name="executionId"/>; this only records the spawner.
|
/// <paramref name="executionId"/>; this only records the spawner.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="sourceNode">Optional cluster node identifier (node-a/node-b) for audit trail stamping.</param>
|
/// <param name="sourceNode">Optional cluster node identifier (node-a/node-b) for audit trail stamping.</param>
|
||||||
|
/// <param name="siteEventLogger">
|
||||||
|
/// M2.12 (#25): optional site event logger. When supplied, recursion-limit
|
||||||
|
/// violations at <c>CallScript</c> and <c>CallShared</c> emit a
|
||||||
|
/// <c>script</c> Error event in addition to the existing
|
||||||
|
/// <c>ILogger.LogError</c> + throw. When null the existing behaviour is
|
||||||
|
/// unchanged; all existing callers and tests remain source-compatible.
|
||||||
|
/// </param>
|
||||||
public ScriptRuntimeContext(
|
public ScriptRuntimeContext(
|
||||||
IActorRef instanceActor,
|
IActorRef instanceActor,
|
||||||
IActorRef self,
|
IActorRef self,
|
||||||
@@ -199,7 +214,8 @@ public class ScriptRuntimeContext
|
|||||||
ICachedCallTelemetryForwarder? cachedForwarder = null,
|
ICachedCallTelemetryForwarder? cachedForwarder = null,
|
||||||
Guid? executionId = null,
|
Guid? executionId = null,
|
||||||
Guid? parentExecutionId = null,
|
Guid? parentExecutionId = null,
|
||||||
string? sourceNode = null)
|
string? sourceNode = null,
|
||||||
|
ISiteEventLogger? siteEventLogger = null)
|
||||||
{
|
{
|
||||||
_instanceActor = instanceActor;
|
_instanceActor = instanceActor;
|
||||||
_self = self;
|
_self = self;
|
||||||
@@ -227,8 +243,22 @@ public class ScriptRuntimeContext
|
|||||||
// Audit Log #23 (ParentExecutionId): stored verbatim — no `?? NewGuid()`
|
// Audit Log #23 (ParentExecutionId): stored verbatim — no `?? NewGuid()`
|
||||||
// fallback. A non-routed run legitimately has no parent and stays null.
|
// fallback. A non-routed run legitimately has no parent and stays null.
|
||||||
_parentExecutionId = parentExecutionId;
|
_parentExecutionId = parentExecutionId;
|
||||||
|
// M2.12 (#25): optional — null when not wired (tests / AlarmExecutionActor).
|
||||||
|
_siteEventLogger = siteEventLogger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <c>ScriptExecutionActor</c>'s catch blocks (WP-32 / M1.8). The returned
|
||||||
|
/// task is intentionally discarded by callers (<c>_ =</c>) so the event log
|
||||||
|
/// never blocks or faults the throw that follows. A null logger is a no-op.
|
||||||
|
/// </summary>
|
||||||
|
private Task EmitRecursionLimitEventAsync(string msg) =>
|
||||||
|
_siteEventLogger?.LogEventAsync(
|
||||||
|
"script", "Error", _instanceName, _sourceScript ?? "ScriptRuntimeContext", msg)
|
||||||
|
?? Task.CompletedTask;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the current value of an attribute from the Instance Actor.
|
/// Gets the current value of an attribute from the Instance Actor.
|
||||||
/// Uses Ask pattern (system boundary between script execution and instance state).
|
/// Uses Ask pattern (system boundary between script execution and instance state).
|
||||||
@@ -302,6 +332,8 @@ public class ScriptRuntimeContext
|
|||||||
var msg = $"Script call depth exceeded maximum of {_maxCallDepth}. " +
|
var msg = $"Script call depth exceeded maximum of {_maxCallDepth}. " +
|
||||||
$"CallScript('{scriptName}') rejected at depth {nextDepth}.";
|
$"CallScript('{scriptName}') rejected at depth {nextDepth}.";
|
||||||
_logger.LogError(msg);
|
_logger.LogError(msg);
|
||||||
|
// M2.12 (#25): emit to site event log in addition to ILogger; fire-and-forget.
|
||||||
|
_ = EmitRecursionLimitEventAsync(msg);
|
||||||
throw new InvalidOperationException(msg);
|
throw new InvalidOperationException(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,6 +496,9 @@ public class ScriptRuntimeContext
|
|||||||
var msg = $"Script call depth exceeded maximum of {_maxCallDepth}. " +
|
var msg = $"Script call depth exceeded maximum of {_maxCallDepth}. " +
|
||||||
$"CallShared('{scriptName}') rejected at depth {nextDepth}.";
|
$"CallShared('{scriptName}') rejected at depth {nextDepth}.";
|
||||||
_logger.LogError(msg);
|
_logger.LogError(msg);
|
||||||
|
// M2.12 (#25): emit to site event log via the parent context's
|
||||||
|
// helper — single emission path, fire-and-forget.
|
||||||
|
_ = _context.EmitRecursionLimitEventAsync(msg);
|
||||||
throw new InvalidOperationException(msg);
|
throw new InvalidOperationException(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+176
@@ -0,0 +1,176 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Moq;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// M2.12 (#25): recursion-limit violations at <c>CallScript</c> and
|
||||||
|
/// <c>CallShared</c> must emit a <c>script</c> Error site event via
|
||||||
|
/// <see cref="ISiteEventLogger"/> in addition to the existing
|
||||||
|
/// <c>ILogger.LogError</c> + throw.
|
||||||
|
/// </summary>
|
||||||
|
public class RecursionLimitSiteEventTests
|
||||||
|
{
|
||||||
|
private const string InstanceName = "test-instance";
|
||||||
|
private const string SourceScript = "ScriptActor:OnTick";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Build a <see cref="ScriptRuntimeContext"/> wired at the recursion
|
||||||
|
/// limit (currentCallDepth == maxCallDepth) so the very next
|
||||||
|
/// <c>CallScript</c> or <c>CallShared</c> call trips the guard.
|
||||||
|
/// </summary>
|
||||||
|
private static ScriptRuntimeContext CreateContextAtLimit(
|
||||||
|
ISiteEventLogger siteEventLogger,
|
||||||
|
int maxCallDepth = 3)
|
||||||
|
{
|
||||||
|
var compilationService = new ScriptCompilationService(
|
||||||
|
NullLogger<ScriptCompilationService>.Instance);
|
||||||
|
var sharedScriptLibrary = new SharedScriptLibrary(
|
||||||
|
compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||||
|
|
||||||
|
return new ScriptRuntimeContext(
|
||||||
|
ActorRefs.Nobody,
|
||||||
|
ActorRefs.Nobody,
|
||||||
|
sharedScriptLibrary,
|
||||||
|
currentCallDepth: maxCallDepth, // already AT the limit
|
||||||
|
maxCallDepth: maxCallDepth,
|
||||||
|
askTimeout: TimeSpan.FromSeconds(5),
|
||||||
|
instanceName: InstanceName,
|
||||||
|
logger: NullLogger.Instance,
|
||||||
|
siteEventLogger: siteEventLogger);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// CallScript
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CallScript_RecursionLimitExceeded_EmitsScriptErrorSiteEvent()
|
||||||
|
{
|
||||||
|
var loggerMock = new Mock<ISiteEventLogger>();
|
||||||
|
loggerMock
|
||||||
|
.Setup(l => l.LogEventAsync(
|
||||||
|
It.IsAny<string>(), It.IsAny<string>(),
|
||||||
|
It.IsAny<string?>(), It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(), It.IsAny<string?>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var context = CreateContextAtLimit(loggerMock.Object);
|
||||||
|
|
||||||
|
// The call must throw — recursion limit hit
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => context.CallScript("AnyScript"));
|
||||||
|
|
||||||
|
// Give the fire-and-forget task a moment to complete
|
||||||
|
await Task.Yield();
|
||||||
|
|
||||||
|
loggerMock.Verify(l => l.LogEventAsync(
|
||||||
|
"script",
|
||||||
|
"Error",
|
||||||
|
InstanceName,
|
||||||
|
It.IsAny<string>(), // source (may be null when sourceScript not set)
|
||||||
|
It.Is<string>(m => m.Contains("CallScript") && m.Contains("depth")),
|
||||||
|
null),
|
||||||
|
Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CallScript_RecursionLimitExceeded_NullLogger_DoesNotThrow()
|
||||||
|
{
|
||||||
|
// Null siteEventLogger — existing behaviour must be fully unchanged:
|
||||||
|
// LogError + throw, no NullReferenceException from the emission path.
|
||||||
|
var context = CreateContextAtLimit(siteEventLogger: null!);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => context.CallScript("AnyScript"));
|
||||||
|
|
||||||
|
Assert.Contains("depth", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// CallShared (via Scripts.CallShared)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CallShared_RecursionLimitExceeded_EmitsScriptErrorSiteEvent()
|
||||||
|
{
|
||||||
|
var loggerMock = new Mock<ISiteEventLogger>();
|
||||||
|
loggerMock
|
||||||
|
.Setup(l => l.LogEventAsync(
|
||||||
|
It.IsAny<string>(), It.IsAny<string>(),
|
||||||
|
It.IsAny<string?>(), It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(), It.IsAny<string?>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var context = CreateContextAtLimit(loggerMock.Object);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => context.Scripts.CallShared("AnySharedScript"));
|
||||||
|
|
||||||
|
await Task.Yield();
|
||||||
|
|
||||||
|
loggerMock.Verify(l => l.LogEventAsync(
|
||||||
|
"script",
|
||||||
|
"Error",
|
||||||
|
InstanceName,
|
||||||
|
It.IsAny<string>(),
|
||||||
|
It.Is<string>(m => m.Contains("CallShared") && m.Contains("depth")),
|
||||||
|
null),
|
||||||
|
Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CallShared_RecursionLimitExceeded_NullLogger_DoesNotThrow()
|
||||||
|
{
|
||||||
|
var context = CreateContextAtLimit(siteEventLogger: null!);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => context.Scripts.CallShared("AnySharedScript"));
|
||||||
|
|
||||||
|
Assert.Contains("depth", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Verify the event shape precisely (category, severity, message content)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CallScript_RecursionLimitExceeded_EventHasCorrectCategoryAndSeverity()
|
||||||
|
{
|
||||||
|
string? capturedCategory = null;
|
||||||
|
string? capturedSeverity = null;
|
||||||
|
string? capturedInstance = null;
|
||||||
|
string? capturedMessage = null;
|
||||||
|
|
||||||
|
var loggerMock = new Mock<ISiteEventLogger>();
|
||||||
|
loggerMock
|
||||||
|
.Setup(l => l.LogEventAsync(
|
||||||
|
It.IsAny<string>(), It.IsAny<string>(),
|
||||||
|
It.IsAny<string?>(), It.IsAny<string>(),
|
||||||
|
It.IsAny<string>(), It.IsAny<string?>()))
|
||||||
|
.Callback<string, string, string?, string, string, string?>(
|
||||||
|
(cat, sev, inst, src, msg, det) =>
|
||||||
|
{
|
||||||
|
capturedCategory = cat;
|
||||||
|
capturedSeverity = sev;
|
||||||
|
capturedInstance = inst;
|
||||||
|
capturedMessage = msg;
|
||||||
|
})
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var context = CreateContextAtLimit(loggerMock.Object, maxCallDepth: 2);
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => context.CallScript("DeepScript"));
|
||||||
|
await Task.Yield();
|
||||||
|
|
||||||
|
Assert.Equal("script", capturedCategory);
|
||||||
|
Assert.Equal("Error", capturedSeverity);
|
||||||
|
Assert.Equal(InstanceName, capturedInstance);
|
||||||
|
Assert.NotNull(capturedMessage);
|
||||||
|
Assert.Contains("CallScript", capturedMessage);
|
||||||
|
Assert.Contains("2", capturedMessage); // maxCallDepth in the message
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user