refactor(virtualtags): retire dormant VirtualTagEngine/Source/Definition + TimerTriggerScheduler (#603)

TimerTriggerScheduler was welded to the engine and has no production consumer.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 06:51:22 -04:00
parent dc54b883eb
commit a8703c6afe
21 changed files with 27 additions and 2181 deletions
@@ -5,8 +5,7 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Engines;
/// <summary>
/// Abstraction over the compiled virtual-tag expression engine. Runtime consumes this so
/// <see cref="VirtualTagActor"/> can stay free of Roslyn / scripting machinery and the
/// production wiring binds an adapter over <c>VirtualTagEngine</c> from
/// <c>Core.VirtualTags</c>.
/// production wiring binds <c>RoslynVirtualTagEvaluator</c> (Host).
/// </summary>
public interface IVirtualTagEvaluator
{
@@ -98,7 +97,7 @@ public static class ScriptEvalFailureReasons
}
/// <summary>Returns <see cref="VirtualTagEvalResult.NoChange"/> from every call. Bound by default
/// when the production <c>VirtualTagEngine</c> adapter hasn't been registered (Mac dev, tests).</summary>
/// when the production <c>RoslynVirtualTagEvaluator</c> hasn't been registered (Mac dev, tests).</summary>
public sealed class NullVirtualTagEvaluator : IVirtualTagEvaluator
{
public static readonly NullVirtualTagEvaluator Instance = new();
@@ -39,7 +39,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// </param>
/// <param name="VirtualTagId">
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.Virtual"/> — stable
/// logical id the VirtualTagEngine addresses by. Null otherwise.
/// logical id the virtual-tag runtime addresses by. Null otherwise.
/// </param>
/// <param name="ScriptedAlarmId">
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.ScriptedAlarm"/> —
@@ -78,7 +78,7 @@ public sealed record DriverAttributeInfo(
/// Discriminates which runtime subsystem owns this node's Read/Write/
/// Subscribe dispatch. <c>Driver</c> = a real IDriver capability surface;
/// <c>Virtual</c> = a Phase 7 <see cref="DriverAttributeInfo"/>.VirtualTagId'd tag
/// computed by the VirtualTagEngine; <c>ScriptedAlarm</c> = a scripted Part 9 alarm
/// computed by the virtual-tag runtime; <c>ScriptedAlarm</c> = a scripted Part 9 alarm
/// materialized by the ScriptedAlarmEngine.
/// </summary>
public enum NodeSourceKind
@@ -8,9 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// Per-evaluation <see cref="ScriptContext"/> for a virtual-tag script. Reads come
/// out of the engine's last-known-value cache (driver tags updated via the
/// <c>ITagUpstreamSource</c> subscription, virtual tags updated by prior
/// evaluations). Writes route through <c>VirtualTagEngine</c>'s
/// <c>OnScriptSetVirtualTag</c> callback so cross-tag write side effects still
/// participate in change-trigger cascades (via the engine's <c>CascadeAsync</c>).
/// evaluations). Writes route through the host-supplied set-virtual-tag callback.
/// </summary>
/// <remarks>
/// <para>
@@ -15,11 +15,8 @@ namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// from a last-known-value cache populated by the subscription callbacks.
/// </para>
/// <para>
/// The subscription path feeds <see cref="VirtualTagEngine"/>'s
/// <c>OnUpstreamChange</c> callback, which updates the engine's value cache and
/// schedules <c>CascadeAsync</c> to re-evaluate every change-driven dependent in
/// topological order. One subscription per distinct upstream tag path; the
/// engine tracks the mapping itself.
/// The subscription path feeds the consumer's change callback (scripted alarms via
/// <c>DependencyMuxTagUpstreamSource</c>). One subscription per distinct upstream tag path.
/// </para>
/// </remarks>
public interface ITagUpstreamSource
@@ -1,162 +0,0 @@
using Serilog;
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// <summary>
/// Periodic re-evaluation scheduler for tags with a non-null
/// <see cref="VirtualTagDefinition.TimerInterval"/>. Independent of the
/// change-trigger path — a tag can be timer-only, change-only, or both. One
/// <see cref="System.Threading.Timer"/> per interval-group keeps the wire count
/// low regardless of tag count.
/// </summary>
/// <remarks>
/// <para>
/// Each timer group carries a per-group in-flight flag (see
/// <c>TickGroup.InFlight</c>). When the timer fires while a tick for the same
/// group is still running, the new callback skips the work and increments
/// <see cref="SkippedTickCount"/> rather than blocking a thread-pool thread on
/// the engine's evaluation gate. This bounds the work outstanding at one tick
/// per group, regardless of how long an individual evaluation takes.
/// </para>
/// </remarks>
public sealed class TimerTriggerScheduler : IDisposable
{
private readonly VirtualTagEngine _engine;
private readonly ILogger _logger;
private readonly List<Timer> _timers = [];
private readonly List<TickGroup> _groups = [];
private readonly CancellationTokenSource _cts = new();
private long _skippedTickCount;
private bool _disposed;
private bool _started;
/// <summary>Initializes a new instance of the <see cref="TimerTriggerScheduler"/> class.</summary>
/// <param name="engine">The virtual tag engine to trigger evaluations on.</param>
/// <param name="logger">A logger for diagnostic output.</param>
public TimerTriggerScheduler(VirtualTagEngine engine, ILogger logger)
{
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// Diagnostic counter: number of timer callbacks that skipped their work because
/// the prior tick for the same group was still running. Exposed for tests +
/// operational metrics. Monotonic; never resets.
/// </summary>
public long SkippedTickCount => Interlocked.Read(ref _skippedTickCount);
/// <summary>
/// Stand up one <see cref="Timer"/> per unique interval. All tags with
/// matching interval share a timer; each tick triggers re-evaluation of the
/// group in topological order so cascades are consistent with change-triggered
/// behavior.
/// </summary>
/// <param name="definitions">The virtual tag definitions to schedule timers for.</param>
/// <exception cref="InvalidOperationException">Thrown when <c>Start</c> is called more than once on the same instance.</exception>
public void Start(IReadOnlyList<VirtualTagDefinition> definitions)
{
if (_disposed) throw new ObjectDisposedException(nameof(TimerTriggerScheduler));
if (_started) throw new InvalidOperationException(
"TimerTriggerScheduler.Start has already been called. Create a new instance for each Load cycle.");
_started = true;
var byInterval = definitions
.Where(d => d.TimerInterval.HasValue && d.TimerInterval.Value > TimeSpan.Zero)
.GroupBy(d => d.TimerInterval!.Value);
foreach (var group in byInterval)
{
var paths = group.Select(d => d.Path).ToArray();
var interval = group.Key;
var ctx = new TickGroup(paths);
_groups.Add(ctx);
var timer = new Timer(_ => OnTimer(ctx), null, interval, interval);
_timers.Add(timer);
_logger.Information("TimerTriggerScheduler: {TagCount} tag(s) on {Interval} cadence",
paths.Length, interval);
}
}
private void OnTimer(TickGroup ctx)
{
if (_cts.IsCancellationRequested) return;
// Skip the tick when the prior one for this group is still running. Without
// this guard a slow evaluation (or one waiting on the engine's _evalGate) would
// cause subsequent timer callbacks to each pin a thread-pool thread on the
// gate, compounding under high tick rates.
if (Interlocked.CompareExchange(ref ctx.InFlight, 1, 0) != 0)
{
Interlocked.Increment(ref _skippedTickCount);
return;
}
// Run async without blocking the timer's pool-thread callback. The task is
// fire-and-forget — failures are logged inside RunTickAsync; the InFlight flag
// is reset in the finally block so the next tick can proceed.
_ = RunTickAsync(ctx);
}
private async Task RunTickAsync(TickGroup ctx)
{
try
{
foreach (var p in ctx.Paths)
{
if (_cts.IsCancellationRequested) return;
try
{
await _engine.EvaluateOneAsync(p, _cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
catch (Exception ex)
{
_logger.Error(ex, "TimerTriggerScheduler evaluate failed for {Path}", p);
}
}
}
finally
{
Interlocked.Exchange(ref ctx.InFlight, 0);
}
}
/// <summary>Releases all timers and disposes the scheduler's resources.</summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_cts.Cancel();
foreach (var t in _timers)
{
try { t.Dispose(); } catch { }
}
_timers.Clear();
_groups.Clear();
_cts.Dispose();
}
/// <summary>Groups tags that share the same timer interval.</summary>
private sealed class TickGroup
{
// 0 = idle, 1 = a tick is currently running (or queued) for this group. Use
// Interlocked.CompareExchange so a timer callback observes a consistent "is the
// prior tick still running" answer without taking a lock.
/// <summary>Flag indicating whether a tick is currently in flight for this group.</summary>
public int InFlight;
/// <summary>Gets the list of tag paths in this group.</summary>
public IReadOnlyList<string> Paths { get; }
/// <summary>Initializes a new instance of the <see cref="TickGroup"/> class.</summary>
/// <param name="paths">The tag paths in this timer group.</param>
public TickGroup(IReadOnlyList<string> paths)
{
Paths = paths;
}
}
}
@@ -1,41 +0,0 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// <summary>
/// Operator-authored virtual-tag configuration row. Phase 7 Stream E (config DB
/// schema) materializes these from the <c>VirtualTag</c> + <c>Script</c> tables on
/// publish; the engine ingests a list of them at load time.
/// </summary>
/// <param name="Path">
/// UNS tag path — <c>Enterprise/Site/Area/Line/Equipment/TagName</c>. Used both as
/// the engine's internal id and the OPC UA browse path.
/// </param>
/// <param name="DataType">
/// Expected return type. The evaluator coerces the script's return value to this
/// type before publishing; mismatch surfaces as <c>BadTypeMismatch</c> quality on
/// the tag.
/// </param>
/// <param name="ScriptSource">Roslyn C# script source. Must compile under <c>ScriptSandbox</c>.</param>
/// <param name="ChangeTriggered">
/// True if any input tag's change (value / status / timestamp delta) should trigger
/// re-evaluation. Operator picks per tag — usually true for inputs that change at
/// protocol rates.
/// </param>
/// <param name="TimerInterval">
/// Optional periodic re-evaluation cadence. Null = timer-driven disabled. Both can
/// be enabled simultaneously; independent scheduling paths both end at
/// <see cref="VirtualTagEngine"/>'s <c>EvaluateInternalAsync</c>.
/// </param>
/// <param name="Historize">
/// When true, every evaluation result is forwarded to the configured
/// <see cref="IHistoryWriter"/>. Operator-set per tag; the Admin UI exposes as a
/// checkbox.
/// </param>
public sealed record VirtualTagDefinition(
string Path,
DriverDataType DataType,
string ScriptSource,
bool ChangeTriggered = true,
TimeSpan? TimerInterval = null,
bool Historize = false);
@@ -1,568 +0,0 @@
using System.Collections.Concurrent;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// <summary>
/// The Phase 7 virtual-tag evaluation engine. Ingests a set of
/// <see cref="VirtualTagDefinition"/>s at load time, compiles each script against
/// <see cref="ScriptSandbox"/>, builds the dependency graph, subscribes to every
/// referenced upstream tag, and schedules re-evaluations on change + on timer.
/// </summary>
public sealed class VirtualTagEngine : IDisposable
{
private readonly ITagUpstreamSource _upstream;
private readonly IHistoryWriter _history;
private readonly ScriptLoggerFactory _loggerFactory;
private readonly ILogger _engineLogger;
private readonly Func<DateTime> _clock;
private readonly TimeSpan _scriptTimeout;
private readonly DependencyGraph _graph = new();
private readonly Dictionary<string, VirtualTagState> _tags = new(StringComparer.Ordinal);
/// <summary>
/// Compile cache for every virtual-tag script. Routes <see cref="Load"/>'s
/// <see cref="ScriptEvaluator{TContext, TResult}.Compile"/> calls through the
/// cache so the collectible <see cref="System.Runtime.Loader.AssemblyLoadContext"/>
/// each compile produces is actually disposed on the publish-replace path: the
/// cache's <see cref="CompiledScriptCache{TContext, TResult}.Clear"/> disposes
/// every materialised evaluator before dropping its dictionary entry, so a
/// config-publish releases the prior generation's ALCs and the per-publish
/// accretion is actually freed in production. Pre-fix, the engine called
/// <c>ScriptEvaluator.Compile</c> directly, which left the ALCs rooted until
/// the process exited.
/// </summary>
private readonly CompiledScriptCache<VirtualTagContext, object?> _compileCache = new();
private readonly ConcurrentDictionary<string, DataValueSnapshot> _valueCache = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, List<Action<string, DataValueSnapshot>>> _observers
= new(StringComparer.Ordinal);
private readonly List<IDisposable> _upstreamSubscriptions = [];
private readonly SemaphoreSlim _evalGate = new(1, 1);
private bool _loaded;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="VirtualTagEngine"/> class.
/// </summary>
/// <param name="upstream">The upstream tag source for reading input tags.</param>
/// <param name="loggerFactory">Factory for creating script loggers.</param>
/// <param name="engineLogger">Logger for engine events and errors.</param>
/// <param name="historyWriter">Optional history writer for recording tag evaluations; defaults to no-op.</param>
/// <param name="clock">Optional function providing current UTC time; defaults to <see cref="DateTime.UtcNow"/>.</param>
/// <param name="scriptTimeout">Optional timeout for script evaluation; defaults to <see cref="TimedScriptEvaluator{TContext, TResult}.DefaultTimeout"/>.</param>
public VirtualTagEngine(
ITagUpstreamSource upstream,
ScriptLoggerFactory loggerFactory,
ILogger engineLogger,
IHistoryWriter? historyWriter = null,
Func<DateTime>? clock = null,
TimeSpan? scriptTimeout = null)
{
_upstream = upstream ?? throw new ArgumentNullException(nameof(upstream));
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
_engineLogger = engineLogger ?? throw new ArgumentNullException(nameof(engineLogger));
_history = historyWriter ?? NullHistoryWriter.Instance;
_clock = clock ?? (() => DateTime.UtcNow);
_scriptTimeout = scriptTimeout ?? TimedScriptEvaluator<VirtualTagContext, object?>.DefaultTimeout;
}
/// <summary>Registered tag paths, in topological order. Empty before <see cref="Load"/>.</summary>
public IReadOnlyCollection<string> LoadedTagPaths => _tags.Keys;
/// <summary>Compile + register every tag in <paramref name="definitions"/>. Throws on cycle or any compile failure.</summary>
/// <param name="definitions">List of virtual tag definitions to load and compile.</param>
public void Load(IReadOnlyList<VirtualTagDefinition> definitions)
{
if (_disposed) throw new ObjectDisposedException(nameof(VirtualTagEngine));
if (definitions is null) throw new ArgumentNullException(nameof(definitions));
// Start from a clean slate — supports config-publish reloads.
UnsubscribeFromUpstream();
_tags.Clear();
_graph.Clear();
_compileCache.Clear();
var compileFailures = new List<string>();
var seenPaths = new HashSet<string>(StringComparer.Ordinal);
foreach (var def in definitions)
{
if (!seenPaths.Add(def.Path))
{
compileFailures.Add($"{def.Path}: duplicate path — only one definition per path is allowed");
continue;
}
if (!IsSupportedDataType(def.DataType))
{
compileFailures.Add(
$"{def.Path}: unsupported DataType DriverDataType.{def.DataType} — virtual tags only support scalar primitive types");
continue;
}
try
{
var extraction = DependencyExtractor.Extract(def.ScriptSource);
if (!extraction.IsValid)
{
var msgs = string.Join("; ", extraction.Rejections.Select(r => r.Message));
compileFailures.Add($"{def.Path}: dependency extraction rejected — {msgs}");
continue;
}
var evaluator = _compileCache.GetOrCompile(def.ScriptSource);
var timed = new TimedScriptEvaluator<VirtualTagContext, object?>(evaluator, _scriptTimeout);
var scriptLogger = _loggerFactory.Create(def.Path);
_tags[def.Path] = new VirtualTagState(def, timed, extraction.Reads, extraction.Writes, scriptLogger);
_graph.Add(def.Path, extraction.Reads);
}
catch (Exception ex)
{
compileFailures.Add($"{def.Path}: {ex.Message}");
}
}
// Validate every ctx.SetVirtualTag write target resolves to a registered virtual
// tag. A script writing to a non-existent virtual path would otherwise be silently
// dropped at runtime by OnScriptSetVirtualTag's warning-and-drop branch; catching
// it here surfaces operator typos as a publish failure.
foreach (var (path, state) in _tags)
{
foreach (var writeTarget in state.Writes)
{
if (!_tags.ContainsKey(writeTarget))
{
compileFailures.Add(
$"{path}: ctx.SetVirtualTag target '{writeTarget}' is not a registered virtual tag");
}
}
}
if (compileFailures.Count > 0)
{
var joined = string.Join("\n ", compileFailures);
throw new InvalidOperationException(
$"Virtual-tag engine load failed. {compileFailures.Count} script(s) did not compile:\n {joined}");
}
// Cycle check — throws DependencyCycleException on offense.
_ = _graph.TopologicalSort();
// Subscribe to every referenced upstream path (driver tags only — virtual tags
// cascade internally). Seed the cache with current upstream values so first
// evaluations see something real. Iterate _tags.Values (the registered set) rather
// than definitions to avoid indexing by a raw input list that may contain duplicates.
var upstreamPaths = _tags.Values
.SelectMany(s => s.Reads)
.Where(p => !_tags.ContainsKey(p))
.Distinct(StringComparer.Ordinal);
foreach (var path in upstreamPaths)
{
_valueCache[path] = _upstream.ReadTag(path);
_upstreamSubscriptions.Add(_upstream.SubscribeTag(path, OnUpstreamChange));
}
_loaded = true;
_engineLogger.Information(
"VirtualTagEngine loaded {TagCount} tag(s), {UpstreamCount} upstream subscription(s)",
_tags.Count, _upstreamSubscriptions.Count);
}
/// <summary>
/// Evaluate every registered tag once in topological order — used at startup so
/// virtual tags have a defined initial value rather than inheriting the cache
/// default. Also called after a config reload.
/// </summary>
/// <param name="ct">Cancellation token to stop evaluation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task EvaluateAllAsync(CancellationToken ct = default)
{
EnsureLoaded();
var order = _graph.TopologicalSort();
foreach (var path in order)
{
if (_tags.ContainsKey(path))
await EvaluateOneAsync(path, ct).ConfigureAwait(false);
}
}
/// <summary>Evaluate a single tag — used by the timer trigger + test hooks.</summary>
/// <param name="path">Path of the virtual tag to evaluate.</param>
/// <param name="ct">Cancellation token to stop evaluation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task EvaluateOneAsync(string path, CancellationToken ct = default)
{
EnsureLoaded();
if (!_tags.ContainsKey(path))
throw new ArgumentException($"Not a registered virtual tag: {path}", nameof(path));
return EvaluateInternalAsync(path, ct);
}
/// <summary>
/// Read the most recently evaluated value for <paramref name="path"/>. Driver
/// tags return the last-known upstream value; virtual tags return their last
/// evaluation result.
/// </summary>
/// <param name="path">Path of the tag to read.</param>
/// <returns>The tag's most recently evaluated value, or a Bad-quality snapshot if the path is unrecognized.</returns>
public DataValueSnapshot Read(string path)
{
if (string.IsNullOrWhiteSpace(path))
return new DataValueSnapshot(null, 0x80340000u, null, _clock());
return _valueCache.TryGetValue(path, out var v)
? v
: new DataValueSnapshot(null, 0x80340000u /* BadNodeIdUnknown */, null, _clock());
}
/// <summary>
/// Register an observer that fires on every evaluation of the given tag.
/// Returns an <see cref="IDisposable"/> to unsubscribe. Does NOT fire a seed
/// value — subscribers call <see cref="Read"/> for the current value if needed.
/// </summary>
/// <param name="path">Path of the tag to subscribe to.</param>
/// <param name="observer">Callback invoked with the tag path and new value on each evaluation.</param>
/// <returns>An <see cref="IDisposable"/> that removes the observer when disposed.</returns>
public IDisposable Subscribe(string path, Action<string, DataValueSnapshot> observer)
{
// Race-safe pattern paired with Unsub.Dispose: if Unsub.Dispose removed the
// dictionary entry between our GetOrAdd and the lock-protected Add, the list
// we hold a reference to is orphaned. Re-check the map under the lock and
// re-insert the list (or grab the current one) if needed, retrying until the
// dictionary observably contains the list we just added our observer to.
while (true)
{
var list = _observers.GetOrAdd(path, _ => []);
lock (list)
{
// Confirm the list is still the dictionary's value for this key. If
// Dispose removed the entry, _observers[path] either doesn't exist or
// points at a different (newer) list — retry.
if (_observers.TryGetValue(path, out var current) && ReferenceEquals(current, list))
{
list.Add(observer);
return new Unsub(this, path, observer);
}
}
// Lost the race — Dispose pruned the list out from under us. Loop and
// either re-create or pick up the newer list.
}
}
/// <summary>
/// Change-trigger entry point — called by the upstream subscription callback.
/// Updates the cache, fans out to observers (so OPC UA clients see the upstream
/// change too if they subscribed via the engine), and schedules every
/// change-triggered dependent for re-evaluation in topological order.
/// </summary>
/// <param name="path">Path of the upstream tag that changed.</param>
/// <param name="value">New value of the tag.</param>
internal void OnUpstreamChange(string path, DataValueSnapshot value)
{
_valueCache[path] = value;
NotifyObservers(path, value);
// Fire-and-forget — the upstream subscription callback must not block the
// driver's dispatcher. Exceptions during cascade are handled per-tag inside
// EvaluateInternalAsync.
_ = CascadeAsync(path, CancellationToken.None);
}
private async Task CascadeAsync(string upstreamPath, CancellationToken ct)
{
try
{
var dependents = _graph.TransitiveDependentsInOrder(upstreamPath);
foreach (var dep in dependents)
{
if (_tags.TryGetValue(dep, out var state) && state.Definition.ChangeTriggered)
await EvaluateInternalAsync(dep, ct).ConfigureAwait(false);
}
}
catch (Exception ex)
{
_engineLogger.Error(ex, "VirtualTagEngine cascade failed for upstream {Path}", upstreamPath);
}
}
private async Task EvaluateInternalAsync(string path, CancellationToken ct)
{
if (!_tags.TryGetValue(path, out var state)) return;
await _evalGate.WaitAsync(ct).ConfigureAwait(false);
try
{
var ctxCache = BuildReadCache(state.Reads);
// Cold-start guard — when any upstream input is still unset or Bad-quality,
// publish a BadWaitingForInitialData snapshot so OPC UA clients see a defined
// quality rather than observing "not yet computed" as a stale Good value.
// Evaluating with nulls would throw inside the script (scripts cast
// ctx.GetTag(path).Value directly) and produce a persistent BadInternalError.
if (!AreInputsReady(ctxCache))
{
var notReady = new DataValueSnapshot(null, 0x80320000u /* BadWaitingForInitialData */, null, _clock());
_valueCache[path] = notReady;
NotifyObservers(path, notReady);
return;
}
var context = new VirtualTagContext(
ctxCache,
(p, v) => OnScriptSetVirtualTag(p, v),
state.Logger,
_clock);
DataValueSnapshot result;
try
{
var raw = await state.Evaluator.RunAsync(context, ct).ConfigureAwait(false);
var coerced = CoerceResult(raw, state.Definition.DataType);
// null from CoerceResult means the conversion threw (raw was non-null but
// not convertible to the declared type). Surface as BadInternalError so
// the OPC UA client sees a defined Bad quality rather than a Good null.
result = (raw is not null && coerced is null)
? new DataValueSnapshot(null, 0x80020000u /* BadInternalError */, null, _clock())
: new DataValueSnapshot(coerced, 0u, _clock(), _clock());
}
catch (ScriptTimeoutException tex)
{
state.Logger.Warning("Script timed out after {Timeout}", tex.Timeout);
result = new DataValueSnapshot(null, 0x80020000u /* BadInternalError */, null, _clock());
}
catch (OperationCanceledException)
{
throw; // shutdown path — don't misclassify
}
catch (Exception ex)
{
state.Logger.Error(ex, "Virtual-tag script threw");
result = new DataValueSnapshot(null, 0x80020000u /* BadInternalError */, null, _clock());
}
_valueCache[path] = result;
NotifyObservers(path, result);
if (state.Definition.Historize) _history.Record(path, result);
}
finally
{
_evalGate.Release();
}
}
private IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(IReadOnlySet<string> reads)
{
var map = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
foreach (var r in reads)
{
map[r] = _valueCache.TryGetValue(r, out var v)
? v
: _upstream.ReadTag(r);
}
return map;
}
/// <summary>
/// Returns true when every entry in <paramref name="cache"/> has a
/// Good/Uncertain-quality <see cref="DataValueSnapshot.StatusCode"/> (bit 31 = 0).
/// Readiness is gated on <c>StatusCode</c> alone — a Good-quality snapshot with a
/// null <c>Value</c> (e.g. a script that returns <c>null</c>, or a
/// <c>ctx.SetVirtualTag</c> call with a null argument) is considered ready and the
/// downstream script is allowed to run. The downstream script will observe the null
/// value and typically throw an NRE, which the outer evaluation catch maps to
/// <c>BadInternalError</c> — the correct sentinel for "script ran but faulted"
/// rather than "inputs not yet available". Previously the null-value check
/// caused a Good-quality null upstream to permanently block all its dependents at
/// <c>BadWaitingForInitialData</c>.
/// </summary>
private static bool AreInputsReady(IReadOnlyDictionary<string, DataValueSnapshot> cache)
{
foreach (var kv in cache)
{
if (kv.Value is null) return false;
if ((kv.Value.StatusCode & 0x80000000u) != 0) return false;
}
return true;
}
private void OnScriptSetVirtualTag(string path, object? value)
{
if (!_tags.ContainsKey(path))
{
_engineLogger.Warning(
"Script attempted ctx.SetVirtualTag on non-virtual or non-registered path {Path}", path);
return;
}
var snap = new DataValueSnapshot(value, 0u, _clock(), _clock());
_valueCache[path] = snap;
NotifyObservers(path, snap);
if (_tags[path].Definition.Historize) _history.Record(path, snap);
// A cross-tag write must participate in the change-trigger cascade, exactly
// like an upstream delta — any change-triggered tag that reads this path
// would otherwise go stale until an unrelated trigger fires (see
// docs/VirtualTags.md, VirtualTagContext section). Fire-and-forget: this
// callback runs inside EvaluateInternalAsync with the non-reentrant
// _evalGate held, so the cascade must be scheduled, not invoked inline.
_ = CascadeAsync(path, CancellationToken.None);
}
private void NotifyObservers(string path, DataValueSnapshot value)
{
if (!_observers.TryGetValue(path, out var list)) return;
Action<string, DataValueSnapshot>[] snapshot;
lock (list) { snapshot = list.ToArray(); }
foreach (var obs in snapshot)
{
try { obs(path, value); }
catch (Exception ex)
{
_engineLogger.Warning(ex, "Virtual-tag observer for {Path} threw", path);
}
}
}
private static object? CoerceResult(object? raw, DriverDataType target)
{
if (raw is null) return null;
try
{
return target switch
{
DriverDataType.Boolean => Convert.ToBoolean(raw),
DriverDataType.Int16 => Convert.ToInt16(raw),
DriverDataType.Int32 => Convert.ToInt32(raw),
DriverDataType.Int64 => Convert.ToInt64(raw),
DriverDataType.UInt16 => Convert.ToUInt16(raw),
DriverDataType.UInt32 => Convert.ToUInt32(raw),
DriverDataType.UInt64 => Convert.ToUInt64(raw),
DriverDataType.Float32 => Convert.ToSingle(raw),
DriverDataType.Float64 => Convert.ToDouble(raw),
DriverDataType.String => Convert.ToString(raw) ?? string.Empty,
DriverDataType.DateTime => raw is DateTime dt ? dt : Convert.ToDateTime(raw),
// Any DriverDataType not in the explicit list (currently Reference, or any
// future enum member added without coercion support) must NOT silently
// return the uncoerced raw value — that would surface as a wire-level
// type mismatch on the OPC UA Variant. Throwing here is caught by the
// outer catch and mapped to BadInternalError. Load-time validation in
// IsSupportedDataType ensures operators never publish such a tag.
_ => throw new InvalidOperationException(
$"Virtual-tag CoerceResult does not support DriverDataType.{target}"),
};
}
catch
{
// Caller maps null to BadInternalError (0x80020000) — we let null propagate
// so the outer evaluation path sets the Bad quality on the snapshot.
return null;
}
}
/// <summary>
/// The set of <see cref="DriverDataType"/> values <see cref="CoerceResult"/> can
/// honour. Definitions declaring any other type are rejected at <see cref="Load"/>
/// so an operator typo (or a future enum member added without coercion support) is
/// caught at publish time rather than silently producing a type-mismatched value.
/// </summary>
private static bool IsSupportedDataType(DriverDataType t) => t switch
{
DriverDataType.Boolean => true,
DriverDataType.Int16 => true,
DriverDataType.Int32 => true,
DriverDataType.Int64 => true,
DriverDataType.UInt16 => true,
DriverDataType.UInt32 => true,
DriverDataType.UInt64 => true,
DriverDataType.Float32 => true,
DriverDataType.Float64 => true,
DriverDataType.String => true,
DriverDataType.DateTime => true,
_ => false,
};
private void UnsubscribeFromUpstream()
{
foreach (var s in _upstreamSubscriptions)
{
try { s.Dispose(); } catch { /* best effort */ }
}
_upstreamSubscriptions.Clear();
}
private void EnsureLoaded()
{
if (!_loaded) throw new InvalidOperationException(
"VirtualTagEngine not loaded. Call Load(definitions) first.");
}
/// <summary>
/// Releases all resources held by the engine, including upstream subscriptions and compiled script caches.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
UnsubscribeFromUpstream();
_tags.Clear();
_graph.Clear();
_compileCache.Dispose();
}
/// <summary>
/// Gets the dependency graph for testing purposes.
/// </summary>
internal DependencyGraph GraphForTesting => _graph;
private sealed class Unsub : IDisposable
{
private readonly VirtualTagEngine _engine;
private readonly string _path;
private readonly Action<string, DataValueSnapshot> _observer;
/// <summary>
/// Initializes a new instance of the <see cref="Unsub"/> class.
/// </summary>
/// <param name="e">The parent virtual tag engine.</param>
/// <param name="path">Path of the subscribed tag.</param>
/// <param name="observer">The observer callback to unsubscribe.</param>
public Unsub(VirtualTagEngine e, string path, Action<string, DataValueSnapshot> observer)
{
_engine = e; _path = path; _observer = observer;
}
/// <summary>
/// Removes the observer from the subscription list.
/// </summary>
public void Dispose()
{
if (_engine._observers.TryGetValue(_path, out var list))
{
lock (list)
{
list.Remove(_observer);
// If we removed the last observer, prune the dictionary entry so a
// long-running server doesn't accumulate empty Lists for paths that
// saw transient subscriptions. The emptiness check is inside the same
// lock so a concurrent Subscribe can't slip an observer in after we
// observe the list as empty.
if (list.Count == 0)
{
// ICollection<KeyValuePair<,>> removal is value-typed — only removes
// if both key + value still match (i.e. the dictionary still points
// at this list). This keeps a racing Subscribe's brand-new list safe.
((ICollection<KeyValuePair<string, List<Action<string, DataValueSnapshot>>>>)_engine._observers)
.Remove(new KeyValuePair<string, List<Action<string, DataValueSnapshot>>>(_path, list));
}
}
}
}
}
internal sealed record VirtualTagState(
VirtualTagDefinition Definition,
TimedScriptEvaluator<VirtualTagContext, object?> Evaluator,
IReadOnlySet<string> Reads,
IReadOnlySet<string> Writes,
ILogger Logger);
}
@@ -1,106 +0,0 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// <summary>
/// Implements the driver-agnostic capability surface the
/// <c>DriverNodeManager</c> dispatches to when a node resolves to
/// <c>NodeSource.Virtual</c>. Reads return the engine's last-known
/// evaluation result; subscriptions forward engine-emitted change events as
/// <see cref="ISubscribable.OnDataChange"/> events.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="IWritable"/> is deliberately not implemented — OPC UA client
/// writes to virtual tags are rejected in <c>DriverNodeManager</c> before they
/// reach here. Scripts are the only write path, routed
/// through <c>ctx.SetVirtualTag</c>.
/// </para>
/// </remarks>
public sealed class VirtualTagSource : IReadable, ISubscribable
{
private readonly VirtualTagEngine _engine;
private readonly ConcurrentDictionary<string, Subscription> _subs = new(StringComparer.Ordinal);
/// <summary>
/// Initializes a new instance of the <see cref="VirtualTagSource"/> class.
/// </summary>
/// <param name="engine">The virtual tag engine.</param>
public VirtualTagSource(VirtualTagEngine engine)
{
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
}
/// <summary>
/// Occurs when data values change.
/// </summary>
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <inheritdoc />
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
if (fullReferences is null) throw new ArgumentNullException(nameof(fullReferences));
var results = new DataValueSnapshot[fullReferences.Count];
for (var i = 0; i < fullReferences.Count; i++)
results[i] = _engine.Read(fullReferences[i]);
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(results);
}
/// <inheritdoc />
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences,
TimeSpan publishingInterval,
CancellationToken cancellationToken)
{
if (fullReferences is null) throw new ArgumentNullException(nameof(fullReferences));
var handle = new SubscriptionHandle(Guid.NewGuid().ToString("N"));
var observers = new List<IDisposable>(fullReferences.Count);
// OPC UA convention: for each path, emit the initial-data callback BEFORE
// registering the change observer. This prevents a race where an upstream change
// fires the observer between the Subscribe call and the Read call, which would
// deliver a newer change event before the initial-data event, leaving the client
// with a stale last-known value.
foreach (var path in fullReferences)
{
var snap = _engine.Read(path);
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, path, snap));
observers.Add(_engine.Subscribe(path, (p, s) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, p, s))));
}
_subs[handle.DiagnosticId] = new Subscription(handle, observers);
return Task.FromResult<ISubscriptionHandle>(handle);
}
/// <inheritdoc />
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
if (handle is null) throw new ArgumentNullException(nameof(handle));
if (_subs.TryRemove(handle.DiagnosticId, out var sub))
{
foreach (var d in sub.Observers)
{
try { d.Dispose(); } catch { }
}
}
return Task.CompletedTask;
}
private sealed class SubscriptionHandle : ISubscriptionHandle
{
/// <summary>
/// Initializes a new instance of the <see cref="SubscriptionHandle"/> class.
/// </summary>
/// <param name="id">The subscription identifier.</param>
public SubscriptionHandle(string id) { DiagnosticId = id; }
/// <inheritdoc />
public string DiagnosticId { get; }
}
private sealed record Subscription(SubscriptionHandle Handle, IReadOnlyList<IDisposable> Observers);
}
@@ -3,12 +3,10 @@ using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
/// <summary>
/// Periodic re-evaluation scheduler for calc tags carrying a <c>timerIntervalMs</c>. A deliberate
/// mirror of the VirtualTag <c>TimerTriggerScheduler</c> (<c>Core.VirtualTags</c>): one
/// Periodic re-evaluation scheduler for calc tags carrying a <c>timerIntervalMs</c>. One
/// <see cref="System.Threading.Timer"/> per distinct interval group (so the wire count stays low
/// regardless of tag count) with a per-group in-flight flag that skips a tick when the prior tick for
/// the same group is still running. Revived <b>inside the driver</b> because the VT scheduler is welded
/// to <c>VirtualTagEngine.EvaluateOneAsync</c>; here each tick invokes the driver's own evaluate
/// the same group is still running. Each tick invokes the driver's own evaluate
/// callback per tag in the group.
/// </summary>
internal sealed class CalculationTimerScheduler : IDisposable
@@ -22,7 +22,7 @@ public sealed class LastValueCache
{
// OPC UA BadWaitingForInitialData (0x80320000) — the repo-wide convention for "no value has
// been observed yet" (mirrored by CalculationDriver before its first evaluation,
// VirtualTagEngine for a freshly-materialised node, and OtOpcUaNodeManager /
// and OtOpcUaNodeManager /
// AddressSpaceApplier for a just-deployed variable). MQTT is subscribe-first, so an unseen
// RawPath is in exactly that state until its first publish arrives. Deliberately NOT
// GoodNoData — that code is reserved (see NullHistorianDataSource, OtOpcUaNodeManager
@@ -347,7 +347,7 @@ public sealed class ScriptAnalysisService
: $"**Tag path** `{Code(info.Path)}`\n\n{info.Kind} · Type **{info.DataType}**"
+ (info.DriverInstanceId is null ? "" : $" · Driver `{Code(info.DriverInstanceId)}`");
// Truthfulness: ctx.SetVirtualTag(...) is a no-op in the live single-tag evaluator
// (RoslynVirtualTagEvaluator drops cross-tag writes; the cascade VirtualTagEngine is dormant).
// (RoslynVirtualTagEvaluator drops cross-tag writes).
if (isSetVirtualTag)
tagMd += "\n\n⚠ Cross-tag `SetVirtualTag` writes are currently dropped in single-tag mode " +
"(the cascade engine is not wired into the host); this write will not take effect at runtime.";
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Engines;
///
/// 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
/// Cycle detection lives in the deploy gate (<c>DependencyGraph</c>); this adapter
/// stays single-tag scoped to keep <see cref="VirtualTagActor"/>'s message loop simple.
///
/// <para>
@@ -366,7 +366,7 @@ public sealed class VirtualTagActor : ReceiveActor
_virtualTagId, _hasLastValue ? _lastValue : null, timestampUtc, CorrelationId.NewId(), OpcUaStatus.BadInternalError));
}
/// <summary>Inputs-ready gate (mirrors the dormant <c>VirtualTagEngine.AreInputsReady</c>): true once
/// <summary>Inputs-ready gate (the retired cascade engine's rule): true once
/// every declared dependency ref has been seen at least once. Called once per failure — the ref list
/// is small, so no caching. A vacuously-true empty ref list means a no-dependency script degrades on
/// its first failure.</summary>