fix(virtualtags): a disabled VirtualTag is no longer deployed or evaluated (lmxopcua#787)
v2-ci / unit-tests (push) Waiting to run
v2-ci / unit-tests (push) Waiting to run
VirtualTag.Enabled was authorable but ignored by both runtime-plan seams. AddressSpaceComposer.Compose and DeploymentArtifact.BuildEquipmentVirtualTagPlans now drop disabled rows (byte-parity), so no node is materialised and no VirtualTagActor is spawned - the same outcome as a disabled scripted alarm. DraftValidator exempts a disabled VT from VirtualTagNoEffectiveTrigger. Calculation tags have no Enabled flag, so no equivalent gap. docs/VirtualTags.md updated. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -229,6 +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.
|
||||
- `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.
|
||||
|
||||
@@ -493,6 +493,8 @@ public static class DraftValidator
|
||||
.ToDictionary(g => g.Key, g => g.First().SourceCode, StringComparer.Ordinal);
|
||||
foreach (var v in draft.VirtualTags)
|
||||
{
|
||||
// A disabled VirtualTag is never deployed or evaluated (lmxopcua#787), so it can't be stuck waiting.
|
||||
if (!v.Enabled) continue;
|
||||
if (v.TimerIntervalMs is > 0) continue;
|
||||
var source = scriptSourceById.TryGetValue(v.ScriptId, out var s) ? s : null;
|
||||
// An unresolved script is reported elsewhere; with no source there are no reads to count.
|
||||
|
||||
@@ -615,7 +615,10 @@ 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.
|
||||
var equipmentVirtualTags = vtags
|
||||
.Where(v => v.Enabled)
|
||||
.OrderBy(v => v.EquipmentId, StringComparer.Ordinal)
|
||||
.ThenBy(v => v.Name, StringComparer.Ordinal)
|
||||
.Select(v =>
|
||||
|
||||
@@ -1405,6 +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;
|
||||
|
||||
var source = scriptId is not null && scriptSourceById.TryGetValue(scriptId, out var src)
|
||||
? src : string.Empty;
|
||||
|
||||
|
||||
+10
@@ -45,6 +45,16 @@ public sealed class DraftValidatorVirtualTagTriggerTests
|
||||
public void A_literal_read_with_change_trigger_is_an_effective_trigger() =>
|
||||
Run(Vt(true, null), "return (double)ctx.GetTag(\"a/b\").Value;").ShouldBeEmpty();
|
||||
|
||||
/// <summary>lmxopcua#787 — a disabled VirtualTag is never deployed or evaluated, so it must not block a
|
||||
/// deploy for lacking an effective trigger.</summary>
|
||||
[Fact]
|
||||
public void A_disabled_virtual_tag_is_exempt()
|
||||
{
|
||||
var vt = Vt(true, null);
|
||||
vt.Enabled = false;
|
||||
Run(vt, "return 1;").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unresolved_script_reports_no_trigger_error()
|
||||
{
|
||||
|
||||
+30
@@ -163,6 +163,36 @@ public sealed class DeploymentArtifactVirtualTagHistorizeParityTests
|
||||
d.ChangeTriggered.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>lmxopcua#787 — a disabled VirtualTag is dropped by both the artifact decode and the composer
|
||||
/// (byte-parity), so it is neither deployed nor evaluated; absent/non-bool Enabled keeps the default true.</summary>
|
||||
[Fact]
|
||||
public void ParseComposition_drops_disabled_virtual_tag_in_parity_with_composer()
|
||||
{
|
||||
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
|
||||
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
|
||||
var equip = new Equipment { EquipmentId = "eq-1", UnsLineId = "line-1", Name = "M1", MachineCode = "M1" };
|
||||
var script = new Script { ScriptId = "s-1", Name = "p", SourceCode = "return 1;", SourceHash = "h" };
|
||||
var on = new VirtualTag { VirtualTagId = "vt-on", EquipmentId = "eq-1", Name = "On", DataType = "Int32", ScriptId = "s-1", TimerIntervalMs = 1000 };
|
||||
var off = new VirtualTag { VirtualTagId = "vt-off", EquipmentId = "eq-1", Name = "Off", DataType = "Int32", ScriptId = "s-1", TimerIntervalMs = 1000, Enabled = false };
|
||||
|
||||
var composed = AddressSpaceComposer.Compose(
|
||||
new[] { area }, new[] { line }, new[] { equip },
|
||||
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>(),
|
||||
virtualTags: new[] { on, off }, scripts: new[] { script });
|
||||
|
||||
var blob = BlobOf(new
|
||||
{
|
||||
Namespaces = new[] { new { NamespaceId = "ns-eq", Kind = 0 } },
|
||||
Scripts = new[] { new { ScriptId = "s-1", SourceCode = "return 1;" } },
|
||||
VirtualTags = new[] { ToSnapshot(on), ToSnapshot(off) },
|
||||
});
|
||||
var decoded = DeploymentArtifact.ParseComposition(blob);
|
||||
|
||||
composed.EquipmentVirtualTags.ShouldHaveSingleItem().VirtualTagId.ShouldBe("vt-on");
|
||||
decoded.EquipmentVirtualTags.ShouldHaveSingleItem().VirtualTagId.ShouldBe("vt-on");
|
||||
decoded.EquipmentVirtualTags.SequenceEqual(composed.EquipmentVirtualTags).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// The Pascal-case snapshot a VirtualTag EF entity serialises to (matches ConfigComposer's
|
||||
// DefaultIgnoreCondition.Never whole-entity serialisation — includes Historize).
|
||||
private static object ToSnapshot(VirtualTag v) => new
|
||||
|
||||
+36
-2
@@ -1,3 +1,4 @@
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using Akka.Actor;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -83,8 +84,31 @@ public sealed class DriverHostActorVirtualTagTests : RuntimeActorTestBase
|
||||
apply.Plans.ShouldHaveSingleItem().VirtualTagId.ShouldBe("vt-1");
|
||||
}
|
||||
|
||||
/// <summary>lmxopcua#787: a deployment carrying a disabled VirtualTag next to an enabled one hands the
|
||||
/// VirtualTag host ONLY the enabled plan — the disabled tag is never spawned, so never evaluated.</summary>
|
||||
[Fact]
|
||||
public void Apply_does_not_forward_disabled_VirtualTag_to_virtual_tag_host()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var deploymentId = SeedDeploymentWithVirtualTag(db, RevA, withDisabledSibling: true);
|
||||
|
||||
var coordinator = CreateTestProbe();
|
||||
var vtHost = CreateTestProbe();
|
||||
var actor = Sys.ActorOf(DriverHostActor.Props(
|
||||
db, TestNode, coordinator.Ref,
|
||||
localRoles: new HashSet<string> { "driver" },
|
||||
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
|
||||
virtualTagHostOverride: vtHost.Ref));
|
||||
|
||||
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
|
||||
|
||||
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
||||
var apply = vtHost.ExpectMsg<VirtualTagHostActor.ApplyVirtualTags>(TimeSpan.FromSeconds(5));
|
||||
apply.Plans.ShouldHaveSingleItem().VirtualTagId.ShouldBe("vt-1");
|
||||
}
|
||||
|
||||
private static DeploymentId SeedDeploymentWithVirtualTag(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev)
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev, bool withDisabledSibling = false)
|
||||
{
|
||||
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
|
||||
{
|
||||
@@ -105,8 +129,18 @@ public sealed class DriverHostActorVirtualTagTests : RuntimeActorTestBase
|
||||
Name = "Doubled",
|
||||
DataType = "Float",
|
||||
ScriptId = "scr-1",
|
||||
Enabled = true,
|
||||
},
|
||||
},
|
||||
new
|
||||
{
|
||||
VirtualTagId = "vt-off",
|
||||
EquipmentId = "eq-1",
|
||||
Name = "Off",
|
||||
DataType = "Float",
|
||||
ScriptId = "scr-1",
|
||||
Enabled = false,
|
||||
},
|
||||
}.Take(withDisabledSibling ? 2 : 1).ToArray(),
|
||||
});
|
||||
|
||||
var id = DeploymentId.NewId();
|
||||
|
||||
Reference in New Issue
Block a user