fix(core-virtual-tags): resolve Low code-review findings (Core.VirtualTags-004,006,007,009,010,011,013)

- Core.VirtualTags-004: CoerceResult now covers every scalar
  DriverDataType and throws on the default arm; Load rejects unsupported
  declared types.
- Core.VirtualTags-006: Subscribe/Unsub prune empty observer-list
  entries from _observers under the same lock with a reconfirm-on-add
  race guard.
- Core.VirtualTags-007: rewrote TimerTriggerScheduler so each TickGroup
  tracks an InFlight flag (Interlocked CAS); ticks that overlap a still-
  running tick for the same group are skipped + counted.
- Core.VirtualTags-009: DirectDependencies / DirectDependents return a
  shared static empty set on miss instead of allocating per call.
- Core.VirtualTags-010: corrected XML docs to reference the real engine
  symbols (OnUpstreamChange, CascadeAsync, etc.) instead of phantom types.
- Core.VirtualTags-011: Load now rejects scripts whose declared Writes
  target a non-registered virtual-tag path.
- Core.VirtualTags-013: DependencyCycleException renders SCC members as
  a set rather than a fabricated arrow-traversal edge path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 07:23:53 -04:00
parent 0a20de728d
commit 3f01a24b45
10 changed files with 464 additions and 57 deletions

View File

