fix(virtualtags): a disabled VirtualTag keeps its node and reads BadOutOfService (lmxopcua#787, owner decision)
v2-ci / unit-tests (push) Has been cancelled

Revises 963b0d66, which dropped a disabled VirtualTag from the runtime plan so a
bound client got BadNodeIdUnknown. Now the plan carries Enabled (part of value
equality): AddressSpaceComposer.Compose and DeploymentArtifact.BuildEquipmentVirtualTagPlans
keep the row with Enabled=false (byte-parity), the applier materialises the node as
normal, and VirtualTagHostActor spawns no VirtualTagActor for it but publishes
BadOutOfService (new OpcUaStatus constant) with a null value stamped at deploy, on
every apply. Enabled flips are node-irrelevant to the change classifier; enabled->disabled
stops the child, disabled->enabled resumes evaluation. Validator exemption and name
uniqueness unchanged. Scripted alarms keep their not-materialised convention.
Tests updated; new wire test DisabledVirtualTagWireTests. docs/VirtualTags.md updated.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-10 04:48:56 -05:00
parent 8582ce91bc
commit f201f16fd4
10 changed files with 238 additions and 30 deletions
+1 -1
View File
@@ -229,7 +229,7 @@ In the v2 actor system, virtual-tag engine composition is owned by the driver-ro
- `AddressSpaceComposer.Compose` emits `DriverInstancePlan` / `ScriptedAlarmPlan` records; the driver-role `DriverHostActor` spawns one `VirtualTagActor` per virtual-tag expression and one `ScriptedAlarmActor` per scripted alarm.
- `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.
- **Disabled virtual tags (`VirtualTag.Enabled = false`, lmxopcua#787).** Both the composer and the artifact decode (`DeploymentArtifact.BuildEquipmentVirtualTagPlans`, byte-parity) drop a disabled row from `EquipmentVirtualTags`, so it is neither materialised as a node nor spawned/evaluated. This matches disabled scripted alarms (not materialised). Consequences: a client bound to the node (e.g. ScadaBridge) gets `BadNodeIdUnknown`, and a script that reads it via `ctx.GetTag` sees a Bad/missing upstream; re-enabling and redeploying restores it. `DraftValidator` exempts a disabled VT from `VirtualTagNoEffectiveTrigger` (it never evaluates, so it must not block a deploy); its name still counts toward the equipment name-uniqueness check. Calculation tags have no `Enabled` flag, so there is no equivalent gap.
- **Disabled virtual tags (`VirtualTag.Enabled = false`, lmxopcua#787, owner decision).** A disabled tag **keeps its node** but is **never evaluated**. Both seams (`AddressSpaceComposer.Compose` and the artifact decode `DeploymentArtifact.BuildEquipmentVirtualTagPlans`, byte-parity) keep the row in `EquipmentVirtualTags` with `EquipmentVirtualTagPlan.Enabled = false` (part of the plan's value equality), so the node is materialised exactly as for an enabled tag (same NodeId, read-only). `VirtualTagHostActor` spawns **no** `VirtualTagActor` for it (no mux registration, no timer, no historization) and instead publishes **`BadOutOfService` (0x808D0000) with a null value**, stamped `DateTime.UtcNow` at deploy, on every apply (a rebuild resets the node to `BadWaitingForInitialData`; the publish lands after it in the publish actor's mailbox). A client bound to the node (e.g. ScadaBridge) therefore reads a Bad quality, not `BadNodeIdUnknown`, and can tell "deliberately out of service" from "script failed" (`BadInternalError`). Writes are refused like any VirtualTag (the node is read-only). Flipping `Enabled` is node-irrelevant to `AddressSpaceChangeClassifier` (no address-space rebuild): enabled -> disabled stops the live child and publishes `BadOutOfService`; disabled -> enabled (redeploy) spawns the child and evaluation resumes on the next trigger. A script that reads a disabled VT via `ctx.GetTag` gets no pushed value for it (VirtualTag outputs are not fed through `DependencyMuxActor`, which carries driver publishes only), i.e. the same never-pushed/null upstream it would see for an enabled VT, so a dependent never sees a stale Good. `DraftValidator` exempts a disabled VT from `VirtualTagNoEffectiveTrigger` (it never evaluates, so it must not block a deploy); its name still counts toward the equipment name-uniqueness check. **Scripted alarms are unchanged:** a disabled one is still not materialised (`MaterialiseScriptedAlarms` skips it) and exposes no node; only VirtualTags keep a node. Calculation tags have no `Enabled` flag, so there is no equivalent gap. Pinned by `VirtualTagHostActorTests`, `DeploymentArtifactVirtualTagHistorizeParityTests`, `DriverHostActorVirtualTagTests`, `DraftValidatorVirtualTagTriggerTests` and, on the wire, `DisabledVirtualTagWireTests`.
- `DependencyMuxActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs`) receives every `DriverInstanceActor.AttributeValueBatchPublished` and routes each item to the `VirtualTagActor` instances that registered interest in that tag ref.
`ITagUpstreamSource` and `IHistoryWriter` remain available as `Core.VirtualTags` ports.