From 05a0596fb1b8ddc6a619bfeb10c593cf1bedf61c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 26 May 2026 10:58:04 -0400 Subject: [PATCH] feat(host): F9b RoslynScriptedAlarmEvaluator + #107 close engine DI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RoslynScriptedAlarmEvaluator mirrors F8b's pattern for alarm predicates: caches a compiled ScriptEvaluator per unique predicate, runs against the dependency dictionary with a 2s timeout, and turns every failure (compile error, sandbox violation, runtime throw, ctx.SetVirtualTag attempt — predicates must be pure) into a ScriptedAlarmEvalResult.Failure. ScriptedAlarmActor preserves prior state on Failure so a broken predicate can't flip Active/Inactive spuriously. Program.cs binds both evaluators on driver-role hosts — this fully satisfies #107 ("bind production VirtualTagEngine + ScriptedAlarmEngine adapters"). The two Roslyn adapters together replace the F8 + F9 Null defaults, so VirtualTagActor + ScriptedAlarmActor now run real user scripts in production. 7 new adapter tests cover: predicate true → Active, predicate false → Inactive, cache reuse, compile-error denial, write-attempt denial, empty-predicate denial, post-dispose denial. Host.IntegrationTests now 17/17 green. Closes #80 + #107. All major v2 follow-ups are now complete; only cleanup + observability polish remains. --- .../Engines/RoslynScriptedAlarmEvaluator.cs | 107 ++++++++++++++++++ src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 6 + .../ZB.MOM.WW.OtOpcUa.Host.csproj | 1 + .../RoslynScriptedAlarmEvaluatorTests.cs | 102 +++++++++++++++++ 4 files changed, 216 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynScriptedAlarmEvaluatorTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs new file mode 100644 index 0000000..a0e9a1d --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynScriptedAlarmEvaluator.cs @@ -0,0 +1,107 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Commons.Engines; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; +using ZB.MOM.WW.OtOpcUa.Core.Scripting; +using SerilogLogger = Serilog.ILogger; +using SerilogLog = Serilog.Log; + +namespace ZB.MOM.WW.OtOpcUa.Host.Engines; + +/// +/// F9b — production binding. Compiles each unique +/// predicate once via against +/// and caches the resulting evaluator. Predicates are +/// pure functions returning bool: +/// throws so a misbehaving script can't smuggle a side effect into alarm evaluation. +/// +/// Failure modes (compile error, sandbox violation, runtime exception, timeout) all surface +/// as ; +/// preserves the prior state on failure (does not flip Active/Inactive). +/// +public sealed class RoslynScriptedAlarmEvaluator : IScriptedAlarmEvaluator, IDisposable +{ + private static readonly SerilogLogger ScriptLogger = SerilogLog.ForContext(); + + private readonly ConcurrentDictionary> _cache + = new(StringComparer.Ordinal); + private readonly ILogger _logger; + private readonly TimeSpan _runTimeout; + private bool _disposed; + + public RoslynScriptedAlarmEvaluator(ILogger logger, TimeSpan? runTimeout = null) + { + _logger = logger; + _runTimeout = runTimeout ?? TimeSpan.FromSeconds(2); + } + + public ScriptedAlarmEvalResult Evaluate(string alarmId, string predicate, IReadOnlyDictionary dependencies) + { + if (_disposed) return ScriptedAlarmEvalResult.Failure("evaluator disposed"); + if (string.IsNullOrWhiteSpace(predicate)) return ScriptedAlarmEvalResult.Failure("empty predicate"); + + ScriptEvaluator evaluator; + try + { + evaluator = _cache.GetOrAdd(predicate, ScriptEvaluator.Compile); + } + catch (CompilationErrorException ex) + { + _logger.LogWarning(ex, "Alarm {Id}: predicate compile failed", alarmId); + return ScriptedAlarmEvalResult.Failure($"compile error: {ex.Message}"); + } + catch (ScriptSandboxViolationException ex) + { + _logger.LogWarning(ex, "Alarm {Id}: predicate sandbox violation", alarmId); + return ScriptedAlarmEvalResult.Failure($"sandbox violation: {ex.Message}"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Alarm {Id}: predicate compile threw", alarmId); + return ScriptedAlarmEvalResult.Failure($"compile failure: {ex.Message}"); + } + + var readCache = BuildReadCache(dependencies); + var context = new AlarmPredicateContext(readCache, ScriptLogger); + + try + { + using var cts = new CancellationTokenSource(_runTimeout); + var active = evaluator.RunAsync(context, cts.Token).GetAwaiter().GetResult(); + return ScriptedAlarmEvalResult.Ok(active); + } + catch (OperationCanceledException) + { + return ScriptedAlarmEvalResult.Failure($"predicate timed out after {_runTimeout.TotalSeconds:F1}s"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Alarm {Id}: predicate execution threw", alarmId); + return ScriptedAlarmEvalResult.Failure($"predicate threw: {ex.Message}"); + } + } + + private static IReadOnlyDictionary BuildReadCache( + IReadOnlyDictionary deps) + { + var nowUtc = DateTime.UtcNow; + var cache = new Dictionary(StringComparer.Ordinal); + foreach (var kv in deps) + { + cache[kv.Key] = new DataValueSnapshot(kv.Value, StatusCode: 0u, SourceTimestampUtc: nowUtc, ServerTimestampUtc: nowUtc); + } + return cache; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + foreach (var ev in _cache.Values) + { + try { ev.Dispose(); } catch { /* best-effort */ } + } + _cache.Clear(); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 57d5145..ded12f3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -79,6 +79,12 @@ if (hasDriver) new RoslynVirtualTagEvaluator(sp.GetRequiredService().CreateLogger())); builder.Services.AddSingleton(sp => sp.GetRequiredService()); + // F9b — same pattern for scripted-alarm predicates. The actor preserves prior state on + // any Failure result, so a misbehaving script can't flip Active/Inactive spuriously. + builder.Services.AddSingleton(sp => + new RoslynScriptedAlarmEvaluator(sp.GetRequiredService().CreateLogger())); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddOptions().Bind(builder.Configuration.GetSection("Ldap")); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj index db80245..3b7e33b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj @@ -32,6 +32,7 @@ + diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynScriptedAlarmEvaluatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynScriptedAlarmEvaluatorTests.cs new file mode 100644 index 0000000..5e7a0e4 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynScriptedAlarmEvaluatorTests.cs @@ -0,0 +1,102 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Host.Engines; + +namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; + +/// +/// F9b — verifies compiles alarm predicates, +/// returns the bool result on success, surfaces compile/runtime errors as Failure (so the +/// actor preserves prior state), and rejects predicates that try to ctx.SetVirtualTag (the +/// AlarmPredicateContext throws on writes — predicates must stay pure). +/// +public sealed class RoslynScriptedAlarmEvaluatorTests +{ + [Fact] + public void Evaluate_predicate_returning_true_reports_Active() + { + using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance); + + var result = sut.Evaluate( + alarmId: "alarm-hi", + predicate: "return (int)ctx.GetTag(\"temp\").Value > 100;", + dependencies: new Dictionary { ["temp"] = 150 }); + + result.Success.ShouldBeTrue(result.Reason); + result.Active.ShouldBeTrue(); + } + + [Fact] + public void Evaluate_predicate_returning_false_reports_Inactive() + { + using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance); + + var result = sut.Evaluate( + alarmId: "alarm-hi", + predicate: "return (int)ctx.GetTag(\"temp\").Value > 100;", + dependencies: new Dictionary { ["temp"] = 50 }); + + result.Success.ShouldBeTrue(result.Reason); + result.Active.ShouldBeFalse(); + } + + [Fact] + public void Evaluate_caches_compiled_predicate_across_calls() + { + using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance); + const string predicate = "return (bool)ctx.GetTag(\"door_open\").Value;"; + + var first = sut.Evaluate("alarm-door", predicate, new Dictionary { ["door_open"] = true }); + var second = sut.Evaluate("alarm-door", predicate, new Dictionary { ["door_open"] = false }); + + first.Active.ShouldBeTrue(); + second.Active.ShouldBeFalse(); + } + + [Fact] + public void Evaluate_compile_error_returns_Failure() + { + using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance); + + var result = sut.Evaluate("alarm-bad", "this isn't C#;", new Dictionary()); + + result.Success.ShouldBeFalse(); + result.Reason!.ShouldContain("compile"); + } + + [Fact] + public void Evaluate_predicate_writing_virtual_tag_returns_Failure() + { + using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance); + + // AlarmPredicateContext.SetVirtualTag throws — wrapper catches + reports as Failure. + var result = sut.Evaluate( + alarmId: "alarm-bad-write", + predicate: "ctx.SetVirtualTag(\"x\", 1); return true;", + dependencies: new Dictionary()); + + result.Success.ShouldBeFalse(); + result.Reason!.ShouldContain("threw"); + } + + [Fact] + public void Evaluate_empty_predicate_returns_Failure() + { + using var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance); + + sut.Evaluate("alarm-empty", "", new Dictionary()).Success.ShouldBeFalse(); + } + + [Fact] + public void Evaluate_after_dispose_returns_Failure() + { + var sut = new RoslynScriptedAlarmEvaluator(NullLogger.Instance); + sut.Dispose(); + + var result = sut.Evaluate("alarm", "return true;", new Dictionary()); + + result.Success.ShouldBeFalse(); + result.Reason!.ShouldContain("disposed"); + } +}