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:
Joseph Doherty
2026-06-16 06:20:58 -04:00
parent d160c7f694
commit f08038db23
3 changed files with 217 additions and 2 deletions
@@ -217,7 +217,11 @@ public class ScriptExecutionActor : ReceiveActor
// and the four cached-call telemetry constructors can stamp
// it onto NotificationSubmit.SourceNode and
// 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
{
@@ -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.Enums;
using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
using ZB.MOM.WW.ScadaBridge.StoreAndForward;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
@@ -94,6 +95,13 @@ public class ScriptRuntimeContext
/// </summary>
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>
/// 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
@@ -179,6 +187,13 @@ public class ScriptRuntimeContext
/// <paramref name="executionId"/>; this only records the spawner.
/// </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(
IActorRef instanceActor,
IActorRef self,
@@ -199,7 +214,8 @@ public class ScriptRuntimeContext
ICachedCallTelemetryForwarder? cachedForwarder = null,
Guid? executionId = null,
Guid? parentExecutionId = null,
string? sourceNode = null)
string? sourceNode = null,
ISiteEventLogger? siteEventLogger = null)
{
_instanceActor = instanceActor;
_self = self;
@@ -227,8 +243,22 @@ public class ScriptRuntimeContext
// Audit Log #23 (ParentExecutionId): stored verbatim — no `?? NewGuid()`
// fallback. A non-routed run legitimately has no parent and stays null.
_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>
/// Gets the current value of an attribute from the Instance Actor.
/// 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}. " +
$"CallScript('{scriptName}') rejected at depth {nextDepth}.";
_logger.LogError(msg);
// M2.12 (#25): emit to site event log in addition to ILogger; fire-and-forget.
_ = EmitRecursionLimitEventAsync(msg);
throw new InvalidOperationException(msg);
}
@@ -464,6 +496,9 @@ public class ScriptRuntimeContext
var msg = $"Script call depth exceeded maximum of {_maxCallDepth}. " +
$"CallShared('{scriptName}') rejected at depth {nextDepth}.";
_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);
}