86ca1a76d1
New Driver.Calculation project + CalculationEvaluator, mirroring the VirtualTag RoslynVirtualTagEvaluator: source-hash compile cache (CompiledScriptCache), 2 s TimedScriptEvaluator, passthrough fast-path, single-tag mode (ctx.SetVirtualTag dropped + logged), sandbox/compile/ timeout/throw all surfaced as VirtualTagEvalResult.Failure for WP7 to map to Bad quality. IScriptCacheOwner.ClearCompiledScripts for the deploy generation boundary. 11 parity unit tests. Both projects added to the solution. Evaluator + tests only — driver shell / registration is WP7. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
194 lines
10 KiB
C#
194 lines
10 KiB
C#
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>
|
|
/// T0-4 — 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 (for WP7).</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). WP7's <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 WP7 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>
|
|
/// </summary>
|
|
public sealed class CalculationEvaluator : IScriptCacheOwner, 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
|
|
// WP7 driver per deploy generation — is what stops ALCs accreting across script edits.
|
|
private readonly CompiledScriptCache<VirtualTagContext, object?> _cache = new();
|
|
private readonly ILogger<CalculationEvaluator> _logger;
|
|
private readonly SerilogLogger _scriptRoot;
|
|
private readonly TimeSpan _runTimeout;
|
|
private bool _disposed;
|
|
|
|
/// <summary>Initializes a new <see cref="CalculationEvaluator"/>.</summary>
|
|
/// <param name="logger">Logger for host-side compilation/execution diagnostics.</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>
|
|
public CalculationEvaluator(
|
|
ILogger<CalculationEvaluator> logger,
|
|
ScriptRootLogger scriptRoot,
|
|
TimeSpan? runTimeout = null)
|
|
{
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
_scriptRoot = (scriptRoot ?? throw new ArgumentNullException(nameof(scriptRoot))).Logger;
|
|
_runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
|
|
}
|
|
|
|
/// <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>
|
|
public VirtualTagEvalResult Evaluate(string calcTagId, string scriptSource, IReadOnlyDictionary<string, object?> dependencies)
|
|
{
|
|
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))
|
|
{
|
|
return dependencies.TryGetValue(passthroughRef, out var ptValue)
|
|
? VirtualTagEvalResult.Ok(ptValue)
|
|
: VirtualTagEvalResult.Ok(null);
|
|
}
|
|
|
|
var readCache = BuildReadCache(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);
|
|
|
|
// 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)
|
|
{
|
|
_logger.LogWarning(ex, "Calculation {Id}: Roslyn compile failed", calcTagId);
|
|
return VirtualTagEvalResult.Failure($"compile error: {ex.Message}");
|
|
}
|
|
catch (ScriptSandboxViolationException ex)
|
|
{
|
|
_logger.LogWarning(ex, "Calculation {Id}: sandbox violation", calcTagId);
|
|
return VirtualTagEvalResult.Failure($"sandbox violation: {ex.Message}");
|
|
}
|
|
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 TimedScriptEvaluator (Task.Run + WaitAsync): a raw CancellationToken can't
|
|
// interrupt a CPU-bound/infinite-loop Roslyn script, so the timed wrapper is what bounds a
|
|
// runaway script to the wall-clock budget instead of wedging the driver's dispatch.
|
|
var timed = new TimedScriptEvaluator<VirtualTagContext, object?>(evaluator, _runTimeout);
|
|
var raw = timed.RunAsync(context).GetAwaiter().GetResult();
|
|
return VirtualTagEvalResult.Ok(raw);
|
|
}
|
|
catch (ScriptTimeoutException)
|
|
{
|
|
return VirtualTagEvalResult.Failure($"script timed out after {_runTimeout.TotalSeconds:F1}s");
|
|
}
|
|
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("evaluator disposed during evaluation");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Calculation {Id}: script execution threw", calcTagId);
|
|
return VirtualTagEvalResult.Failure($"script threw: {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void ClearCompiledScripts() => _cache.Clear();
|
|
|
|
private static IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(
|
|
IReadOnlyDictionary<string, object?> deps)
|
|
{
|
|
// VirtualTagContext.GetTag returns a DataValueSnapshot — wrap each raw dep value as Good-quality
|
|
// so the script's `(int)ctx.GetTag("a").Value` pattern works. Null values stay null.
|
|
var nowUtc = DateTime.UtcNow;
|
|
var cache = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
|
|
foreach (var kv in deps)
|
|
{
|
|
cache[kv.Key] = new DataValueSnapshot(kv.Value, StatusCode: 0u, SourceTimestampUtc: nowUtc, ServerTimestampUtc: nowUtc);
|
|
}
|
|
return cache;
|
|
}
|
|
|
|
/// <summary>Disposes the evaluator, draining + disposing every cached compile's collectible ALC.</summary>
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
_cache.Dispose();
|
|
}
|
|
}
|