perf(opcua): skip address-space rebuild for vtag expression/deps/historize-only edits (F10b)

This commit is contained in:
Joseph Doherty
2026-06-18 13:03:50 -04:00
parent 0226b49437
commit 6c6a2c4203
2 changed files with 211 additions and 8 deletions
@@ -648,11 +648,15 @@ public sealed class Phase7ApplierTests
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>H1a — a deploy that ONLY edits an existing VirtualTag's expression must rebuild the
/// address space. The planner diffs it into <c>ChangedEquipmentVirtualTags</c> alone; the applier
/// must drive exactly one rebuild.</summary>
/// <summary>F10b (backlog #11) — a deploy that ONLY edits an existing VirtualTag's <c>Expression</c>
/// must SKIP the address-space rebuild. The vtag's materialised node is derived only from
/// {EquipmentId, FolderPath, Name, DataType}, so an Expression-only edit leaves a byte-identical node;
/// the vtag engine adopts the new expression via <c>VirtualTagHostActor</c>'s independent respawn, not
/// the address-space path. Skipping the rebuild preserves every client's server-wide subscriptions.
/// The edit STILL counts as a change (ChangedNodes == 1) — it just no longer forces a rebuild.
/// (Supersedes the former H1a "expression edit ⇒ rebuild" behavior.)</summary>
[Fact]
public void Changed_equipment_virtual_tags_only_trigger_rebuild()
public void Changed_virtual_tag_expression_only_skips_rebuild()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
@@ -674,8 +678,186 @@ public sealed class Phase7ApplierTests
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0); // NO RebuildAddressSpace — subscriptions preserved
outcome.ChangedNodes.ShouldBe(1); // the edit is still tallied as a change
}
/// <summary>F10b — a vtag delta differing ONLY in <c>DependencyRefs</c> (e.g. a new <c>ctx.GetTag</c>
/// literal in the script) is node-irrelevant and SKIPS the rebuild; the engine respawn picks up the
/// new dependency set.</summary>
[Fact]
public void Changed_virtual_tag_dependency_refs_only_skips_rebuild()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var previous = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }));
// Same node-relevant fields; only the dependency set differs.
var next = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a", "b" }));
var plan = Phase7Planner.Compute(previous, next);
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
outcome.ChangedNodes.ShouldBe(1);
}
/// <summary>F10b — a vtag delta differing ONLY in <c>Historize</c> (write-side flag, not materialised
/// on the node) is node-irrelevant and SKIPS the rebuild.</summary>
[Fact]
public void Changed_virtual_tag_historize_only_skips_rebuild()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var previous = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }, Historize: false));
// Same node-relevant fields; only the Historize flag flips.
var next = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }, Historize: true));
var plan = Phase7Planner.Compute(previous, next);
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeFalse();
sink.RebuildCalls.ShouldBe(0);
outcome.ChangedNodes.ShouldBe(1);
}
/// <summary>F10b safe-default — a vtag delta where the node-affecting <c>DataType</c> ALSO changed is
/// NOT node-irrelevant: the materialised node would differ, so the applier must still rebuild. Pins
/// the whitelist (Expression/DependencyRefs/Historize) against accidentally swallowing a DataType
/// edit.</summary>
[Fact]
public void Changed_virtual_tag_data_type_change_still_rebuilds()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var previous = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\") * 120", DependencyRefs: new[] { "a" }));
// DataType flips AND the expression changes — DataType is node-affecting, so this must rebuild.
var next = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Int32",
Expression: "ctx.GetTag(\"a\") * 60", DependencyRefs: new[] { "a" }));
var plan = Phase7Planner.Compute(previous, next);
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>F10b safe-default — a vtag delta where the node-affecting <c>Name</c> changed must
/// rebuild (the node's BrowseName/NodeId derive from Name).</summary>
[Fact]
public void Changed_virtual_tag_name_change_still_rebuilds()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var previous = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }));
var next = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "EfficiencyPct", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }));
var plan = Phase7Planner.Compute(previous, next);
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>F10b safe-default — a vtag delta where the node-affecting <c>FolderPath</c> changed must
/// rebuild (the folder-scoped NodeId derives from FolderPath).</summary>
[Fact]
public void Changed_virtual_tag_folder_path_change_still_rebuilds()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var previous = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }));
var next = CompositionWithVirtualTags(
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "Calc", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }));
var plan = Phase7Planner.Compute(previous, next);
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>F10b — the skip is ONLY for a node-irrelevant vtag edit that is the SOLE change. A
/// node-irrelevant Expression-only vtag edit MIXED with any other change (here a changed equipment
/// tag) must still rebuild — the rebuild is forced by the OTHER change, and the running server gets
/// its single rebuild as before.</summary>
[Fact]
public void Node_irrelevant_vtag_edit_mixed_with_another_change_still_rebuilds()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var previous = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null),
},
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\") * 60", DependencyRefs: new[] { "a" }),
},
};
// Expression-only vtag edit (node-irrelevant) AND a node-affecting tag DataType flip.
var next = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentTags = new[]
{
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Int32", FullName: "40001", Writable: false, Alarm: null),
},
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64",
Expression: "ctx.GetTag(\"a\") * 120", DependencyRefs: new[] { "a" }),
},
};
var plan = Phase7Planner.Compute(previous, next);
// Both a node-irrelevant vtag change AND a node-affecting tag change are present.
plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1);
plan.ChangedEquipmentTags.Count.ShouldBe(1);
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
sink.RebuildCalls.ShouldBe(1);
}