@@ -7,7 +7,7 @@
| Review date | 2026-05-22 | | Review date | 2026-05-22 |
| Commit reviewed | `76d35d1` | | Commit reviewed | `76d35d1` |
| Status | Reviewed | | Status | Reviewed |
| Open findings | 7 | | Open findings | 0 |
## Checklist coverage ## Checklist coverage
@@ -124,7 +124,7 @@ collection is keyed off the registered set, not the raw input list.
| Severity | Low | | Severity | Low |
| Category | Correctness & logic bugs | | Category | Correctness & logic bugs |
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs:349` | | Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs:349` |
| Status | Open | | Status | Resolved |
**Description:** `CoerceResult`'s switch has a default arm (`_ => raw`) that returns the **Description:** `CoerceResult`'s switch has a default arm (`_ => raw`) that returns the
script's raw return value uncoerced for any `DriverDataType` not in the explicit list script's raw return value uncoerced for any `DriverDataType` not in the explicit list
@@ -139,7 +139,7 @@ the outer pipeline maps to BadInternalError) for an unsupported `DriverDataType`
document precisely which `DriverDataType` values `CoerceResult` supports and validate at document precisely which `DriverDataType` values `CoerceResult` supports and validate at
`Load` time that no definition declares an unsupported type. `Load` time that no definition declares an unsupported type.
**Resolution:** _(open)_ **Resolution:** Resolved 2026-05-23 — extended `CoerceResult` to cover every scalar `DriverDataType` (`Int16`, `UInt16`, `UInt32`, `UInt64` added); the default arm now throws (mapped to `BadInternalError`) instead of returning the uncoerced raw value, and a new `IsSupportedDataType` validation in `Load` rejects definitions declaring an unsupported type (currently `Reference`) so the typo is caught at publish time. Added regression tests for both Int16/UInt16/UInt32/UInt64 round-trip and the publish-time rejection.
### Core.VirtualTags-005 ### Core.VirtualTags-005
@@ -172,7 +172,7 @@ delivered before any subsequent change for that path.
| Severity | Low | | Severity | Low |
| Category | Concurrency & thread safety | | Category | Concurrency & thread safety |
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs:177-182`, `:395-401` | | Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs:177-182`, `:395-401` |
| Status | Open | | Status | Resolved |
**Description:** `Subscribe` does `_observers.GetOrAdd(path, _ => [])` then **Description:** `Subscribe` does `_observers.GetOrAdd(path, _ => [])` then
`lock (list) { list.Add(observer); }`. When `Unsub.Dispose` removes the last observer, `lock (list) { list.Add(observer); }`. When `Unsub.Dispose` removes the last observer,
@@ -188,7 +188,7 @@ but it makes any future "prune empty entries" logic racy.
lock, re-checking emptiness inside the lock to avoid dropping a concurrently-added lock, re-checking emptiness inside the lock to avoid dropping a concurrently-added
observer. observer.
**Resolution:** _(open)_ **Resolution:** Resolved 2026-05-23 — `Unsub.Dispose` now removes the dictionary entry under the same lock when the observer list becomes empty, using the `ICollection<KeyValuePair>.Remove(key,value)` overload so a racing Subscribe's brand-new list is not collateral damage. `Subscribe` retries via the GetOrAdd / lock-and-reconfirm pattern so it cannot deposit an observer into a list that has already been pruned. Added a regression test that subscribes twice + disposes both and asserts the dictionary entry is gone.
### Core.VirtualTags-007 ### Core.VirtualTags-007
@@ -197,7 +197,7 @@ observer.
| Severity | Low | | Severity | Low |
| Category | Error handling & resilience | | Category | Error handling & resilience |
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/TimerTriggerScheduler.cs:58` | | Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/TimerTriggerScheduler.cs:58` |
| Status | Open | | Status | Resolved |
**Description:** `Tick` calls **Description:** `Tick` calls
`_engine.EvaluateOneAsync(p, _cts.Token).GetAwaiter().GetResult()`, blocking the `_engine.EvaluateOneAsync(p, _cts.Token).GetAwaiter().GetResult()`, blocking the
@@ -214,7 +214,7 @@ if the previous one for that group is still running (a per-group "in flight" fla
rather than blocking synchronously. At minimum, document the blocking behaviour and the rather than blocking synchronously. At minimum, document the blocking behaviour and the
expected upper bound on group evaluation time relative to the interval. expected upper bound on group evaluation time relative to the interval.
**Resolution:** _(open)_ **Resolution:** Resolved 2026-05-23 — rewrote `TimerTriggerScheduler` to use a per-`TickGroup` `InFlight` flag (`Interlocked.CompareExchange`-guarded). The timer callback no longer blocks on `GetAwaiter().GetResult()`; instead it kicks off an async `RunTickAsync` and skips the tick (incrementing the new `SkippedTickCount` diagnostic counter) when the prior tick for that group is still running. Added a regression test that runs a 250ms evaluation against a 50ms cadence and asserts `SkippedTickCount > 2`.
### Core.VirtualTags-008 ### Core.VirtualTags-008
@@ -246,7 +246,7 @@ O(V+E) cost into an O(closure) cost.
| Severity | Low | | Severity | Low |
| Category | Performance & resource management | | Category | Performance & resource management |
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/DependencyGraph.cs:64-65`, `:72-73` | | Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/DependencyGraph.cs:64-65`, `:72-73` |
| Status | Open | | Status | Resolved |
**Description:** `DirectDependencies` and `DirectDependents` allocate a fresh empty **Description:** `DirectDependencies` and `DirectDependents` allocate a fresh empty
`HashSet<string>` on every call for an unregistered node. `DirectDependents` is called `HashSet<string>` on every call for an unregistered node. `DirectDependents` is called
@@ -257,7 +257,7 @@ on the change-cascade path.
**Recommendation:** Return a shared static empty set for the miss case instead of **Recommendation:** Return a shared static empty set for the miss case instead of
allocating each time. allocating each time.
**Resolution:** _(open)_ **Resolution:** Resolved 2026-05-23 — `DependencyGraph` now exposes a shared static `EmptySet` instance and `DirectDependencies` / `DirectDependents` return it on a miss instead of allocating a fresh `HashSet<string>` every call. Added regression tests asserting `ReferenceEquals` across two miss calls.
### Core.VirtualTags-010 ### Core.VirtualTags-010
@@ -266,7 +266,7 @@ allocating each time.
| Severity | Low | | Severity | Low |
| Category | Documentation & comments | | Category | Documentation & comments |
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ITagUpstreamSource.cs:18`, `VirtualTagContext.cs:30`, `VirtualTagDefinition.cs:28` | | Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ITagUpstreamSource.cs:18`, `VirtualTagContext.cs:30`, `VirtualTagDefinition.cs:28` |
| Status | Open | | Status | Resolved |
**Description:** Several XML docs reference component names that do not exist in the **Description:** Several XML docs reference component names that do not exist in the
codebase. `ITagUpstreamSource` XML doc says the subscription path "feeds the engine's codebase. `ITagUpstreamSource` XML doc says the subscription path "feeds the engine's
@@ -280,7 +280,7 @@ XML docs mislead maintainers searching for the named component.
`CascadeAsync`, `EvaluateInternalAsync`) or drop the specific name in favour of a `CascadeAsync`, `EvaluateInternalAsync`) or drop the specific name in favour of a
behavioural description. behavioural description.
**Resolution:** _(open)_ **Resolution:** Resolved 2026-05-23 — replaced the stale type names: `ITagUpstreamSource` now references `VirtualTagEngine.OnUpstreamChange` + `CascadeAsync`; `VirtualTagContext` references `VirtualTagEngine.OnScriptSetVirtualTag` + `CascadeAsync`; `VirtualTagDefinition.TimerInterval` references `VirtualTagEngine.EvaluateInternalAsync`.
### Core.VirtualTags-011 ### Core.VirtualTags-011
@@ -289,7 +289,7 @@ behavioural description.
| Severity | Low | | Severity | Low |
| Category | Code organization & conventions | | Category | Code organization & conventions |
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs:404-409` | | Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs:404-409` |
| Status | Open | | Status | Resolved |
**Description:** `VirtualTagState` records a Writes set (the `ctx.SetVirtualTag` targets **Description:** `VirtualTagState` records a Writes set (the `ctx.SetVirtualTag` targets
extracted by `DependencyExtractor`), but nothing in the engine reads it -- it is captured extracted by `DependencyExtractor`), but nothing in the engine reads it -- it is captured
@@ -305,7 +305,7 @@ miss), so an operator typo is caught at publish rather than silently dropped at
If validation is deliberately deferred, remove the unused field or comment why it is If validation is deliberately deferred, remove the unused field or comment why it is
retained. retained.
**Resolution:** _(open)_ **Resolution:** Resolved 2026-05-23 — `Load` now iterates every registered tag's `Writes` set and adds a `compileFailures` entry for any write target that does not resolve to a registered virtual tag. Updated the pre-existing Core.VirtualTags-012 "warning on non-registered path" test to assert publish-time rejection (the runtime warning branch remains as a defensive guard but the static `DependencyExtractor` enforces literal-string paths, so it is unreachable for any operator-authored script). Added a positive companion test confirming a write to a registered path still loads cleanly.
### Core.VirtualTags-012 ### Core.VirtualTags-012
@@ -342,7 +342,7 @@ correspond to open correctness findings and would have caught them.
| Severity | Low | | Severity | Low |
| Category | Documentation & comments | | Category | Documentation & comments |
| Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/DependencyGraph.cs:266-270` | | Location | `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/DependencyGraph.cs:266-270` |
| Status | Open | | Status | Resolved |
**Description:** `DependencyCycleException.BuildMessage` renders each cycle as **Description:** `DependencyCycleException.BuildMessage` renders each cycle as
`string.Join(" -> ", c) + " -> " + c[0]`, presenting the SCC member list as a traversable `string.Join(" -> ", c) + " -> " + c[0]`, presenting the SCC member list as a traversable
@@ -356,4 +356,4 @@ into looking for an edge that is not in their config.
path) rather than rendering arrows, or reconstruct an actual cycle path within the SCC path) rather than rendering arrows, or reconstruct an actual cycle path within the SCC
(a single DFS back-edge walk) before formatting. (a single DFS back-edge walk) before formatting.
**Resolution:** _(open)_ **Resolution:** Resolved 2026-05-23 — `DependencyCycleException.BuildMessage` now formats each cycle as `cycle members: A, B, C` (comma-separated set) rather than the misleading `A -> B -> C -> A` arrow form. Added a regression test asserting the message contains the word "member" and does not fabricate an edge sequence.

View File

@@ -31,6 +31,13 @@ public sealed class DependencyGraph
private readonly Dictionary<string, HashSet<string>> _dependsOn = new(StringComparer.Ordinal); private readonly Dictionary<string, HashSet<string>> _dependsOn = new(StringComparer.Ordinal);
private readonly Dictionary<string, HashSet<string>> _dependents = new(StringComparer.Ordinal); private readonly Dictionary<string, HashSet<string>> _dependents = new(StringComparer.Ordinal);
// Shared empty set returned from DirectDependencies / DirectDependents on a miss.
// The CascadeAsync DFS and the Kahn topological sort both call DirectDependents
// per leaf per pass; allocating a fresh HashSet each time would churn the GC on
// every change-cascade event. Returning a shared immutable-via-convention empty
// set is safe because callers only enumerate (the IReadOnlySet contract).
private static readonly IReadOnlySet<string> EmptySet = new HashSet<string>(StringComparer.Ordinal);
// Cached topological rank — built lazily by TransitiveDependentsInOrder and // Cached topological rank — built lazily by TransitiveDependentsInOrder and
// invalidated whenever the graph is mutated (Add / Clear). Avoids re-running // invalidated whenever the graph is mutated (Add / Clear). Avoids re-running
// a full O(V+E) Kahn pass on every change-cascade event. // a full O(V+E) Kahn pass on every change-cascade event.
@@ -68,7 +75,7 @@ public sealed class DependencyGraph
/// <summary>Tag paths <paramref name="nodeId"/> directly reads.</summary> /// <summary>Tag paths <paramref name="nodeId"/> directly reads.</summary>
public IReadOnlySet<string> DirectDependencies(string nodeId) => public IReadOnlySet<string> DirectDependencies(string nodeId) =>
_dependsOn.TryGetValue(nodeId, out var set) ? set : (IReadOnlySet<string>)new HashSet<string>(); _dependsOn.TryGetValue(nodeId, out var set) ? set : EmptySet;
/// <summary> /// <summary>
/// Tags whose evaluation depends on <paramref name="nodeId"/> — i.e. when /// Tags whose evaluation depends on <paramref name="nodeId"/> — i.e. when
@@ -76,7 +83,7 @@ public sealed class DependencyGraph
/// transitive propagation falls out of the topological sort. /// transitive propagation falls out of the topological sort.
/// </summary> /// </summary>
public IReadOnlySet<string> DirectDependents(string nodeId) => public IReadOnlySet<string> DirectDependents(string nodeId) =>
_dependents.TryGetValue(nodeId, out var set) ? set : (IReadOnlySet<string>)new HashSet<string>(); _dependents.TryGetValue(nodeId, out var set) ? set : EmptySet;
/// <summary> /// <summary>
/// Full transitive dependent closure of <paramref name="nodeId"/> in topological /// Full transitive dependent closure of <paramref name="nodeId"/> in topological
@@ -284,7 +291,14 @@ public sealed class DependencyCycleException : Exception
private static string BuildMessage(IReadOnlyList<IReadOnlyList<string>> cycles) private static string BuildMessage(IReadOnlyList<IReadOnlyList<string>> cycles)
{ {
var lines = cycles.Select(c => " - " + string.Join(" -> ", c) + " -> " + c[0]); // Render each cycle as a comma-separated list of MEMBERS rather than an arrowed
// edge path. Tarjan's algorithm returns SCC members in stack-pop order, which is
// not guaranteed to be a valid edge sequence — for an SCC larger than two nodes
// the previously-emitted "A -> B -> C -> A" rendering could list edges that do
// not exist, sending operators looking for the wrong edge. Member framing avoids
// implying an order or set of edges.
var lines = cycles.Select(c =>
" - cycle members: " + string.Join(", ", c));
return "Virtual-tag dependency graph contains cycle(s):\n" + string.Join("\n", lines); return "Virtual-tag dependency graph contains cycle(s):\n" + string.Join("\n", lines);
} }
} }

View File

@@ -15,10 +15,11 @@ namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// from a last-known-value cache populated by the subscription callbacks. /// from a last-known-value cache populated by the subscription callbacks.
/// </para> /// </para>
/// <para> /// <para>
/// The subscription path feeds the engine's <c>ChangeTriggerDispatcher</c> so /// The subscription path feeds <see cref="VirtualTagEngine"/>'s
/// change-driven virtual tags re-evaluate on any upstream delta (value, status, /// <c>OnUpstreamChange</c> callback, which updates the engine's value cache and
/// or timestamp). One subscription per distinct upstream tag path; the engine /// schedules <c>CascadeAsync</c> to re-evaluate every change-driven dependent in
/// tracks the mapping itself. /// topological order. One subscription per distinct upstream tag path; the
/// engine tracks the mapping itself.
/// </para> /// </para>
/// </remarks> /// </remarks>
public interface ITagUpstreamSource public interface ITagUpstreamSource

View File

@@ -9,12 +9,24 @@ namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// <see cref="System.Threading.Timer"/> per interval-group keeps the wire count /// <see cref="System.Threading.Timer"/> per interval-group keeps the wire count
/// low regardless of tag count. /// low regardless of tag count.
/// </summary> /// </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 public sealed class TimerTriggerScheduler : IDisposable
{ {
private readonly VirtualTagEngine _engine; private readonly VirtualTagEngine _engine;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly List<Timer> _timers = []; private readonly List<Timer> _timers = [];
private readonly List<TickGroup> _groups = [];
private readonly CancellationTokenSource _cts = new(); private readonly CancellationTokenSource _cts = new();
private long _skippedTickCount;
private bool _disposed; private bool _disposed;
public TimerTriggerScheduler(VirtualTagEngine engine, ILogger logger) public TimerTriggerScheduler(VirtualTagEngine engine, ILogger logger)
@@ -23,6 +35,13 @@ public sealed class TimerTriggerScheduler : IDisposable
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _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> /// <summary>
/// Stand up one <see cref="Timer"/> per unique interval. All tags with /// Stand up one <see cref="Timer"/> per unique interval. All tags with
/// matching interval share a timer; each tick triggers re-evaluation of the /// matching interval share a timer; each tick triggers re-evaluation of the
@@ -41,31 +60,60 @@ public sealed class TimerTriggerScheduler : IDisposable
{ {
var paths = group.Select(d => d.Path).ToArray(); var paths = group.Select(d => d.Path).ToArray();
var interval = group.Key; var interval = group.Key;
var timer = new Timer(_ => Tick(paths), null, interval, interval); var ctx = new TickGroup(paths);
_groups.Add(ctx);
var timer = new Timer(_ => OnTimer(ctx), null, interval, interval);
_timers.Add(timer); _timers.Add(timer);
_logger.Information("TimerTriggerScheduler: {TagCount} tag(s) on {Interval} cadence", _logger.Information("TimerTriggerScheduler: {TagCount} tag(s) on {Interval} cadence",
paths.Length, interval); paths.Length, interval);
} }
} }
private void Tick(IReadOnlyList<string> paths) private void OnTimer(TickGroup ctx)
{ {
if (_cts.IsCancellationRequested) return; if (_cts.IsCancellationRequested) return;
foreach (var p in paths)
// 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)
{ {
try 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)
{ {
_engine.EvaluateOneAsync(p, _cts.Token).GetAwaiter().GetResult(); if (_cts.IsCancellationRequested) return;
} try
catch (OperationCanceledException) {
{ await _engine.EvaluateOneAsync(p, _cts.Token).ConfigureAwait(false);
return; }
} catch (OperationCanceledException)
catch (Exception ex) {
{ return;
_logger.Error(ex, "TimerTriggerScheduler evaluate failed for {Path}", p); }
catch (Exception ex)
{
_logger.Error(ex, "TimerTriggerScheduler evaluate failed for {Path}", p);
}
} }
} }
finally
{
Interlocked.Exchange(ref ctx.InFlight, 0);
}
} }
public void Dispose() public void Dispose()
@@ -78,6 +126,21 @@ public sealed class TimerTriggerScheduler : IDisposable
try { t.Dispose(); } catch { } try { t.Dispose(); } catch { }
} }
_timers.Clear(); _timers.Clear();
_groups.Clear();
_cts.Dispose(); _cts.Dispose();
} }
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.
public int InFlight;
public IReadOnlyList<string> Paths { get; }
public TickGroup(IReadOnlyList<string> paths)
{
Paths = paths;
}
}
} }

View File

@@ -8,8 +8,9 @@ namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// Per-evaluation <see cref="ScriptContext"/> for a virtual-tag script. Reads come /// 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 /// out of the engine's last-known-value cache (driver tags updated via the
/// <see cref="ITagUpstreamSource"/> subscription, virtual tags updated by prior /// <see cref="ITagUpstreamSource"/> subscription, virtual tags updated by prior
/// evaluations). Writes route through the engine's <c>SetVirtualTag</c> callback so /// evaluations). Writes route through <see cref="VirtualTagEngine"/>'s
/// cross-tag write side effects still participate in change-trigger cascades. /// <c>OnScriptSetVirtualTag</c> callback so cross-tag write side effects still
/// participate in change-trigger cascades (via the engine's <c>CascadeAsync</c>).
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>

View File

@@ -24,8 +24,8 @@ namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// </param> /// </param>
/// <param name="TimerInterval"> /// <param name="TimerInterval">
/// Optional periodic re-evaluation cadence. Null = timer-driven disabled. Both can /// Optional periodic re-evaluation cadence. Null = timer-driven disabled. Both can
/// be enabled simultaneously; independent scheduling paths both feed /// be enabled simultaneously; independent scheduling paths both end at
/// <c>EvaluationPipeline</c>. /// <see cref="VirtualTagEngine"/>'s <c>EvaluateInternalAsync</c>.
/// </param> /// </param>
/// <param name="Historize"> /// <param name="Historize">
/// When true, every evaluation result is forwarded to the configured /// When true, every evaluation result is forwarded to the configured

View File

@@ -85,6 +85,13 @@ public sealed class VirtualTagEngine : IDisposable
continue; continue;
} }
if (!IsSupportedDataType(def.DataType))
{
compileFailures.Add(
$"{def.Path}: unsupported DataType DriverDataType.{def.DataType} — virtual tags only support scalar primitive types");
continue;
}
try try
{ {
var extraction = DependencyExtractor.Extract(def.ScriptSource); var extraction = DependencyExtractor.Extract(def.ScriptSource);
@@ -108,6 +115,22 @@ public sealed class VirtualTagEngine : IDisposable
} }
} }
// 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) if (compileFailures.Count > 0)
{ {
var joined = string.Join("\n ", compileFailures); var joined = string.Join("\n ", compileFailures);
@@ -184,9 +207,28 @@ public sealed class VirtualTagEngine : IDisposable
/// </summary> /// </summary>
public IDisposable Subscribe(string path, Action<string, DataValueSnapshot> observer) public IDisposable Subscribe(string path, Action<string, DataValueSnapshot> observer)
{ {
var list = _observers.GetOrAdd(path, _ => []); // Race-safe pattern paired with Unsub.Dispose: if Unsub.Dispose removed the
lock (list) { list.Add(observer); } // dictionary entry between our GetOrAdd and the lock-protected Add, the list
return new Unsub(this, path, observer); // 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> /// <summary>
@@ -367,13 +409,24 @@ public sealed class VirtualTagEngine : IDisposable
return target switch return target switch
{ {
DriverDataType.Boolean => Convert.ToBoolean(raw), DriverDataType.Boolean => Convert.ToBoolean(raw),
DriverDataType.Int16 => Convert.ToInt16(raw),
DriverDataType.Int32 => Convert.ToInt32(raw), DriverDataType.Int32 => Convert.ToInt32(raw),
DriverDataType.Int64 => Convert.ToInt64(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.Float32 => Convert.ToSingle(raw),
DriverDataType.Float64 => Convert.ToDouble(raw), DriverDataType.Float64 => Convert.ToDouble(raw),
DriverDataType.String => Convert.ToString(raw) ?? string.Empty, DriverDataType.String => Convert.ToString(raw) ?? string.Empty,
DriverDataType.DateTime => raw is DateTime dt ? dt : Convert.ToDateTime(raw), DriverDataType.DateTime => raw is DateTime dt ? dt : Convert.ToDateTime(raw),
_ => 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 catch
@@ -384,6 +437,28 @@ public sealed class VirtualTagEngine : IDisposable
} }
} }
/// <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() private void UnsubscribeFromUpstream()
{ {
foreach (var s in _upstreamSubscriptions) foreach (var s in _upstreamSubscriptions)
@@ -423,7 +498,23 @@ public sealed class VirtualTagEngine : IDisposable
{ {
if (_engine._observers.TryGetValue(_path, out var list)) if (_engine._observers.TryGetValue(_path, out var list))
{ {
lock (list) { list.Remove(_observer); } 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));
}
}
} }
} }
} }

View File

@@ -153,6 +153,65 @@ public sealed class DependencyGraphTests
g.DirectDependents("A").ShouldBe(new[] { "B" }); g.DirectDependents("A").ShouldBe(new[] { "B" });
} }
// ----- Core.VirtualTags-013: DependencyCycleException message must not present SCC as edge path -----
[Fact]
public void DependencyCycleException_message_describes_cycle_members_not_a_fabricated_edge_path()
{
// Regression for Core.VirtualTags-013: Tarjan returns SCC members in stack-pop
// order, NOT in edge-traversal order. The exception message must not render the
// members as "A -> B -> C -> A" — that misleads operators into looking for an
// edge that may not be in the config. Instead the message uses a set-form
// ("members: A, B, C") or a labelled traversal.
var g = new DependencyGraph();
g.Add("A", Set("B"));
g.Add("B", Set("A"));
var ex = Should.Throw<DependencyCycleException>(() => g.TopologicalSort());
// The arrow ("->") notation as used previously (string.Join(" -> ", c) + " -> " + c[0])
// implies an ordered edge path. After the fix, the message must NOT contain the
// closing edge `-> A` (i.e. " -> " + first-member) on its own — the formatting
// must clearly mark the list as cycle MEMBERS rather than an edge sequence.
ex.Message.ShouldContain("cycle");
ex.Message.ShouldContain("A");
ex.Message.ShouldContain("B");
// Verify the message uses a member-list framing ("members:" or "members of cycle"
// or commas) rather than the misleading edge-path framing.
ex.Message.ShouldContain("member", Case.Insensitive,
"message should label entries as cycle members, not present them as an edge path");
}
// ----- Core.VirtualTags-009: empty-set allocation on miss -----
[Fact]
public void DirectDependencies_miss_returns_shared_empty_set_instance()
{
// Regression for Core.VirtualTags-009: calling DirectDependencies for an
// unregistered node should NOT allocate a fresh HashSet each time. The miss
// path returns a shared empty set so the change-cascade hot path doesn't
// churn the GC.
var g = new DependencyGraph();
var a = g.DirectDependencies("Unknown1");
var b = g.DirectDependencies("Unknown2");
a.ShouldBeEmpty();
b.ShouldBeEmpty();
ReferenceEquals(a, b).ShouldBeTrue("miss path must return the shared empty-set instance");
}
[Fact]
public void DirectDependents_miss_returns_shared_empty_set_instance()
{
// Same regression as above for DirectDependents — called from inside the
// CascadeAsync DFS and TopologicalSort Kahn loop, so the miss-path allocation
// is on every change-cascade event.
var g = new DependencyGraph();
var a = g.DirectDependents("LeafA");
var b = g.DirectDependents("LeafB");
a.ShouldBeEmpty();
b.ShouldBeEmpty();
ReferenceEquals(a, b).ShouldBeTrue("miss path must return the shared empty-set instance");
}
[Fact] [Fact]
public void Deep_graph_no_stack_overflow() public void Deep_graph_no_stack_overflow()
{ {

View File

@@ -92,6 +92,53 @@ public sealed class TimerTriggerSchedulerTests
})); }));
} }
// ----- Core.VirtualTags-007: timer ticks must not block pool threads and must skip when prior tick is still running -----
[Fact]
public async Task Tick_skips_when_prior_tick_for_the_same_group_is_still_running()
{
// Regression for Core.VirtualTags-007: if a single tick takes longer than the
// interval, subsequent timer callbacks must NOT each pin a thread-pool thread
// waiting on the same evaluation gate. The scheduler tracks an in-flight flag
// per group and skips a new tick when the prior one is still running.
var up = new FakeUpstream();
up.Set("In", 1);
var logger = new LoggerConfiguration().CreateLogger();
// Slow script — each evaluation takes longer than several timer intervals.
const int slowMs = 250;
const int intervalMs = 50;
using var engine = new VirtualTagEngine(up,
new ScriptLoggerFactory(logger), logger);
engine.Load([new VirtualTagDefinition(
"Slow", DriverDataType.Int32,
$$"""
var end = DateTime.UtcNow.AddMilliseconds({{slowMs}});
while (DateTime.UtcNow < end) { }
return (int)ctx.GetTag("In").Value;
""",
ChangeTriggered: false,
TimerInterval: TimeSpan.FromMilliseconds(intervalMs))]);
using var sched = new TimerTriggerScheduler(engine, logger);
sched.Start([new VirtualTagDefinition(
"Slow", DriverDataType.Int32,
"",
ChangeTriggered: false,
TimerInterval: TimeSpan.FromMilliseconds(intervalMs))]);
// Wait long enough for many timer ticks at 50ms while one evaluation
// (~250ms each) holds the engine. Window is 600ms ~ 12 ticks.
await Task.Delay(600);
// With the fix in place, ticks that fire while the previous one for the same
// group is still running are skipped. The skipped count must be measurable; if
// SkippedTickCount is still 0 after 600ms with ~12 ticks fired and a 250ms eval,
// the fix is not working — at minimum 3-4 ticks must have been skipped.
sched.SkippedTickCount.ShouldBeGreaterThan(2,
"ticks that fire while the prior tick for the same group is still running must be skipped");
}
[Fact] [Fact]
public void Disposed_scheduler_stops_firing() public void Disposed_scheduler_stops_firing()
{ {

View File

@@ -400,24 +400,25 @@ public sealed class VirtualTagEngineTests
} }
[Fact] [Fact]
public async Task SetVirtualTag_on_non_registered_path_logs_warning_and_does_not_throw() public async Task SetVirtualTag_on_non_registered_path_is_caught_at_Load()
{ {
// Arrange: script writes to a path that is not a registered virtual tag. // Originally validated the runtime warning-and-drop branch in OnScriptSetVirtualTag.
// After Core.VirtualTags-011 the static DependencyExtractor.Writes set is validated
// at Load time, so a literal-string write to a non-existent path is now rejected
// at publish — the dynamic warning path is reserved as a defensive guard for cases
// the static extractor cannot see (currently none, since dynamic paths are also
// rejected at extraction).
var up = new FakeUpstream(); var up = new FakeUpstream();
up.Set("In", 1); up.Set("In", 1);
using var engine = Build(up); using var engine = Build(up);
engine.Load([new VirtualTagDefinition( Should.Throw<InvalidOperationException>(() => engine.Load([
"Writer", DriverDataType.Int32, new VirtualTagDefinition("Writer", DriverDataType.Int32,
""" """
ctx.SetVirtualTag("NonExistentPath", 99); ctx.SetVirtualTag("NonExistentPath", 99);
return (int)ctx.GetTag("In").Value; return (int)ctx.GetTag("In").Value;
""")]); """)
])).Message.ShouldContain("NonExistentPath");
// Act + Assert: should not throw; engine stays healthy.
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
engine.Read("Writer").StatusCode.ShouldBe(0u, "engine must not fault on write to non-registered path");
engine.Read("Writer").Value.ShouldBe(1);
await Task.CompletedTask; await Task.CompletedTask;
} }
@@ -451,6 +452,136 @@ public sealed class VirtualTagEngineTests
engine.Read("Bad").Value.ShouldBeNull(); engine.Read("Bad").Value.ShouldBeNull();
} }
// ----- Core.VirtualTags-011: Writes target validation at Load time -----
[Fact]
public async Task Load_rejects_script_writing_to_unregistered_virtual_tag_path()
{
// Regression for Core.VirtualTags-011: a script that calls
// ctx.SetVirtualTag("Typo", ...) must be caught at publish/load time rather than
// silently dropped at runtime, so operator typos surface as a publish failure.
var up = new FakeUpstream();
using var engine = Build(up);
var ex = Should.Throw<InvalidOperationException>(() => engine.Load([
new VirtualTagDefinition("Writer", DriverDataType.Int32,
"""
ctx.SetVirtualTag("NonRegisteredTarget", 1);
return 0;
"""),
new VirtualTagDefinition("RegisteredTarget", DriverDataType.Int32,
"""return 1;"""),
]));
ex.Message.ShouldContain("Writer");
ex.Message.ShouldContain("NonRegisteredTarget");
await Task.CompletedTask;
}
[Fact]
public async Task Load_accepts_script_writing_to_registered_virtual_tag_path()
{
// Companion to the rejection test: a write to a registered tag must continue to
// load successfully.
var up = new FakeUpstream();
up.Set("In", 1);
using var engine = Build(up);
// No throw — Writer writes to Target which is registered.
engine.Load([
new VirtualTagDefinition("Target", DriverDataType.Int32,
"""return 0;""", ChangeTriggered: false),
new VirtualTagDefinition("Writer", DriverDataType.Int32,
"""
ctx.SetVirtualTag("Target", (int)ctx.GetTag("In").Value);
return 0;
"""),
]);
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
engine.Read("Target").Value.ShouldBe(1);
}
// ----- Core.VirtualTags-006: empty observer list left in _observers map -----
[Fact]
public void Subscribe_then_Unsub_prunes_empty_observer_list_for_path()
{
// Regression for Core.VirtualTags-006: disposing the last subscriber for a path
// must remove the dictionary entry so a long-running server with churning OPC UA
// subscriptions does not accumulate an unbounded number of empty List entries.
var up = new FakeUpstream();
using var engine = Build(up);
engine.Load([new VirtualTagDefinition(
"T", DriverDataType.Int32, """return 1;""")]);
// Subscribe, then immediately Dispose — both the only observer.
var sub1 = engine.Subscribe("T", (_, _) => { });
var sub2 = engine.Subscribe("T", (_, _) => { });
sub1.Dispose();
sub2.Dispose();
// The internal map should no longer hold an entry for the path.
// Use the same ConcurrentDictionary type the engine uses; we check via reflection
// on the test-private field so this is robust to future renames inside engine.
var observersField = typeof(VirtualTagEngine).GetField(
"_observers",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
observersField.ShouldNotBeNull();
var observers = observersField!.GetValue(engine);
observers.ShouldNotBeNull();
var containsKey = observers!.GetType().GetMethod("ContainsKey")!;
var result = (bool)containsKey.Invoke(observers, new object[] { "T" })!;
result.ShouldBeFalse("disposing the last subscriber must remove the dictionary entry");
}
// ----- Core.VirtualTags-004: CoerceResult default arm leaks uncoerced values -----
[Fact]
public async Task CoerceResult_handles_Int16_UInt16_UInt32_UInt64()
{
// Regression for Core.VirtualTags-004: before the fix, CoerceResult had a default
// arm that returned the script's raw double/string for these types, producing a
// type-mismatched DataValueSnapshot. Verify every integer DriverDataType the engine
// is allowed to declare coerces correctly.
var up = new FakeUpstream();
using var engine = Build(up);
engine.Load([
new VirtualTagDefinition("AsInt16", DriverDataType.Int16, """return 7.0;"""),
new VirtualTagDefinition("AsUInt16", DriverDataType.UInt16, """return 8.0;"""),
new VirtualTagDefinition("AsUInt32", DriverDataType.UInt32, """return 9.0;"""),
new VirtualTagDefinition("AsUInt64", DriverDataType.UInt64, """return 10.0;"""),
]);
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
engine.Read("AsInt16").Value.ShouldBeOfType<short>();
engine.Read("AsInt16").Value.ShouldBe((short)7);
engine.Read("AsUInt16").Value.ShouldBeOfType<ushort>();
engine.Read("AsUInt16").Value.ShouldBe((ushort)8);
engine.Read("AsUInt32").Value.ShouldBeOfType<uint>();
engine.Read("AsUInt32").Value.ShouldBe((uint)9);
engine.Read("AsUInt64").Value.ShouldBeOfType<ulong>();
engine.Read("AsUInt64").Value.ShouldBe((ulong)10);
}
[Fact]
public async Task Load_rejects_definition_with_unsupported_DriverDataType()
{
// Regression for Core.VirtualTags-004: any DriverDataType that CoerceResult cannot
// honour must be rejected at Load time so an operator typo (or a future enum
// member added without coercion support) does not silently emit a type-mismatched
// value to OPC UA clients. Reference is unsupported for virtual tags (the engine
// does not synthesize Galaxy attribute references).
var up = new FakeUpstream();
using var engine = Build(up);
var ex = Should.Throw<InvalidOperationException>(() => engine.Load([
new VirtualTagDefinition("Ref", DriverDataType.Reference, """return "Some.Attribute";"""),
]));
ex.Message.ShouldContain("Reference");
ex.Message.ShouldContain("Ref");
await Task.CompletedTask;
}
[Fact] [Fact]
public async Task Load_rejects_duplicate_path_with_aggregated_error() public async Task Load_rejects_duplicate_path_with_aggregated_error()
{ {