feat(host): F8b RoslynVirtualTagEvaluator — production virtual-tag eval
RoslynVirtualTagEvaluator wraps Core.Scripting.ScriptEvaluator + Core .VirtualTags.VirtualTagContext into a single-tag IVirtualTagEvaluator adapter. Caches the compiled ScriptEvaluator per unique expression so the second-and-onwards Evaluate is an in-process method call against the dependency dictionary. Compile/sandbox/runtime errors all surface as VirtualTagEvalResult.Failure rather than propagating exceptions through the VirtualTagActor message loop. Single-tag scope: cross-tag ctx.SetVirtualTag writes are dropped + logged because fan-out between actors is owned by DependencyMuxActor. Cycle detection + cascade ordering stay in Core.VirtualTags.VirtualTagEngine where they belong (loaded fleet-wide); this adapter keeps the actor message handler simple. Host adds Core.Scripting + Core.VirtualTags project refs, plus a TargetWarningsAsErrors NU1608 suppression — Microsoft.CodeAnalysis.CSharp .Scripting 4.12.0 pins Common to 4.12.0 but ASP.NET Core transitively brings Microsoft.CodeAnalysis.Common 5.0.0; the surface we use is stable across the drift (verified by Core.Scripting.Tests). Program.cs binds RoslynVirtualTagEvaluator → IVirtualTagEvaluator on driver-role hosts, replacing the F8-default NullVirtualTagEvaluator so VirtualTagActor evaluates real user scripts at runtime. 6 new adapter tests cover: simple expression sums, cache reuse across calls, compile-error denial, runtime-throw denial, empty-expression denial, post-dispose denial. Host.IntegrationTests now 10/10 green. Closes #79. F9b + #107 next.
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
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;
|
||||
using SerilogLog = Serilog.Log;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Engines;
|
||||
|
||||
/// <summary>
|
||||
/// F8b — production <see cref="IVirtualTagEvaluator"/> binding. Compiles each unique
|
||||
/// expression once via <see cref="ScriptEvaluator{TContext, TResult}"/> (Roslyn-backed
|
||||
/// sandbox) and caches the resulting evaluator keyed by source. Subsequent evaluations are
|
||||
/// in-process method invocations on the dependency dictionary — fast enough to run inline
|
||||
/// inside the actor's message handler.
|
||||
///
|
||||
/// Single-tag mode: cross-tag <c>ctx.SetVirtualTag</c> writes are dropped (logged) because
|
||||
/// fan-out between actors is owned by <c>DependencyMuxActor</c>, not by the eval engine.
|
||||
/// Cycle detection + cascade ordering live in <see cref="VirtualTagEngine"/>; this adapter
|
||||
/// stays single-tag scoped to keep <see cref="VirtualTagActor"/>'s message loop simple.
|
||||
/// </summary>
|
||||
public sealed class RoslynVirtualTagEvaluator : IVirtualTagEvaluator, IDisposable
|
||||
{
|
||||
private static readonly SerilogLogger ScriptLogger = SerilogLog.ForContext<RoslynVirtualTagEvaluator>();
|
||||
|
||||
private readonly ConcurrentDictionary<string, ScriptEvaluator<VirtualTagContext, object?>> _cache
|
||||
= new(StringComparer.Ordinal);
|
||||
private readonly ILogger<RoslynVirtualTagEvaluator> _logger;
|
||||
private readonly TimeSpan _runTimeout;
|
||||
private bool _disposed;
|
||||
|
||||
public RoslynVirtualTagEvaluator(ILogger<RoslynVirtualTagEvaluator> logger, TimeSpan? runTimeout = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
|
||||
}
|
||||
|
||||
public VirtualTagEvalResult Evaluate(string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
|
||||
{
|
||||
if (_disposed) return VirtualTagEvalResult.Failure("evaluator disposed");
|
||||
if (string.IsNullOrWhiteSpace(expression)) return VirtualTagEvalResult.Failure("empty expression");
|
||||
|
||||
ScriptEvaluator<VirtualTagContext, object?> evaluator;
|
||||
try
|
||||
{
|
||||
evaluator = _cache.GetOrAdd(expression, ScriptEvaluator<VirtualTagContext, object?>.Compile);
|
||||
}
|
||||
catch (CompilationErrorException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "VirtualTag {Id}: Roslyn compile failed", virtualTagId);
|
||||
return VirtualTagEvalResult.Failure($"compile error: {ex.Message}");
|
||||
}
|
||||
catch (ScriptSandboxViolationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "VirtualTag {Id}: sandbox violation", virtualTagId);
|
||||
return VirtualTagEvalResult.Failure($"sandbox violation: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "VirtualTag {Id}: compile threw", virtualTagId);
|
||||
return VirtualTagEvalResult.Failure($"compile failure: {ex.Message}");
|
||||
}
|
||||
|
||||
var readCache = BuildReadCache(dependencies);
|
||||
var context = new VirtualTagContext(
|
||||
readCache,
|
||||
setVirtualTag: (path, _) =>
|
||||
_logger.LogDebug("VirtualTag {Id}: cross-tag write to {Path} dropped (single-tag adapter)",
|
||||
virtualTagId, path),
|
||||
logger: ScriptLogger);
|
||||
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(_runTimeout);
|
||||
var raw = evaluator.RunAsync(context, cts.Token).GetAwaiter().GetResult();
|
||||
return VirtualTagEvalResult.Ok(raw);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return VirtualTagEvalResult.Failure($"script timed out after {_runTimeout.TotalSeconds:F1}s");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "VirtualTag {Id}: script execution threw", virtualTagId);
|
||||
return VirtualTagEvalResult.Failure($"script threw: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(
|
||||
IReadOnlyDictionary<string, object?> deps)
|
||||
{
|
||||
// VirtualTagContext.GetTag returns a DataValueSnapshot — we wrap each raw dep value
|
||||
// as Good-quality so the script's `(int)ctx.GetTag("a").Value` pattern works. Null
|
||||
// values stay null; the script can null-check via GetTag(path).Value.
|
||||
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;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
foreach (var ev in _cache.Values)
|
||||
{
|
||||
try { ev.Dispose(); } catch { /* best-effort */ }
|
||||
}
|
||||
_cache.Clear();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user