a85d1c069a
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
315 lines
18 KiB
C#
315 lines
18 KiB
C#
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<CalculationEvaluator></c>) so the
|
||
/// caller can forward its OWN <c>ILogger<T></c> — every <c>ILogger<T></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();
|
||
}
|
||
}
|