Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationEvaluator.cs
T
Joseph Doherty a85d1c069a fix(scripting): quarantine a refusal behind a stuck orphan (fix round 2)
Ruling refinement on I1. Only timeouts are strikes, so a never-returning
while(true), which times out once and is then only refused, was never
quarantined, and that defeated E1. A refusal now quarantines immediately
when the orphan it was refused behind has been executing for longer than
ScriptingOptions.StuckOrphanQuarantineFactor x the script's timeout
(default 10, so 20 s at the 2 s default; 0 or less disables it). It uses
the same quarantine path: one Error, otopcua.script.quarantined with
reason=stuck, Bad, lifted on source change or redeploy. A young orphan
(a short overrun) is still a plain refusal.

The guard stamps the run's start (Stopwatch timestamp) when the pool
picks it up and clears it when the run really finishes.

Tests: the 30-trigger while(true) regression (paced) now ends
quarantined with reason "stuck", still with exactly one orphan and a
healthy sibling. Positive control: a 2x overrun is refused, not
quarantined, and evaluates again once the orphan completes. A factor of
0 disables the rule. Docs (ScriptEditor "Runtime safety", VirtualTags)
and the seam remarks are updated.

Refs #734

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 13:59:10 -04:00

315 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.Scripting;
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
using SerilogLogger = Serilog.ILogger;
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
/// <summary>
/// The Calculation driver's script evaluator. A calc tag is a raw tag whose value is
/// produced by a C# script over other tags' live values. This evaluator is a deliberate mirror of
/// the VirtualTag <c>RoslynVirtualTagEvaluator</c>: it compiles each unique source once via
/// <see cref="ScriptEvaluator{TContext, TResult}"/> (the Roslyn-backed sandbox), caches the
/// resulting evaluator keyed by source in a <see cref="CompiledScriptCache{TContext, TResult}"/>,
/// and runs steady-state evaluations as in-process method invocations against the dependency
/// dictionary — fast enough to run inline on the driver's dispatch. It reuses
/// <see cref="VirtualTagContext"/> verbatim so the Monaco editor, sandbox, and diagnostics pipeline
/// apply to calc scripts with no divergence.
///
/// <para>
/// <b>Single-tag mode.</b> A calc tag produces exactly one output value — its own — so cross-tag
/// <c>ctx.SetVirtualTag</c> writes are dropped (and logged at Debug). Fan-out between tags is owned
/// by the host's <c>DependencyMuxActor</c> (calc-of-calc chains re-enter the mux), never by the
/// eval engine, exactly as in the VirtualTag adapter.
/// </para>
/// <para>
/// <b>Error surfacing .</b> This evaluator never throws for script faults: compile error,
/// sandbox violation, runtime throw, and timeout all return <see cref="VirtualTagEvalResult.Failure"/>
/// with a descriptive <c>Reason</c>. A successful run returns <see cref="VirtualTagEvalResult.Ok"/>
/// carrying the computed value (which may be null). The <c>CalculationDriver</c> maps a
/// <c>Failure</c> to Bad quality + a <c>ScriptLogEntry</c> and an <c>Ok</c> to the published value.
/// </para>
/// <para>
/// <b>Cache management.</b> Implements <see cref="IScriptCacheOwner"/> so the Calculation driver can drop compiled
/// scripts at each deploy generation whose source set changed — mirroring the VirtualTag adapter's
/// apply-boundary clear, which is what stops collectible <c>AssemblyLoadContext</c>s accreting across
/// script edits.
/// </para>
/// <para>
/// <b>Run quarantine.</b> Every compiled run goes through one <see cref="ScriptRunGuard"/> keyed by
/// calc-tag RawPath with the script source as the identity: a run is refused while the tag's previous
/// (timed-out, abandoned) run is still executing, and the tag is quarantined after three consecutive
/// timeouts (a refusal is Bad but not a strike, unless its orphan is stuck past StuckOrphanQuarantineFactor × the budget) or an allocation-budget breach — both surface as a <see cref="VirtualTagEvalResult.Failure"/>
/// the driver maps to Bad. The driver's evaluation pump awaits <see cref="EvaluateAsync"/> rather than
/// blocking a pool thread on the sync <see cref="Evaluate(string, string, IReadOnlyDictionary{string, DataValueSnapshot})"/>,
/// and the guard starts the budget clock only once the pool has picked the script up, so pool starvation
/// is never reported as a script timeout.
/// </para>
/// </summary>
public sealed class CalculationEvaluator : IScriptCacheOwner, IScriptQuarantineOwner, IDisposable
{
// Source-hash-keyed compile cache: Lazy single-compile (no double-compile race) and Clear()/Dispose()
// that dispose each evaluator's collectible AssemblyLoadContext. ClearCompiledScripts() — driven by the
// Calculation driver per deploy generation — is what stops ALCs accreting across script edits.
private readonly CompiledScriptCache<VirtualTagContext, object?> _cache = new();
private readonly ILogger _logger;
private readonly SerilogLogger _scriptRoot;
private readonly TimeSpan _runTimeout;
private readonly ScriptRunGuard _guard;
// Negative compile cache (R07:P4), keyed by source hash. A compile error is a property of the SOURCE,
// which never changes between triggers (it is fixed in the deploy artifact) — yet every trigger of a
// broken tag used to re-run a full Roslyn compile under the driver-wide evaluation gate, starving the
// healthy tags behind it, and log the same Warning each time. The failure is now remembered until the
// next ClearCompiledScripts (a redeploy), and the host Warning fires once per source.
private readonly ConcurrentDictionary<string, string> _compileFailures = new(StringComparer.Ordinal);
private bool _disposed;
/// <summary>Initializes a new <see cref="CalculationEvaluator"/>.</summary>
/// <param name="logger">Logger for host-side compilation/execution diagnostics. Deliberately the
/// non-generic <see cref="ILogger"/> (rather than <c>ILogger&lt;CalculationEvaluator&gt;</c>) so the
/// caller can forward its OWN <c>ILogger&lt;T&gt;</c> — every <c>ILogger&lt;T&gt;</c> already
/// implements this — instead of needing a second, separately-obtained logger instance.</param>
/// <param name="scriptRoot">Root script logger; user <c>ctx.Logger.*</c> output flows through this to the Script-log page.</param>
/// <param name="runTimeout">Wall-clock budget per script run; defaults to 2 seconds (parity with the VT evaluator).</param>
/// <param name="scripting">Run-guard budget + quarantine threshold; null ⇒ <see cref="ScriptingOptions"/> defaults.</param>
public CalculationEvaluator(
ILogger logger,
ScriptRootLogger scriptRoot,
TimeSpan? runTimeout = null,
ScriptingOptions? scripting = null)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_scriptRoot = (scriptRoot ?? throw new ArgumentNullException(nameof(scriptRoot))).Logger;
_runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
_guard = new ScriptRunGuard(scripting, _scriptRoot);
}
/// <summary>The run guard every compiled evaluation goes through (diagnostics / tests).</summary>
public ScriptRunGuard RunGuard => _guard;
/// <summary>
/// Evaluate <paramref name="scriptSource"/> for the calc tag <paramref name="calcTagId"/> against
/// the snapshot in <paramref name="dependencies"/> (RawPath → last value). Never throws — script
/// faults are reported via <see cref="VirtualTagEvalResult.Failure"/>; a successful run returns
/// <see cref="VirtualTagEvalResult.Ok"/> carrying the single computed value.
/// </summary>
/// <param name="calcTagId">Identity of the calc tag (bound to the script log; in the live path equals the script id).</param>
/// <param name="scriptSource">The C# script source producing the tag's value.</param>
/// <param name="dependencies">Read-only snapshot of dependency values keyed by RawPath.</param>
/// <returns>The evaluation result, including any script fault.</returns>
/// <remarks>
/// <b>Value-only overload.</b> Every dependency reaches the script stamped <c>Good</c> with an
/// evaluation-time timestamp, because bare values carry nothing better. Prefer
/// <see cref="Evaluate(string, string, IReadOnlyDictionary{string, DataValueSnapshot})"/>, which the
/// driver uses: it carries each input's real status and source timestamp, so
/// <c>ctx.GetTag(path).StatusCode</c> is honest. This overload remains for
/// callers that genuinely hold only values — chiefly tests.
/// </remarks>
public VirtualTagEvalResult Evaluate(string calcTagId, string scriptSource, IReadOnlyDictionary<string, object?> dependencies)
{
ArgumentNullException.ThrowIfNull(dependencies);
var nowUtc = DateTime.UtcNow;
var snapshots = new Dictionary<string, DataValueSnapshot>(dependencies.Count, StringComparer.Ordinal);
foreach (var kv in dependencies)
snapshots[kv.Key] = new DataValueSnapshot(kv.Value, StatusCode: 0u, nowUtc, nowUtc);
return Evaluate(calcTagId, scriptSource, snapshots);
}
/// <summary>
/// Evaluate <paramref name="scriptSource"/> for the calc tag <paramref name="calcTagId"/> against
/// the dependency <b>snapshots</b> in <paramref name="dependencies"/> — value, status code and
/// source timestamp per input. Never throws; script faults surface as
/// <see cref="VirtualTagEvalResult.Failure"/>.
/// </summary>
/// <remarks>
/// The snapshots pass <b>through</b> to the script's read cache rather than being re-wrapped as
/// Good. The old wrapping meant a script that inspected
/// <c>ctx.GetTag(path).StatusCode</c> was told Good for every input, including one whose driver had
/// stopped communicating — so a quality-aware script could not be written at all. The driver holds
/// evaluation entirely while any input is Bad, so what a script sees here is Good or Uncertain.
/// </remarks>
/// <param name="calcTagId">Identity of the calc tag (bound to the script log).</param>
/// <param name="scriptSource">The C# script source producing the tag's value.</param>
/// <param name="dependencies">Read-only snapshot of dependency values keyed by RawPath.</param>
/// <returns>The evaluation outcome.</returns>
/// <remarks>Blocks the calling thread for the run. The driver's pump uses <see cref="EvaluateAsync"/>;
/// this overload remains for callers with no async context — chiefly tests.</remarks>
public VirtualTagEvalResult Evaluate(
string calcTagId, string scriptSource, IReadOnlyDictionary<string, DataValueSnapshot> dependencies)
=> EvaluateAsync(calcTagId, scriptSource, dependencies).GetAwaiter().GetResult();
/// <summary>
/// The async form of <see cref="Evaluate(string, string, IReadOnlyDictionary{string, DataValueSnapshot})"/>:
/// the compiled script runs on the pool under the <see cref="ScriptRunGuard"/> and the caller awaits
/// it instead of blocking a second pool thread for the duration. Never throws for script faults.
/// </summary>
/// <param name="calcTagId">Identity of the calc tag (bound to the script log).</param>
/// <param name="scriptSource">The C# script source producing the tag's value.</param>
/// <param name="dependencies">Read-only snapshot of dependency values keyed by RawPath.</param>
/// <param name="ct">Caller cancellation (driver shutdown); wins over the run timeout.</param>
/// <returns>The evaluation outcome.</returns>
public async Task<VirtualTagEvalResult> EvaluateAsync(
string calcTagId, string scriptSource, IReadOnlyDictionary<string, DataValueSnapshot> dependencies,
CancellationToken ct = default)
{
if (_disposed) return VirtualTagEvalResult.Failure("evaluator disposed");
if (string.IsNullOrWhiteSpace(scriptSource)) return VirtualTagEvalResult.Failure("empty expression");
// Passthrough fast-path: the mirror shape `return ctx.GetTag("X").Value;` needs no Roslyn.
// Semantics are byte-identical to the compiled mirror: a present dependency returns its value
// (Ok), an absent one makes GetTag yield a Bad snapshot whose .Value is null, so the script
// returns null — Ok(null) here too. Near-misses fall through to the compiled path.
if (PassthroughScript.TryMatch(scriptSource, out var passthroughRef))
{
// The VALUE only — the caller stamps the output with the worst input status, which for a
// single-dependency passthrough IS this input's status. Returning it here as well would put
// the same decision in two places.
return dependencies.TryGetValue(passthroughRef, out var ptSnapshot)
? VirtualTagEvalResult.Ok(ptSnapshot.Value)
: VirtualTagEvalResult.Ok(null);
}
var readCache = dependencies;
// Per-evaluation script logger: bind both ScriptId and CalcTagId (via the VirtualTagId slot, which
// the Script-log page attributes on) from the calc-tag id so each line is attributable.
var scriptLog = _scriptRoot
.ForContext(ScriptLoggerFactory.ScriptIdProperty, calcTagId)
.ForContext(ScriptLoggerFactory.VirtualTagIdProperty, calcTagId);
var context = new VirtualTagContext(
readCache,
// Single-tag mode: a calc tag has exactly one output (its own), so cross-tag writes are dropped.
setVirtualTag: (path, _) =>
_logger.LogDebug("Calculation {Id}: cross-tag write to {Path} dropped (single-tag evaluator)",
calcTagId, path),
logger: scriptLog);
// A source that already failed to compile fails the same way without touching Roslyn (R07:P4).
var sourceKey = HashSource(scriptSource);
if (_compileFailures.TryGetValue(sourceKey, out var knownFailure))
return VirtualTagEvalResult.Failure(knownFailure);
// Fetch + run inside a bounded loop so an ObjectDisposedException caused by a concurrent
// ClearCompiledScripts (which disposed the evaluator between GetOrCompile and the run's
// disposed guard) re-fetches and retries exactly once. The loop runs at most twice.
for (var attempt = 0; ; attempt++)
{
ScriptEvaluator<VirtualTagContext, object?> evaluator;
try
{
evaluator = _cache.GetOrCompile(scriptSource);
}
catch (CompilationErrorException ex)
{
return RememberCompileFailure(sourceKey, calcTagId, $"compile error: {ex.Message}", ex, "Roslyn compile failed");
}
catch (ScriptSandboxViolationException ex)
{
return RememberCompileFailure(sourceKey, calcTagId, $"sandbox violation: {ex.Message}", ex, "sandbox violation");
}
catch (ObjectDisposedException)
{
// Fetch-time disposal is a shutdown signal (the cache is tearing down), not the
// apply-boundary race — never retry.
return VirtualTagEvalResult.Failure("evaluator disposed");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Calculation {Id}: compile threw", calcTagId);
return VirtualTagEvalResult.Failure($"compile failure: {ex.Message}");
}
try
{
// Route through the ScriptRunGuard (Task.Run + WaitAsync, keyed by RawPath): a raw
// CancellationToken can't interrupt a CPU-bound/infinite-loop Roslyn script, so the wall-clock
// budget is what bounds a runaway script — and the guard is what keeps the next trigger from
// stacking another orphan behind it (one per tag, then quarantine).
var raw = await _guard.RunAsync(calcTagId, scriptSource, evaluator, context, _runTimeout, ct)
.ConfigureAwait(false);
return VirtualTagEvalResult.Ok(raw);
}
catch (ScriptTimeoutException)
{
return VirtualTagEvalResult.Failure(
$"{ScriptEvalFailureReasons.TimedOutPrefix}{_runTimeout.TotalSeconds:F1}s");
}
catch (ScriptRunRefusedException ex)
{
return VirtualTagEvalResult.Failure(ex.Message);
}
catch (ScriptQuarantinedException ex)
{
return VirtualTagEvalResult.Failure(ex.Message);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return VirtualTagEvalResult.Failure("evaluation cancelled (driver shutting down)");
}
catch (ObjectDisposedException) when (!_disposed && attempt == 0)
{
// A concurrent ClearCompiledScripts disposed the evaluator between our GetOrCompile and the
// run's disposed-guard. Re-fetch (recompiling the same source into the CURRENT generation)
// and retry once. A second disposal mid-retry falls through to the failure path below.
continue;
}
catch (ObjectDisposedException)
{
return VirtualTagEvalResult.Failure(ScriptEvalFailureReasons.EvaluatorDisposedDuringEvaluation);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Calculation {Id}: script execution threw", calcTagId);
return VirtualTagEvalResult.Failure($"script threw: {ex.Message}");
}
}
}
/// <summary>Record a compile failure for <paramref name="sourceKey"/> and log it ONCE — the next trigger
/// of the same source returns the remembered reason without a Roslyn round-trip or a second Warning.</summary>
private VirtualTagEvalResult RememberCompileFailure(string sourceKey, string calcTagId, string reason, Exception ex, string what)
{
if (_compileFailures.TryAdd(sourceKey, reason))
_logger.LogWarning(ex, "Calculation {Id}: {What} (remembered until the next redeploy)", calcTagId, what);
return VirtualTagEvalResult.Failure(reason);
}
private static string HashSource(string source)
=> Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(source)));
/// <summary>Test/diagnostic: the number of sources currently remembered as failing to compile.</summary>
public int CompileFailureCount => _compileFailures.Count;
/// <summary>Test/diagnostic: the number of compiled scripts currently cached.</summary>
public int CompiledScriptCount => _cache.Count;
/// <inheritdoc />
public void ClearCompiledScripts()
{
_cache.Clear();
_compileFailures.Clear();
// The compile-cache clear is this evaluator's deploy boundary — a redeploy lifts every quarantine
// (an edited source already lifts its own through the guard's source identity).
_guard.ResetAll();
}
/// <inheritdoc />
public void ResetQuarantines() => _guard.ResetAll();
/// <inheritdoc />
public IReadOnlyCollection<string> QuarantinedScripts => _guard.QuarantinedKeys;
/// <summary>Disposes the evaluator, draining + disposing every cached compile's collectible ALC.</summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_cache.Dispose();
_compileFailures.Clear();
}
}