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
@@ -123,20 +123,23 @@ public static class AddressSpaceChangeClassifier
}
// A VirtualTag's materialised OPC UA node (MaterialiseEquipmentVirtualTags) is derived ONLY from
// {EquipmentId, FolderPath, Name, DataType}. Expression/DependencyRefs/Historize are engine/write-side
// {EquipmentId, FolderPath, Name, DataType}. Expression/DependencyRefs/Historize/Enabled are engine/write-side
// only and are adopted by VirtualTagHostActor's INDEPENDENT respawn (DriverHostActor → ApplyVirtualTags),
// so a delta changing ONLY those three leaves a byte-identical node and needs no address-space rebuild.
// so a delta changing ONLY those four leaves a byte-identical node and needs no address-space rebuild.
// Whitelist-of-may-differ via `with` + the record's custom Equals: any OTHER field difference (current
// or future) makes the override unequal → falls back to a full rebuild (safe default).
/// <summary>Whether a changed equipment virtual tag differs only in fields that do not affect its materialised node.</summary>
/// <param name="d">The virtual-tag delta to classify.</param>
/// <returns><see langword="true"/> when only Expression / DependencyRefs / Historize differ; otherwise <see langword="false"/>.</returns>
/// <returns><see langword="true"/> when only Expression / DependencyRefs / Historize / Enabled differ; otherwise <see langword="false"/>.</returns>
internal static bool VtagDeltaIsNodeIrrelevant(AddressSpacePlan.EquipmentVirtualTagDelta d) =>
(d.Previous with
{
Expression = d.Current.Expression,
DependencyRefs = d.Current.DependencyRefs,
Historize = d.Current.Historize,
// Enabled (lmxopcua#787) is node-irrelevant too: the node exists either way; the VirtualTag host
// publishes BadOutOfService on disable / resumes evaluation on enable from its own apply.
Enabled = d.Current.Enabled,
}).Equals(d.Current);
// v3 surgical raw-tag eligibility: a NON-alarm raw tag whose only differences are the in-place-updatable
@@ -378,6 +378,10 @@ public sealed record EquipmentTagAlarmInfo(string AlarmType, int Severity, bool?
/// which keeps existing positional+named ctor call sites compiling and preserves byte-parity.</param>
/// <param name="ChangeTriggered">Evaluate on a dependency change (entity default <c>true</c>).</param>
/// <param name="TimerIntervalMs">Periodic re-evaluation cadence in ms, or null for none (lmxopcua#783).</param>
/// <param name="Enabled">False for a disabled VirtualTag (lmxopcua#787): the node is still materialised (both
/// subtrees, so a bound client reads a status instead of <c>BadNodeIdUnknown</c>) but no <c>VirtualTagActor</c>
/// is spawned — <c>VirtualTagHostActor</c> publishes <c>BadOutOfService</c> with a null value instead. Entity
/// default <c>true</c>; the composer and the artifact decode carry it identically.</param>
public sealed record EquipmentVirtualTagPlan(
string VirtualTagId,
string EquipmentId,
@@ -388,7 +392,8 @@ public sealed record EquipmentVirtualTagPlan(
IReadOnlyList<string> DependencyRefs,
bool Historize = false,
bool ChangeTriggered = true,
int? TimerIntervalMs = null)
int? TimerIntervalMs = null,
bool Enabled = true)
{
/// <inheritdoc />
// Structural equality: the auto-generated record equality would compare DependencyRefs (an
@@ -406,6 +411,7 @@ public sealed record EquipmentVirtualTagPlan(
Historize == other.Historize &&
ChangeTriggered == other.ChangeTriggered &&
TimerIntervalMs == other.TimerIntervalMs &&
Enabled == other.Enabled &&
DependencyRefs.SequenceEqual(other.DependencyRefs, StringComparer.Ordinal);
/// <inheritdoc />
@@ -414,7 +420,7 @@ public sealed record EquipmentVirtualTagPlan(
var hash = new HashCode();
hash.Add(VirtualTagId); hash.Add(EquipmentId); hash.Add(FolderPath);
hash.Add(Name); hash.Add(DataType); hash.Add(Expression);
hash.Add(Historize); hash.Add(ChangeTriggered); hash.Add(TimerIntervalMs);
hash.Add(Historize); hash.Add(ChangeTriggered); hash.Add(TimerIntervalMs); hash.Add(Enabled);
foreach (var r in DependencyRefs) hash.Add(r, StringComparer.Ordinal);
return hash.ToHashCode();
}
@@ -615,10 +621,11 @@ public static class AddressSpaceComposer
// DependencyRefs = the distinct ctx.GetTag("…") literals the VirtualTagActor subscribes to.
// VirtualTag has no FolderPath today → "".
var scriptsById = resolvedScripts.ToDictionary(s => s.ScriptId, StringComparer.Ordinal);
// A disabled VirtualTag (lmxopcua#787) is not part of the runtime plan: no node, no evaluation —
// the same as a disabled scripted alarm (which is not materialised). Mirrored by the artifact decode.
// A disabled VirtualTag (lmxopcua#787, owner decision) STAYS in the plan with Enabled=false: its node
// is materialised as normal but it is never evaluated — the VirtualTag host publishes BadOutOfService.
// Scripted alarms keep their own convention (a disabled one is not materialised). Mirrored by the
// artifact decode.
var equipmentVirtualTags = vtags
.Where(v => v.Enabled)
.OrderBy(v => v.EquipmentId, StringComparer.Ordinal)
.ThenBy(v => v.Name, StringComparer.Ordinal)
.Select(v =>
@@ -636,7 +643,8 @@ public static class AddressSpaceComposer
DependencyRefs: EquipmentScriptPaths.ExtractDependencyRefs(expanded),
Historize: v.Historize,
ChangeTriggered: v.ChangeTriggered,
TimerIntervalMs: v.TimerIntervalMs);
TimerIntervalMs: v.TimerIntervalMs,
Enabled: v.Enabled);
})
.ToList();
@@ -1405,9 +1405,10 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
if (string.IsNullOrWhiteSpace(virtualTagId) || string.IsNullOrWhiteSpace(equipmentId)
|| string.IsNullOrWhiteSpace(name)) continue;
// Enabled (lmxopcua#787): a disabled VirtualTag is not deployed or evaluated — byte-parity with
// AddressSpaceComposer.Compose's `.Where(v => v.Enabled)`. Absent/non-bool => the entity default (true).
if (el.TryGetProperty("Enabled", out var enEl) && enEl.ValueKind == JsonValueKind.False) continue;
// Enabled (lmxopcua#787): a disabled VirtualTag keeps its plan (node materialised, never evaluated,
// BadOutOfService) — byte-parity with AddressSpaceComposer.Compose's `Enabled: v.Enabled`.
// Absent/non-bool => the entity default (true).
var enabled = !(el.TryGetProperty("Enabled", out var enEl) && enEl.ValueKind == JsonValueKind.False);
var source = scriptId is not null && scriptSourceById.TryGetValue(scriptId, out var src)
? src : string.Empty;
@@ -1442,7 +1443,8 @@ internal sealed class ParsedDeploymentArtifact : IDisposable
DependencyRefs: EquipmentScriptPaths.ExtractDependencyRefs(expanded),
Historize: historize,
ChangeTriggered: changeTriggered,
TimerIntervalMs: timerMs));
TimerIntervalMs: timerMs,
Enabled: enabled));
}
result.Sort((a, b) =>
@@ -106,7 +106,11 @@ public sealed class VirtualTagHostActor : ReceiveActor
// and the clear fires (harmless — converges the singleton evaluator's cache to this generation).
// NOTE: this gate reduces clear FREQUENCY only; the S12 retry-once in RoslynVirtualTagEvaluator —
// not this gate — is the correctness fix for a clear racing an in-flight evaluation.
var newExpressions = msg.Plans.Select(p => p.Expression).ToHashSet(StringComparer.Ordinal);
// Only ENABLED plans are evaluated (lmxopcua#787). A disabled plan keeps its node (the applier
// materialises every plan) but gets no child: it is published BadOutOfService below. A plan flipped
// enabled->disabled drops out of `desired`, so its live child is stopped by the loop right after.
var evaluated = msg.Plans.Where(p => p.Enabled).ToList();
var newExpressions = evaluated.Select(p => p.Expression).ToHashSet(StringComparer.Ordinal);
var prevExpressions = _planByVtag.Values.Select(p => p.Expression).ToHashSet(StringComparer.Ordinal);
if (!newExpressions.SetEquals(prevExpressions))
{
@@ -118,7 +122,7 @@ public sealed class VirtualTagHostActor : ReceiveActor
// an edited expression, the guard detects on its own). Cheap on a no-op redeploy (an empty map).
(_evaluator as IScriptQuarantineOwner)?.ResetQuarantines();
var desired = new HashSet<string>(msg.Plans.Select(p => p.VirtualTagId), StringComparer.Ordinal);
var desired = new HashSet<string>(evaluated.Select(p => p.VirtualTagId), StringComparer.Ordinal);
// Stop + forget children whose vtagId is no longer desired. Stopping the child triggers its
// PostStop, which unregisters its interest from the mux.
@@ -133,7 +137,7 @@ public sealed class VirtualTagHostActor : ReceiveActor
// redeploy diffs equal and is left untouched. Forgetting the child here lets the spawn-new
// loop below recreate it from the new plan — children are auto-named (no explicit name), so a
// stop+respawn cannot hit the "actor name not unique" collision.
foreach (var p in msg.Plans)
foreach (var p in evaluated)
{
if (_children.ContainsKey(p.VirtualTagId)
&& _planByVtag.TryGetValue(p.VirtualTagId, out var prev)
@@ -153,7 +157,7 @@ public sealed class VirtualTagHostActor : ReceiveActor
// next evaluation overwrites — acceptable (and strictly better than the pre-H1b behaviour,
// where a renamed vtag was a silent no-op).
_nodeIdByVtag.Clear();
foreach (var p in msg.Plans)
foreach (var p in evaluated)
{
_nodeIdByVtag[p.VirtualTagId] = NodeIdFor(p);
}
@@ -166,7 +170,7 @@ public sealed class VirtualTagHostActor : ReceiveActor
// Track the just-spawned vtags so the re-assert pass below skips them: a fresh child has no last
// value to re-assert and publishes its initial value on the first dependency arrival anyway.
var newlySpawned = new HashSet<string>(StringComparer.Ordinal);
foreach (var p in msg.Plans)
foreach (var p in evaluated)
{
if (_children.ContainsKey(p.VirtualTagId)) continue;
@@ -192,7 +196,7 @@ public sealed class VirtualTagHostActor : ReceiveActor
// Refresh the plan map to exactly the desired set so the next apply can detect in-place
// changes against what each live child was actually built from.
_planByVtag.Clear();
foreach (var p in msg.Plans)
foreach (var p in evaluated)
{
_planByVtag[p.VirtualTagId] = p;
}
@@ -215,8 +219,20 @@ public sealed class VirtualTagHostActor : ReceiveActor
child.Tell(VirtualTagActor.ReassertValue.Instance);
}
_log.Debug("VirtualTagHost: applied (desired={Desired}, children={Children})",
desired.Count, _children.Count);
// Disabled plans: never evaluated; publish BadOutOfService + null value on the (still present) UNS
// node, stamped at deploy time. Re-published on EVERY apply on purpose: a deploy that rebuilds the
// address space resets every VirtualTag node to BadWaitingForInitialData, and this Tell lands in the
// publish actor's FIFO mailbox after that RebuildAddressSpace (same ordering argument as the
// re-assert above). Never historized (no real evaluation happened) and not in _nodeIdByVtag: no child
// exists to send a result for them.
foreach (var p in msg.Plans.Where(p => !p.Enabled))
{
_publishActor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
NodeIdFor(p), null, OpcUaStatus.BadOutOfService, DateTime.UtcNow, AddressSpaceRealm.Uns));
}
_log.Debug("VirtualTagHost: applied (desired={Desired}, children={Children}, disabled={Disabled})",
desired.Count, _children.Count, msg.Plans.Count - evaluated.Count);
}
private void OnResult(VirtualTagActor.EvaluationResult result)