diff --git a/docs/ScriptEditor.md b/docs/ScriptEditor.md
index 88f7c54c..5321aca8 100644
--- a/docs/ScriptEditor.md
+++ b/docs/ScriptEditor.md
@@ -203,9 +203,9 @@ prefix. The concrete implementation `ScriptTagCatalog` reads `Tag` +
literal against `DriverInstanceActor.AttributeValuePublished.FullReference`,
which is the `FullName` field from `Tag.TagConfig`
(see `ScriptTagCatalog.ExtractFullNameFromTagConfig` + `EquipmentNodeWalker.ExtractFullName`).
-The UNS-path engine (`Core.VirtualTags.VirtualTagEngine`, keyed by the
-slash-joined `Enterprise/Site/Area/Line/Equipment/TagName` browse path) is
-dormant — it is not wired into the host — so UNS browse paths never resolve at
+The former UNS-path cascade engine (`Core.VirtualTags.VirtualTagEngine`, keyed by the
+slash-joined `Enterprise/Site/Area/Line/Equipment/TagName` browse path) was
+retired (#603) — it was never wired into the host — so UNS browse paths never resolve at
runtime and are intentionally NOT offered as completions.
The catalog is scoped per Blazor circuit; each call creates and disposes its own
@@ -233,8 +233,8 @@ deploy time. An unrelated `anything.GetTag("…")` no longer triggers it.
**`SetVirtualTag` is a no-op in production.** The live single-tag evaluator
(`RoslynVirtualTagEvaluator`) drops cross-tag `ctx.SetVirtualTag(...)` writes
-(logged); the cascade `VirtualTagEngine` that honours them is dormant (not wired
-into the host). So hover on a `ctx.SetVirtualTag("…")` path literal appends a
+(logged); the cascade engine that would have honoured them (`VirtualTagEngine`) was retired
+unwired (#603). So hover on a `ctx.SetVirtualTag("…")` path literal appends a
note that the write is dropped in single-tag mode and will not take effect at
runtime — the path completions still help author the literal, but the author is
not misled into relying on a no-op. Making `SetVirtualTag` functional would
diff --git a/docs/VirtualTags.md b/docs/VirtualTags.md
index 20032d37..d58013ea 100644
--- a/docs/VirtualTags.md
+++ b/docs/VirtualTags.md
@@ -60,9 +60,7 @@ Parses the script source with `CSharpSyntaxTree.ParseText` (script kind), walks
## Virtual tag engine (`Core.VirtualTags`)
-### `VirtualTagDefinition`
-
-One row per operator-authored tag. Fields: `Path` (UNS browse path; also the engine's internal id), `DataType` (`DriverDataType` enum; the evaluator coerces the script's return value to this and mismatch surfaces as `BadTypeMismatch`), `ScriptSource` (Roslyn C# script text), `ChangeTriggered` (re-evaluate on any input delta), `TimerInterval` (optional periodic cadence; null disables), `Historize` (route every evaluation result to `IHistoryWriter`). Change-trigger and timer are independent — a tag can be either, both, or neither.
+> **Retired (#603):** the standalone cascade engine (`VirtualTagEngine`, `VirtualTagSource`, `VirtualTagDefinition`, `TimerTriggerScheduler`) had no production consumer and was deleted; virtual tags run through `VirtualTagHostActor` / `VirtualTagActor` + `RoslynVirtualTagEvaluator`. `Core.VirtualTags` now holds `DependencyGraph` (deploy-gate cycle check), `ITagUpstreamSource` and `IHistoryWriter`. Descriptions below of the engine's `Load`/cascade/timer behaviour are historical.
### `VirtualTagContext`
@@ -77,56 +75,30 @@ Directed graph of tag paths. Edges run from a virtual tag to each path it reads.
Cycle detection uses an **iterative** Tarjan's SCC implementation (no recursion, deep graphs cannot stack-overflow). Cycles of length > 1 and self-loops both reject; leaf references cannot form cycles with internal nodes.
-### `VirtualTagEngine` lifecycle
-
-- **`Load(definitions)`** — clears prior state, compiles every script through `DependencyExtractor.Extract` + `ScriptEvaluator.Compile` (wrapped in `TimedScriptEvaluator`), registers each in `_tags` + `_graph`, runs `TopologicalSort` (cycle check), then for every upstream (non-virtual) path subscribes via `ITagUpstreamSource.SubscribeTag` and seeds `_valueCache` with `ReadTag`. Throws `InvalidOperationException` aggregating every compile failure at once so operators see the whole set; throws `DependencyCycleException` on cycles. Re-entrant — supports config-publish reloads by disposing the prior upstream subscriptions first.
-- **`EvaluateAllAsync(ct)`** — evaluates every tag once in topological order. Called at startup so virtual tags have a defined initial value before subscriptions start.
-- **`EvaluateOneAsync(path, ct)`** — single-tag evaluation. Entry point for `TimerTriggerScheduler` + tests.
-- **`Read(path)`** — synchronous last-known-value lookup from `_valueCache`. Returns `BadNodeIdUnknown`-quality for unregistered paths.
-- **`Subscribe(path, observer)`** — register a change observer; returns `IDisposable`. Does **not** emit a seed value.
-- **`OnUpstreamChange(path, value)`** (internal, wired from the upstream subscription) — updates cache, notifies observers, launches `CascadeAsync` fire-and-forget so the driver's dispatcher isn't blocked.
-
-Evaluations are **serial across all tags** — `_evalGate` is a `SemaphoreSlim(1, 1)` held around every `EvaluateInternalAsync`. Parallelism is deferred (Phase 7 plan decision #19). Rationale: serial execution preserves the "earlier topological nodes computed before later dependents" invariant when two cascades race. Per-tag error isolation: a script exception or timeout sets that tag's quality to `BadInternalError` and logs a structured error; other tags keep evaluating. `OperationCanceledException` is re-thrown (shutdown path).
-
-Result coercion: `CoerceResult` maps the script's return value to the declared `DriverDataType` via `Convert.ToXxx`. Coercion failure returns null which the outer pipeline maps to `BadInternalError`; `BadTypeMismatch` is documented in the definition shape (`VirtualTagDefinition.DataType` doc) rather than emitted distinctly today.
-
-`IHistoryWriter.Record` fires per evaluation when `Historize = true`. The default `NullHistoryWriter` drops silently.
-
-### `TimerTriggerScheduler`
-
-Groups `VirtualTagDefinition`s by `TimerInterval`, one `System.Threading.Timer` per unique interval. Each tick evaluates the group's paths serially via `VirtualTagEngine.EvaluateOneAsync`. Errors per-tag log and continue. `Dispose()` cancels an internal `CancellationTokenSource` and disposes every timer. Independent of the change-trigger path — a tag with both triggers fires from both scheduling sources.
-
### `ITagUpstreamSource`
-What the engine pulls driver-tag values from. Reads are **synchronous** because user scripts call `ctx.GetTag(path)` inline — a blocking wire call per evaluation would kill throughput. Implementations are expected to serve from a last-known-value cache populated by subscription callbacks. The server's production implementation is `CachedTagUpstreamSource` (see Composition below).
+What a value consumer (scripted alarms) pulls driver-tag values from. Reads are **synchronous** because user scripts call `ctx.GetTag(path)` inline — a blocking wire call per evaluation would kill throughput. Implementations are expected to serve from a last-known-value cache populated by subscription callbacks. The server's production implementation is `CachedTagUpstreamSource` (see Composition below).
### `IHistoryWriter`
-Fire-and-forget sink for evaluation results when `VirtualTagDefinition.Historize = true`. Implementations must queue internally and drain on their own cadence — a slow historian must not block script evaluation. `NullHistoryWriter.Instance` is the no-op default. Scripted-alarm emissions flow through `Core.AlarmHistorian` via `HistorianAdapterActor` → the registered `IAlarmHistorianSink` (a separate concern; see [AlarmTracking.md](AlarmTracking.md)). *(The `Phase7EngineComposer.RouteToHistorianAsync` this used to name is gone — not renamed; the routing moved into the actor.)*
+Fire-and-forget sink for evaluation results when a virtual tag is `Historize`d. Implementations must queue internally and drain on their own cadence — a slow historian must not block script evaluation. `NullHistoryWriter.Instance` is the no-op default. Scripted-alarm emissions flow through `Core.AlarmHistorian` via `HistorianAdapterActor` → the registered `IAlarmHistorianSink` (a separate concern; see [AlarmTracking.md](AlarmTracking.md)). *(The `Phase7EngineComposer.RouteToHistorianAsync` this used to name is gone — not renamed; the routing moved into the actor.)*
**Equipment-namespace path (H5).** The `Historize` flag is threaded end-to-end on the equipment path: `VirtualTag.Historize` → composer + artifact-decode (byte-parity) → `EquipmentVirtualTagPlan.Historize` → `VirtualTagHostActor`, which calls `IHistoryWriter.Record(nodeId, snapshot)` for every historized result (in addition to publishing the live value). The writer is injectable via DI — `DriverHostActor` resolves `IHistoryWriter` (`TryAddSingleton`, `NullHistoryWriter` default) and threads it into `VirtualTagHostActor`. **This `IHistoryWriter` seam still ships no durable binding** (`NullHistoryWriter` default). Durable continuous historization of driver/virtual values is now handled by the separate `ContinuousHistorizationRecorder` (it taps the dependency-mux value fan-out → a crash-safe FasterLog outbox → the HistorianGateway's `WriteLiveValues` path; see [Historian.md](Historian.md)), not through this seam. A deployment can still bind a custom `IHistoryWriter` via DI.
## Dispatch integration
-Per [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md) Option B, there is a single `GenericDriverNodeManager`. `VirtualTagSource` implements `IReadable` + `ISubscribable` over a `VirtualTagEngine`:
-
-- `ReadAsync` fans each path through `engine.Read(...)`.
-- `SubscribeAsync` calls `engine.Subscribe` per path and forwards each engine observer callback as an `OnDataChange` event; emits an initial-data callback per OPC UA convention.
-- `UnsubscribeAsync` disposes every per-path engine subscription it holds.
-- **`IWritable` is deliberately not implemented.** Virtual-tag nodes are not client-writable because `OtOpcUaNodeManager.EnsureVariable` materialises every SDK variable with `AccessLevel = AccessLevels.CurrentRead`; the SDK base `CustomNodeManager2.Write` returns `BadNotWritable` for read-only nodes and v2 has no client-write dispatch path. Scripts are the only write path via `ctx.SetVirtualTag`.
-
-`NodeSourceKind` on each `DriverAttributeInfo` (set by `EquipmentNodeWalker` at address-space build time) drives which backend handles a read. See [ReadWriteOperations.md](ReadWriteOperations.md) and [v1/Subscriptions.md](v1/Subscriptions.md) for the broader dispatch framing.
+Virtual-tag nodes are materialised by `AddressSpaceApplier` and fed by `VirtualTagActor` publishes; they are not client-writable (`AccessLevel = CurrentRead`), scripts being the only write path via `ctx.SetVirtualTag`. See [ReadWriteOperations.md](ReadWriteOperations.md).
## Upstream reads + history
-`ITagUpstreamSource` and `IHistoryWriter` are the two ports the engine requires from its host. Both live in `Core.VirtualTags`. In the v2 actor system:
+`ITagUpstreamSource` and `IHistoryWriter` are the two ports value consumers require from their host. Both live in `Core.VirtualTags`. In the v2 actor system:
- **Upstream-tag feed.** `DependencyMuxActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs`) routes `DriverInstanceActor.AttributeValuePublished` events to the `VirtualTagActor` instances that declared interest in those tag refs. Each `VirtualTagActor` holds the in-memory per-tag dependency map; the `IVirtualTagEvaluator` (`RoslynVirtualTagEvaluator`) receives the dependency snapshot synchronously on the actor message thread. Reads of never-pushed dependency refs return `null` values in the dependency snapshot.
-- **`IHistoryWriter`** — the equipment-namespace path threads `Historize` end-to-end and `VirtualTagHostActor` invokes the injected writer on historized results (H5); the writer is resolved through `DriverHostActor` DI with a `NullHistoryWriter` default. The standalone `VirtualTagEngine` likewise receives `NullHistoryWriter` by default. No *durable* writer ships on this seam — durable continuous historization now flows through the separate `ContinuousHistorizationRecorder` → HistorianGateway `WriteLiveValues` path (see the `IHistoryWriter` section above and [Historian.md](Historian.md)).
+- **`IHistoryWriter`** — the equipment-namespace path threads `Historize` end-to-end and `VirtualTagHostActor` invokes the injected writer on historized results (H5); the writer is resolved through `DriverHostActor` DI with a `NullHistoryWriter` default. No *durable* writer ships on this seam — durable continuous historization now flows through the separate `ContinuousHistorizationRecorder` → HistorianGateway `WriteLiveValues` path (see the `IHistoryWriter` section above and [Historian.md](Historian.md)).
## Composition
-`AddressSpaceComposer` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs`) is a pure static function that flattens config-DB entities into an `AddressSpaceComposition` value (UNS topology + raw subtree + driver-instance plans + scripted-alarm plans). `AddressSpaceApplier` applies that result into the OPC UA SDK node manager. Neither class has knowledge of `VirtualTagEngine` or `ScriptedAlarmEngine`.
+`AddressSpaceComposer` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs`) is a pure static function that flattens config-DB entities into an `AddressSpaceComposition` value (UNS topology + raw subtree + driver-instance plans + scripted-alarm plans). `AddressSpaceApplier` applies that result into the OPC UA SDK node manager. Neither class has knowledge of `ScriptedAlarmEngine`.
In the v2 actor system, virtual-tag engine composition is owned by the driver-role host actor tree:
@@ -134,7 +106,7 @@ In the v2 actor system, virtual-tag engine composition is owned by the driver-ro
- `RoslynVirtualTagEvaluator` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs`) is injected into each `VirtualTagActor` as its `IVirtualTagEvaluator`. It holds a per-source `CompiledScriptCache` keyed by script source and compiles on first use.
- `DependencyMuxActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs`) receives every `DriverInstanceActor.AttributeValuePublished` event and routes it to the `VirtualTagActor` instances that registered interest in that tag ref.
-`VirtualTagEngine`, `VirtualTagSource`, `TimerTriggerScheduler`, and `ITagUpstreamSource` are available as standalone Core.VirtualTags primitives and remain the correct composition path for non-actor deployments (integration tests, future standalone runtimes).
+`ITagUpstreamSource` and `IHistoryWriter` remain available as `Core.VirtualTags` ports.
## Key source files
@@ -148,14 +120,10 @@ In the v2 actor system, virtual-tag engine composition is owned by the driver-ro
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/DependencyExtractor.cs` — static `ctx.GetTag` / `ctx.SetVirtualTag` inference
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLoggerFactory.cs` — per-script Serilog logger
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLogCompanionSink.cs` — error mirror to main log
-- `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagDefinition.cs` — per-tag config record
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagContext.cs` — evaluation-scoped `ctx`
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/DependencyGraph.cs` — Kahn topo-sort + iterative Tarjan SCC
-- `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs` — load / evaluate / cascade pipeline
-- `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/TimerTriggerScheduler.cs` — periodic re-evaluation
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ITagUpstreamSource.cs` — driver-tag read + subscribe port
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/IHistoryWriter.cs` — historize sink port + `NullHistoryWriter`
-- `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs` — `IReadable` + `ISubscribable` adapter
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs` — actor that receives `DependencyValueChanged` from the mux and invokes `IVirtualTagEvaluator` per expression
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs` — routes `DriverInstanceActor.AttributeValuePublished` to interested `VirtualTagActor` subscribers
- `src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs` — production `IVirtualTagEvaluator` binding; holds a per-source `CompiledScriptCache`
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Engines/IVirtualTagEvaluator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Engines/IVirtualTagEvaluator.cs
index 0dc8825a..244b78f9 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Engines/IVirtualTagEvaluator.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Engines/IVirtualTagEvaluator.cs
@@ -5,8 +5,7 @@ namespace ZB.MOM.WW.OtOpcUa.Commons.Engines;
///
/// Abstraction over the compiled virtual-tag expression engine. Runtime consumes this so
/// can stay free of Roslyn / scripting machinery and the
-/// production wiring binds an adapter over VirtualTagEngine from
-/// Core.VirtualTags.
+/// production wiring binds RoslynVirtualTagEvaluator (Host).
///
public interface IVirtualTagEvaluator
{
@@ -98,7 +97,7 @@ public static class ScriptEvalFailureReasons
}
/// Returns from every call. Bound by default
-/// when the production VirtualTagEngine adapter hasn't been registered (Mac dev, tests).
+/// when the production RoslynVirtualTagEvaluator hasn't been registered (Mac dev, tests).
public sealed class NullVirtualTagEvaluator : IVirtualTagEvaluator
{
public static readonly NullVirtualTagEvaluator Instance = new();
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs
index 350a9037..03455f0a 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs
@@ -39,7 +39,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
///
///
/// Set when is — stable
-/// logical id the VirtualTagEngine addresses by. Null otherwise.
+/// logical id the virtual-tag runtime addresses by. Null otherwise.
///
///
/// Set when is —
@@ -78,7 +78,7 @@ public sealed record DriverAttributeInfo(
/// Discriminates which runtime subsystem owns this node's Read/Write/
/// Subscribe dispatch. Driver = a real IDriver capability surface;
/// Virtual = a Phase 7 .VirtualTagId'd tag
-/// computed by the VirtualTagEngine; ScriptedAlarm = a scripted Part 9 alarm
+/// computed by the virtual-tag runtime; ScriptedAlarm = a scripted Part 9 alarm
/// materialized by the ScriptedAlarmEngine.
///
public enum NodeSourceKind
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/VirtualTagContext.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/VirtualTagContext.cs
index fbfae77c..52ce1752 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/VirtualTagContext.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/VirtualTagContext.cs
@@ -8,9 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// Per-evaluation for a virtual-tag script. Reads come
/// out of the engine's last-known-value cache (driver tags updated via the
/// ITagUpstreamSource subscription, virtual tags updated by prior
-/// evaluations). Writes route through VirtualTagEngine's
-/// OnScriptSetVirtualTag callback so cross-tag write side effects still
-/// participate in change-trigger cascades (via the engine's CascadeAsync).
+/// evaluations). Writes route through the host-supplied set-virtual-tag callback.
///
///
///
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptSandbox.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptSandbox.cs
index 42cf1790..1ade6dd0 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptSandbox.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptSandbox.cs
@@ -81,10 +81,12 @@ public static class ScriptSandbox
// surface (operator-authored hosting helpers, third-party packages, app code)
// is not on the list and stays unreachable.
var references = new List();
+ // #603: both halves are cached. CreateFromFile reads + maps the DLL, and the BCL half is ~170
+ // assemblies, so doing it per compile was the dominant fixed cost of every ScriptEvaluator build.
+ // MetadataReference is immutable and thread-safe, so sharing instances across compilations is safe.
foreach (var asm in pinnedAssemblies)
- references.Add(MetadataReference.CreateFromFile(asm.Location));
- foreach (var path in EnumerateBclAssemblyPaths())
- references.Add(MetadataReference.CreateFromFile(path));
+ references.Add(PinnedReferences.GetOrAdd(asm, static a => MetadataReference.CreateFromFile(a.Location)));
+ references.AddRange(BclReferences.Value);
var imports = new[]
{
@@ -97,6 +99,13 @@ public static class ScriptSandbox
return new SandboxConfig(references, imports);
}
+ private static readonly System.Collections.Concurrent.ConcurrentDictionary PinnedReferences = new();
+
+ // The trusted-platform set is process-constant, so the BCL reference list is context-independent.
+ private static readonly Lazy> BclReferences = new(
+ static () => EnumerateBclAssemblyPaths().Select(static p => (MetadataReference)MetadataReference.CreateFromFile(p)).ToArray(),
+ LazyThreadSafetyMode.ExecutionAndPublication);
+
private static IEnumerable EnumerateBclAssemblyPaths()
{
// The .NET host advertises the resolved runtime-shared-framework + BCL DLL set
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ITagUpstreamSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ITagUpstreamSource.cs
index 3616abe0..0edfd654 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ITagUpstreamSource.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ITagUpstreamSource.cs
@@ -15,11 +15,8 @@ namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// from a last-known-value cache populated by the subscription callbacks.
///
///
-/// The subscription path feeds 's
-/// OnUpstreamChange callback, which updates the engine's value cache and
-/// schedules CascadeAsync 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
+/// DependencyMuxTagUpstreamSource). One subscription per distinct upstream tag path.
///
///
public interface ITagUpstreamSource
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/TimerTriggerScheduler.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/TimerTriggerScheduler.cs
deleted file mode 100644
index 93b1f5ed..00000000
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/TimerTriggerScheduler.cs
+++ /dev/null
@@ -1,162 +0,0 @@
-using Serilog;
-
-namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
-
-///
-/// Periodic re-evaluation scheduler for tags with a non-null
-/// . Independent of the
-/// change-trigger path — a tag can be timer-only, change-only, or both. One
-/// per interval-group keeps the wire count
-/// low regardless of tag count.
-///
-///
-///
-/// Each timer group carries a per-group in-flight flag (see
-/// TickGroup.InFlight). When the timer fires while a tick for the same
-/// group is still running, the new callback skips the work and increments
-/// 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.
-///
-///
-public sealed class TimerTriggerScheduler : IDisposable
-{
- private readonly VirtualTagEngine _engine;
- private readonly ILogger _logger;
- private readonly List _timers = [];
- private readonly List _groups = [];
- private readonly CancellationTokenSource _cts = new();
- private long _skippedTickCount;
- private bool _disposed;
- private bool _started;
-
- /// Initializes a new instance of the class.
- /// The virtual tag engine to trigger evaluations on.
- /// A logger for diagnostic output.
- public TimerTriggerScheduler(VirtualTagEngine engine, ILogger logger)
- {
- _engine = engine ?? throw new ArgumentNullException(nameof(engine));
- _logger = logger ?? throw new ArgumentNullException(nameof(logger));
- }
-
- ///
- /// 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.
- ///
- public long SkippedTickCount => Interlocked.Read(ref _skippedTickCount);
-
- ///
- /// Stand up one 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.
- ///
- /// The virtual tag definitions to schedule timers for.
- /// Thrown when Start is called more than once on the same instance.
- public void Start(IReadOnlyList 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);
- }
- }
-
- /// Releases all timers and disposes the scheduler's resources.
- 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();
- }
-
- /// Groups tags that share the same timer interval.
- 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.
- /// Flag indicating whether a tick is currently in flight for this group.
- public int InFlight;
-
- /// Gets the list of tag paths in this group.
- public IReadOnlyList Paths { get; }
-
- /// Initializes a new instance of the class.
- /// The tag paths in this timer group.
- public TickGroup(IReadOnlyList paths)
- {
- Paths = paths;
- }
- }
-}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagDefinition.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagDefinition.cs
deleted file mode 100644
index 60b7f953..00000000
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagDefinition.cs
+++ /dev/null
@@ -1,41 +0,0 @@
-using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
-
-namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
-
-///
-/// Operator-authored virtual-tag configuration row. Phase 7 Stream E (config DB
-/// schema) materializes these from the VirtualTag + Script tables on
-/// publish; the engine ingests a list of them at load time.
-///
-///
-/// UNS tag path — Enterprise/Site/Area/Line/Equipment/TagName. Used both as
-/// the engine's internal id and the OPC UA browse path.
-///
-///
-/// Expected return type. The evaluator coerces the script's return value to this
-/// type before publishing; mismatch surfaces as BadTypeMismatch quality on
-/// the tag.
-///
-/// Roslyn C# script source. Must compile under ScriptSandbox.
-///
-/// 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.
-///
-///
-/// Optional periodic re-evaluation cadence. Null = timer-driven disabled. Both can
-/// be enabled simultaneously; independent scheduling paths both end at
-/// 's EvaluateInternalAsync.
-///
-///
-/// When true, every evaluation result is forwarded to the configured
-/// . Operator-set per tag; the Admin UI exposes as a
-/// checkbox.
-///
-public sealed record VirtualTagDefinition(
- string Path,
- DriverDataType DataType,
- string ScriptSource,
- bool ChangeTriggered = true,
- TimeSpan? TimerInterval = null,
- bool Historize = false);
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs
deleted file mode 100644
index 7009684c..00000000
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs
+++ /dev/null
@@ -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;
-
-///
-/// The Phase 7 virtual-tag evaluation engine. Ingests a set of
-/// s at load time, compiles each script against
-/// , builds the dependency graph, subscribes to every
-/// referenced upstream tag, and schedules re-evaluations on change + on timer.
-///
-public sealed class VirtualTagEngine : IDisposable
-{
- private readonly ITagUpstreamSource _upstream;
- private readonly IHistoryWriter _history;
- private readonly ScriptLoggerFactory _loggerFactory;
- private readonly ILogger _engineLogger;
- private readonly Func _clock;
- private readonly TimeSpan _scriptTimeout;
-
- private readonly DependencyGraph _graph = new();
- private readonly Dictionary _tags = new(StringComparer.Ordinal);
-
- ///
- /// Compile cache for every virtual-tag script. Routes 's
- /// calls through the
- /// cache so the collectible
- /// each compile produces is actually disposed on the publish-replace path: the
- /// cache's 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
- /// ScriptEvaluator.Compile directly, which left the ALCs rooted until
- /// the process exited.
- ///
- private readonly CompiledScriptCache _compileCache = new();
-
- private readonly ConcurrentDictionary _valueCache = new(StringComparer.Ordinal);
- private readonly ConcurrentDictionary>> _observers
- = new(StringComparer.Ordinal);
- private readonly List _upstreamSubscriptions = [];
- private readonly SemaphoreSlim _evalGate = new(1, 1);
- private bool _loaded;
- private bool _disposed;
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The upstream tag source for reading input tags.
- /// Factory for creating script loggers.
- /// Logger for engine events and errors.
- /// Optional history writer for recording tag evaluations; defaults to no-op.
- /// Optional function providing current UTC time; defaults to .
- /// Optional timeout for script evaluation; defaults to .
- public VirtualTagEngine(
- ITagUpstreamSource upstream,
- ScriptLoggerFactory loggerFactory,
- ILogger engineLogger,
- IHistoryWriter? historyWriter = null,
- Func? 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.DefaultTimeout;
- }
-
- /// Registered tag paths, in topological order. Empty before .
- public IReadOnlyCollection LoadedTagPaths => _tags.Keys;
-
- /// Compile + register every tag in . Throws on cycle or any compile failure.
- /// List of virtual tag definitions to load and compile.
- public void Load(IReadOnlyList 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();
- var seenPaths = new HashSet(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(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);
- }
-
- ///
- /// 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.
- ///
- /// Cancellation token to stop evaluation.
- /// A task that represents the asynchronous operation.
- 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);
- }
- }
-
- /// Evaluate a single tag — used by the timer trigger + test hooks.
- /// Path of the virtual tag to evaluate.
- /// Cancellation token to stop evaluation.
- /// A task that represents the asynchronous operation.
- 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);
- }
-
- ///
- /// Read the most recently evaluated value for . Driver
- /// tags return the last-known upstream value; virtual tags return their last
- /// evaluation result.
- ///
- /// Path of the tag to read.
- /// The tag's most recently evaluated value, or a Bad-quality snapshot if the path is unrecognized.
- 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());
- }
-
- ///
- /// Register an observer that fires on every evaluation of the given tag.
- /// Returns an to unsubscribe. Does NOT fire a seed
- /// value — subscribers call for the current value if needed.
- ///
- /// Path of the tag to subscribe to.
- /// Callback invoked with the tag path and new value on each evaluation.
- /// An that removes the observer when disposed.
- public IDisposable Subscribe(string path, Action 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.
- }
- }
-
- ///
- /// 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.
- ///
- /// Path of the upstream tag that changed.
- /// New value of the tag.
- 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 BuildReadCache(IReadOnlySet reads)
- {
- var map = new Dictionary(StringComparer.Ordinal);
- foreach (var r in reads)
- {
- map[r] = _valueCache.TryGetValue(r, out var v)
- ? v
- : _upstream.ReadTag(r);
- }
- return map;
- }
-
- ///
- /// Returns true when every entry in has a
- /// Good/Uncertain-quality (bit 31 = 0).
- /// Readiness is gated on StatusCode alone — a Good-quality snapshot with a
- /// null Value (e.g. a script that returns null, or a
- /// ctx.SetVirtualTag 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
- /// BadInternalError — 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
- /// BadWaitingForInitialData.
- ///
- private static bool AreInputsReady(IReadOnlyDictionary 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[] 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;
- }
- }
-
- ///
- /// The set of values can
- /// honour. Definitions declaring any other type are rejected at
- /// 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.
- ///
- 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.");
- }
-
- ///
- /// Releases all resources held by the engine, including upstream subscriptions and compiled script caches.
- ///
- public void Dispose()
- {
- if (_disposed) return;
- _disposed = true;
- UnsubscribeFromUpstream();
- _tags.Clear();
- _graph.Clear();
- _compileCache.Dispose();
- }
-
- ///
- /// Gets the dependency graph for testing purposes.
- ///
- internal DependencyGraph GraphForTesting => _graph;
-
- private sealed class Unsub : IDisposable
- {
- private readonly VirtualTagEngine _engine;
- private readonly string _path;
- private readonly Action _observer;
- ///
- /// Initializes a new instance of the class.
- ///
- /// The parent virtual tag engine.
- /// Path of the subscribed tag.
- /// The observer callback to unsubscribe.
- public Unsub(VirtualTagEngine e, string path, Action observer)
- {
- _engine = e; _path = path; _observer = observer;
- }
- ///
- /// Removes the observer from the subscription list.
- ///
- 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> 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>>>)_engine._observers)
- .Remove(new KeyValuePair>>(_path, list));
- }
- }
- }
- }
- }
-
- internal sealed record VirtualTagState(
- VirtualTagDefinition Definition,
- TimedScriptEvaluator Evaluator,
- IReadOnlySet Reads,
- IReadOnlySet Writes,
- ILogger Logger);
-}
diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs
deleted file mode 100644
index e12ec270..00000000
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs
+++ /dev/null
@@ -1,106 +0,0 @@
-using System.Collections.Concurrent;
-using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
-
-namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
-
-///
-/// Implements the driver-agnostic capability surface the
-/// DriverNodeManager dispatches to when a node resolves to
-/// NodeSource.Virtual. Reads return the engine's last-known
-/// evaluation result; subscriptions forward engine-emitted change events as
-/// events.
-///
-///
-///
-/// is deliberately not implemented — OPC UA client
-/// writes to virtual tags are rejected in DriverNodeManager before they
-/// reach here. Scripts are the only write path, routed
-/// through ctx.SetVirtualTag.
-///
-///
-public sealed class VirtualTagSource : IReadable, ISubscribable
-{
- private readonly VirtualTagEngine _engine;
- private readonly ConcurrentDictionary _subs = new(StringComparer.Ordinal);
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The virtual tag engine.
- public VirtualTagSource(VirtualTagEngine engine)
- {
- _engine = engine ?? throw new ArgumentNullException(nameof(engine));
- }
-
- ///
- /// Occurs when data values change.
- ///
- public event EventHandler? OnDataChange;
-
- ///
- public Task> ReadAsync(
- IReadOnlyList 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>(results);
- }
-
- ///
- public Task SubscribeAsync(
- IReadOnlyList 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(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(handle);
- }
-
- ///
- 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
- {
- ///
- /// Initializes a new instance of the class.
- ///
- /// The subscription identifier.
- public SubscriptionHandle(string id) { DiagnosticId = id; }
-
- ///
- public string DiagnosticId { get; }
- }
-
- private sealed record Subscription(SubscriptionHandle Handle, IReadOnlyList Observers);
-}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationTimerScheduler.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationTimerScheduler.cs
index bf0bf55d..02e01074 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationTimerScheduler.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationTimerScheduler.cs
@@ -3,12 +3,10 @@ using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
///
-/// Periodic re-evaluation scheduler for calc tags carrying a timerIntervalMs. A deliberate
-/// mirror of the VirtualTag TimerTriggerScheduler (Core.VirtualTags): one
+/// Periodic re-evaluation scheduler for calc tags carrying a timerIntervalMs. One
/// 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 inside the driver because the VT scheduler is welded
-/// to VirtualTagEngine.EvaluateOneAsync; 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.
///
internal sealed class CalculationTimerScheduler : IDisposable
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/LastValueCache.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/LastValueCache.cs
index f759707f..7df9dd0e 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/LastValueCache.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/LastValueCache.cs
@@ -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
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs
index 663ef22c..ba69b0da 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs
@@ -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.";
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbRegistration.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbRegistration.cs
index f8a54971..a26ac5bb 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbRegistration.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbRegistration.cs
@@ -40,7 +40,7 @@ public static class LocalDbRegistration
public const string LocalDbSectionPath = "LocalDb";
/// Configuration section holding ReplicationOptions.
- public const string ReplicationSectionPath = "LocalDb:Replication";
+ public const string ReplicationSectionPath = Runtime.LocalDbReplicationPolicy.ReplicationSectionPath;
///
/// Configuration key carrying the dedicated h2c sync listener's port. Zero or absent means
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs
index 11b1c8d9..7bc8954a 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSetup.cs
@@ -170,12 +170,9 @@ public static class LocalDbSetup
/// true when this node is configured to replicate.
public static bool ReplicationIsConfigured(IConfiguration configuration)
{
- ArgumentNullException.ThrowIfNull(configuration);
-
- var section = configuration.GetSection(LocalDbRegistration.ReplicationSectionPath);
-
- return !string.IsNullOrWhiteSpace(section["PeerAddress"])
- || !string.IsNullOrWhiteSpace(section["ApiKey"]);
+ // Shared with the alarm-historian drain gate (#603): one predicate, so the nodes that capture
+ // changes and the nodes that stand down for a peer can never disagree.
+ return Runtime.LocalDbReplicationPolicy.IsConfigured(configuration);
}
/// Opts every replicated table into change capture, baselining what is already there.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs
index a0fc339d..80369a23 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Engines;
///
/// Single-tag mode: cross-tag ctx.SetVirtualTag writes are dropped (logged) because
/// fan-out between actors is owned by DependencyMuxActor, not by the eval engine.
-/// Cycle detection + cascade ordering live in ; this adapter
+/// Cycle detection lives in the deploy gate (DependencyGraph); this adapter
/// stays single-tag scoped to keep 's message loop simple.
///
///
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
index eee64688..7693ac45 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
@@ -579,7 +579,14 @@ public sealed class AddressSpaceApplier
}
///
- /// Auto-provision the historian for the added historized equipment tags. Runs on the OPC UA
+ /// Resolve the historian name EXACTLY as MaterialiseRawSubtree does: a null/blank override falls
+ /// back to the RawPath (== NodeId).
+ ///
+ private static string ResolveHistorianName(RawTagPlan tag) =>
+ string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.NodeId : tag.HistorianTagname;
+
+ ///
+ /// Auto-provision the historian for the added (and newly-historized / renamed-historian) raw tags. Runs on the OPC UA
/// publish actor's pinned thread, so the synchronous portion is kept to building the request
/// list only and the gateway round-trip is dispatched fire-and-forget. The whole hook is wrapped
/// in try/catch — a synchronously-throwing provisioner (or any request-building fault) is
@@ -597,22 +604,39 @@ public sealed class AddressSpaceApplier
foreach (var tag in plan.AddedRawTags)
{
if (!tag.IsHistorized || tag.Alarm is not null) continue;
+ AddRequest(tag);
+ }
- // Parse the driver-agnostic data type from the tag's DataType string. An unparseable
- // type is skipped (logged at Debug) rather than faulting the hook.
+ // #600.1: a historization flip (false->true) or a historian-name / data-type change on an
+ // EXISTING raw tag never appears in AddedRawTags, so EnsureTags never fired for it. EnsureTags is
+ // an upsert, so re-ensuring is safe; an unchanged pair is a no-op. Only raw deltas are used - a
+ // UNS mirror delta must not double-provision the same historian tag.
+ foreach (var d in plan.ChangedRawTags)
+ {
+ var cur = d.Current;
+ if (!cur.IsHistorized || cur.Alarm is not null) continue;
+ var prev = d.Previous;
+ var wasProvisioned = prev.IsHistorized && prev.Alarm is null;
+ if (wasProvisioned
+ && ResolveHistorianName(prev) == ResolveHistorianName(cur)
+ && string.Equals(prev.DataType, cur.DataType, StringComparison.OrdinalIgnoreCase))
+ continue;
+ AddRequest(cur);
+ }
+
+ void AddRequest(RawTagPlan tag)
+ {
+ // An unparseable data type is skipped (logged at Debug) rather than faulting the hook.
if (!Enum.TryParse(tag.DataType, ignoreCase: true, out var dataType))
{
_logger.LogDebug(
- "AddressSpaceApplier: skipping historian provisioning for an added historized tag whose data type '{DataType}' is not a DriverDataType",
+ "AddressSpaceApplier: skipping historian provisioning for a historized tag whose data type '{DataType}' is not a DriverDataType",
tag.DataType);
- continue;
+ return;
}
- // Resolve the historian name EXACTLY as MaterialiseRawSubtree does: a null/blank override
- // falls back to the RawPath (== NodeId).
- var historianName = string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.NodeId : tag.HistorianTagname;
(requests ??= new List()).Add(
- new HistorianTagProvisionRequest(historianName, dataType, EngineeringUnit: null, Description: tag.Name));
+ new HistorianTagProvisionRequest(ResolveHistorianName(tag), dataType, EngineeringUnit: null, Description: tag.Name));
}
if (requests is null) return;
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/LocalDbReplicationPolicy.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/LocalDbReplicationPolicy.cs
new file mode 100644
index 00000000..e4106f55
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/LocalDbReplicationPolicy.cs
@@ -0,0 +1,43 @@
+using Microsoft.Extensions.Configuration;
+
+namespace ZB.MOM.WW.OtOpcUa.Runtime;
+
+///
+/// The ONE definition of "this node's LocalDb is replicated to a peer" (#603). Two consumers must agree
+/// on it, and used to carry separate predicates that diverged: the Host's table-registration step
+/// (LocalDbSetup.ReplicationIsConfigured - which nodes capture changes) and the alarm-historian
+/// drain gate (AddAlarmHistorian - which nodes stand down in favour of a peer). Lives in Runtime
+/// because Host references Runtime and not the reverse.
+///
+///
+///
+/// Predicate: LocalDb:Replication:PeerAddress OR LocalDb:Replication:ApiKey is
+/// non-blank. The OR is required: only the dialling half of a pair sets PeerAddress, while
+/// the passive half carries the shared ApiKey (plus a listen port) and nothing else.
+///
+///
+/// Why the drain gate takes the same predicate (it previously used PeerAddress OR
+/// SyncListenPort). The gate's question is "does a peer hold the same rows?". The passive
+/// sync endpoint is fail-closed on the key, so a node with a listen port but no ApiKey rejects
+/// every inbound stream and shares its queue with nobody - gating it would park its alarm events
+/// behind a Primary that cannot deliver them. Conversely an ApiKey-only passive node IS
+/// registered and does receive its peer's rows, so it must be gated or both halves drain. The port
+/// is not evidence of a peer; the key is.
+///
+///
+public static class LocalDbReplicationPolicy
+{
+ /// Configuration section holding the LocalDb replication options.
+ public const string ReplicationSectionPath = "LocalDb:Replication";
+
+ /// Whether this node's LocalDb replicates to a pair peer.
+ /// The application configuration.
+ /// true when PeerAddress or ApiKey is set under the replication section.
+ public static bool IsConfigured(IConfiguration configuration)
+ {
+ ArgumentNullException.ThrowIfNull(configuration);
+ var section = configuration.GetSection(ReplicationSectionPath);
+ return !string.IsNullOrWhiteSpace(section["PeerAddress"])
+ || !string.IsNullOrWhiteSpace(section["ApiKey"]);
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs
index ee611e63..a530ce62 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs
@@ -177,12 +177,11 @@ public static class ServiceCollectionExtensions
// possibly deliver its events. Scoping the gate to "is my queue actually shared?" is what
// keeps the two scopes from disagreeing.
// BOTH halves of a pair share the queue, but only one of them dials: the initiator sets
- // Replication:PeerAddress, its partner only sets SyncListenPort and waits. Testing the dial
- // side alone would leave the listening half permanently ungated — one drainer per pair by
- // accident rather than by role, and the wrong one whenever the roles swap.
- var replicated =
- !string.IsNullOrWhiteSpace(configuration["LocalDb:Replication:PeerAddress"])
- || !string.IsNullOrWhiteSpace(configuration["LocalDb:SyncListenPort"]);
+ // Replication:PeerAddress, its partner carries the shared ApiKey and waits. The predicate is
+ // LocalDbReplicationPolicy - the same one LocalDbSetup uses to decide which nodes capture
+ // changes at all. It deliberately does NOT count SyncListenPort: the passive endpoint is
+ // fail-closed on the key, so a port with no ApiKey shares nothing and must keep draining.
+ var replicated = LocalDbReplicationPolicy.IsConfigured(configuration);
if (!replicated)
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs
index c2c7a8fd..c050bf8e 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs
@@ -366,7 +366,7 @@ public sealed class VirtualTagActor : ReceiveActor
_virtualTagId, _hasLastValue ? _lastValue : null, timestampUtc, CorrelationId.NewId(), OpcUaStatus.BadInternalError));
}
- /// Inputs-ready gate (mirrors the dormant VirtualTagEngine.AreInputsReady): true once
+ /// 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.
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptSandboxBuildTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptSandboxBuildTests.cs
index a3d3e212..5231af8b 100644
--- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptSandboxBuildTests.cs
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptSandboxBuildTests.cs
@@ -57,4 +57,18 @@ public sealed class ScriptSandboxBuildTests
"""return (double)ctx.GetTag("X").Value;""");
evaluator.ShouldNotBeNull();
}
+
+ /// #603: the BCL reference set is built once per process, so two Build calls share the same
+ /// MetadataReference instances rather than re-reading ~170 DLLs per compile.
+ [Fact]
+ public void Build_reuses_metadata_reference_instances_across_calls()
+ {
+ var first = ScriptSandbox.Build(typeof(ScriptContext));
+ var second = ScriptSandbox.Build(typeof(ScriptContext));
+
+ first.References.Count.ShouldBeGreaterThan(50);
+ first.References.Count.ShouldBe(second.References.Count);
+ for (var i = 0; i < first.References.Count; i++)
+ ReferenceEquals(first.References[i], second.References[i]).ShouldBeTrue($"reference {i} was rebuilt");
+ }
}
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests/FakeUpstream.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests/FakeUpstream.cs
deleted file mode 100644
index b19aea0d..00000000
--- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests/FakeUpstream.cs
+++ /dev/null
@@ -1,91 +0,0 @@
-using System.Collections.Concurrent;
-using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
-using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
-
-namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests;
-
-///
-/// In-memory for tests. Seed tag values via
-/// , push changes via . Tracks subscriptions so
-/// tests can assert the engine disposes them on reload / shutdown.
-///
-public sealed class FakeUpstream : ITagUpstreamSource
-{
- private readonly ConcurrentDictionary _values = new(StringComparer.Ordinal);
- private readonly ConcurrentDictionary>> _subs = new(StringComparer.Ordinal);
-
- /// Gets the number of active subscriptions.
- public int ActiveSubscriptionCount { get; private set; }
-
- /// Sets a tag value without notifying subscribers.
- /// The path to the tag.
- /// The value to set.
- /// The OPC UA status code for the value.
- public void Set(string path, object value, uint statusCode = 0u)
- {
- var now = DateTime.UtcNow;
- _values[path] = new DataValueSnapshot(value, statusCode, now, now);
- }
-
- /// Sets a tag value and notifies all subscribers.
- /// The path to the tag.
- /// The value to set.
- /// The OPC UA status code for the value.
- public void Push(string path, object value, uint statusCode = 0u)
- {
- Set(path, value, statusCode);
- if (_subs.TryGetValue(path, out var list))
- {
- Action[] snap;
- lock (list) { snap = list.ToArray(); }
- foreach (var obs in snap) obs(path, _values[path]);
- }
- }
-
- /// Reads the current value of a tag.
- /// The path to the tag.
- public DataValueSnapshot ReadTag(string path)
- => _values.TryGetValue(path, out var v)
- ? v
- : new DataValueSnapshot(null, 0x80340000u, null, DateTime.UtcNow);
-
- /// Subscribes to tag value changes.
- /// The path to the tag.
- /// The callback to invoke when the tag value changes.
- public IDisposable SubscribeTag(string path, Action observer)
- {
- var list = _subs.GetOrAdd(path, _ => []);
- lock (list) { list.Add(observer); }
- ActiveSubscriptionCount++;
- return new Unsub(this, path, observer);
- }
-
- private sealed class Unsub : IDisposable
- {
- private readonly FakeUpstream _up;
- private readonly string _path;
- private readonly Action _observer;
-
- /// Initializes a new subscription unsubscription handler.
- /// The parent FakeUpstream instance.
- /// The tag path.
- /// The observer callback to unsubscribe.
- public Unsub(FakeUpstream up, string path, Action observer)
- {
- _up = up; _path = path; _observer = observer;
- }
-
- /// Unsubscribes from tag value changes.
- public void Dispose()
- {
- if (_up._subs.TryGetValue(_path, out var list))
- {
- lock (list)
- {
- if (list.Remove(_observer))
- _up.ActiveSubscriptionCount--;
- }
- }
- }
- }
-}
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests/TimerTriggerSchedulerTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests/TimerTriggerSchedulerTests.cs
deleted file mode 100644
index f21b64d2..00000000
--- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests/TimerTriggerSchedulerTests.cs
+++ /dev/null
@@ -1,203 +0,0 @@
-using Serilog;
-using Shouldly;
-using Xunit;
-using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
-using ZB.MOM.WW.OtOpcUa.Core.Scripting;
-using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
-
-namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests;
-
-[Trait("Category", "Unit")]
-public sealed class TimerTriggerSchedulerTests
-{
- /// Verifies that timer interval causes periodic reevaluation of virtual tags.
- [Fact]
- public async Task Timer_interval_causes_periodic_reevaluation()
- {
- var up = new FakeUpstream();
- // Counter source — re-eval should pick up new value each tick.
- var counter = 0;
- var logger = new LoggerConfiguration().CreateLogger();
-
- using var engine = new VirtualTagEngine(up,
- new ScriptLoggerFactory(logger),
- logger);
-
- engine.Load([new VirtualTagDefinition(
- "Counter", DriverDataType.Int32,
- """return ctx.Now.Millisecond;""", // changes on every evaluation
- ChangeTriggered: false,
- TimerInterval: TimeSpan.FromMilliseconds(100))]);
-
- using var sched = new TimerTriggerScheduler(engine, logger);
- sched.Start([new VirtualTagDefinition(
- "Counter", DriverDataType.Int32,
- """return ctx.Now.Millisecond;""",
- ChangeTriggered: false,
- TimerInterval: TimeSpan.FromMilliseconds(100))]);
-
- // Watch the value change across ticks.
- var snapshots = new List