perf(opcua): surgical in-place tag-attribute writes (Writable/Historizing) avoid rebuild (F10b)
This commit is contained in:
@@ -84,41 +84,90 @@ public sealed class Phase7Applier
|
||||
// Any add / remove / in-place CHANGE of Equipment, ScriptedAlarm, Equipment tag, or Equipment
|
||||
// VirtualTag topology requires a real address-space rebuild — the materialise passes re-derive
|
||||
// every node from the composition, so a changed-only deploy (e.g. a renamed equipment, a
|
||||
// re-severitied alarm, a flipped tag dataType/Writable) must still rebuild or the running server
|
||||
// keeps the stale node.
|
||||
// re-severitied alarm, a flipped tag dataType) must still rebuild or the running server keeps
|
||||
// the stale node.
|
||||
// ChangedDrivers is deliberately EXCLUDED: a driver-instance config change doesn't touch the
|
||||
// address-space topology — it routes through DriverHostActor's spawn-plan in Runtime, which
|
||||
// re-spawns the affected driver actor without re-materialising any nodes.
|
||||
// F10b: a CHANGED VirtualTag whose ONLY differences are Expression/DependencyRefs/Historize is
|
||||
// node-IRRELEVANT (see VtagDeltaIsNodeIrrelevant) — its materialised node is byte-identical and
|
||||
// the vtag engine adopts those edits via VirtualTagHostActor's INDEPENDENT respawn
|
||||
// (DriverHostActor → ApplyVirtualTags), so it skips the rebuild and PRESERVES every client's
|
||||
// server-wide subscriptions. Any structural / node-affecting vtag change (Name/FolderPath/
|
||||
// DataType) — or any non-vtag change anywhere — still forces a full rebuild (safe default).
|
||||
var needsRebuild =
|
||||
// F10b (vtag skip): a CHANGED VirtualTag whose ONLY differences are Expression/DependencyRefs/
|
||||
// Historize is node-IRRELEVANT (see VtagDeltaIsNodeIrrelevant) — its materialised node is
|
||||
// byte-identical and the vtag engine adopts those edits via VirtualTagHostActor's INDEPENDENT
|
||||
// respawn (DriverHostActor → ApplyVirtualTags), so it skips the rebuild and PRESERVES every
|
||||
// client's server-wide subscriptions. Any structural / node-affecting vtag change (Name/
|
||||
// FolderPath/DataType) — or any non-vtag change anywhere — still forces a full rebuild.
|
||||
// F10b (surgical tag write): a CHANGED equipment tag whose ONLY differences are Writable /
|
||||
// IsHistorized / HistorianTagname (a plain value variable — no alarm condition node) can be
|
||||
// updated IN PLACE on the existing node via ISurgicalAddressSpaceSink.UpdateTagAttributes
|
||||
// (see TagDeltaIsSurgicalEligible), again avoiding the full rebuild and preserving subscriptions.
|
||||
// Any other tag difference (DataType/IsArray/ArrayLength/FullName/identity/alarm) — or a sink
|
||||
// that lacks the surgical capability, or a node that turns out missing — falls back to a full
|
||||
// rebuild (safe default).
|
||||
var structuralRebuild =
|
||||
plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 || plan.ChangedEquipment.Count > 0 ||
|
||||
plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 || plan.ChangedAlarms.Count > 0 ||
|
||||
plan.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0 || plan.ChangedEquipmentTags.Count > 0 ||
|
||||
plan.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0 ||
|
||||
plan.ChangedEquipmentTags.Any(d => !TagDeltaIsSurgicalEligible(d)) ||
|
||||
plan.AddedEquipmentVirtualTags.Count > 0 || plan.RemovedEquipmentVirtualTags.Count > 0 ||
|
||||
plan.ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(d));
|
||||
|
||||
if (needsRebuild)
|
||||
var surgicalTagDeltas = plan.ChangedEquipmentTags.Where(TagDeltaIsSurgicalEligible).ToList();
|
||||
var rebuilt = false;
|
||||
|
||||
if (structuralRebuild)
|
||||
{
|
||||
try
|
||||
SafeRebuild();
|
||||
rebuilt = true;
|
||||
}
|
||||
else if (surgicalTagDeltas.Count > 0)
|
||||
{
|
||||
if (_sink is ISurgicalAddressSpaceSink surgical)
|
||||
{
|
||||
_sink.RebuildAddressSpace();
|
||||
var allApplied = true;
|
||||
foreach (var d in surgicalTagDeltas)
|
||||
{
|
||||
// Compute the node id + writable + historian EXACTLY as MaterialiseEquipmentTags would
|
||||
// so the in-place update matches what a rebuild would have produced.
|
||||
var nodeId = EquipmentNodeIds.Variable(d.Current.EquipmentId, d.Current.FolderPath, d.Current.Name);
|
||||
var writable = d.Current.Writable && !d.Current.IsArray;
|
||||
var historian = d.Current.IsHistorized
|
||||
? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.FullName : d.Current.HistorianTagname)
|
||||
: null;
|
||||
bool ok;
|
||||
try { ok = surgical.UpdateTagAttributes(nodeId, writable, historian); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Phase7Applier: surgical UpdateTagAttributes threw for {Node}", nodeId);
|
||||
ok = false;
|
||||
}
|
||||
if (!ok) { allApplied = false; break; }
|
||||
}
|
||||
if (!allApplied) { SafeRebuild(); rebuilt = true; }
|
||||
}
|
||||
catch (Exception ex)
|
||||
else
|
||||
{
|
||||
_logger.LogError(ex, "Phase7Applier: sink.RebuildAddressSpace threw");
|
||||
// Sink lacks the surgical capability ⇒ rebuild (safe default).
|
||||
SafeRebuild();
|
||||
rebuilt = true;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Phase7Applier: applied plan (added={Added}, removed={Removed}, changed={Changed}, rebuild={Rebuild})",
|
||||
addedCount, removedCount, changedCount, needsRebuild);
|
||||
"Phase7Applier: applied plan (added={Added}, removed={Removed}, changed={Changed}, surgicalTags={Surgical}, rebuild={Rebuild})",
|
||||
addedCount, removedCount, changedCount, rebuilt ? 0 : surgicalTagDeltas.Count, rebuilt);
|
||||
|
||||
return new Phase7ApplyOutcome(removedCount, addedCount, changedCount, needsRebuild);
|
||||
return new Phase7ApplyOutcome(removedCount, addedCount, changedCount, rebuilt);
|
||||
}
|
||||
|
||||
private void SafeRebuild()
|
||||
{
|
||||
try
|
||||
{
|
||||
_sink.RebuildAddressSpace();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Phase7Applier: sink.RebuildAddressSpace threw");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -340,6 +389,20 @@ public sealed class Phase7Applier
|
||||
Historize = d.Current.Historize,
|
||||
}).Equals(d.Current);
|
||||
|
||||
// F10b: a CHANGED equipment tag whose ONLY differences are Writable / IsHistorized / HistorianTagname
|
||||
// (a plain value variable — no alarm condition node) can be updated IN PLACE on the existing node via
|
||||
// ISurgicalAddressSpaceSink.UpdateTagAttributes, avoiding a full rebuild (preserving subscriptions).
|
||||
// DataType / IsArray / ArrayLength / FullName / DriverInstanceId / identity / alarm differences fall
|
||||
// through to a rebuild — the override-unequal default also covers any future field.
|
||||
private static bool TagDeltaIsSurgicalEligible(Phase7Plan.EquipmentTagDelta d) =>
|
||||
d.Previous.Alarm is null && d.Current.Alarm is null &&
|
||||
(d.Previous with
|
||||
{
|
||||
Writable = d.Current.Writable,
|
||||
IsHistorized = d.Current.IsHistorized,
|
||||
HistorianTagname = d.Current.HistorianTagname,
|
||||
}).Equals(d.Current);
|
||||
|
||||
/// <summary>The "no-event" condition state written to a removed equipment / alarm node before the
|
||||
/// rebuild tears it down: inactive, acked, confirmed, enabled, unshelved, severity 0, empty message.
|
||||
/// Drives Retain to false so a removed condition stops replaying on ConditionRefresh.</summary>
|
||||
|
||||
@@ -1058,6 +1058,310 @@ public sealed class Phase7ApplierTests
|
||||
outcome.RemovedNodes.ShouldBe(2); // both removals counted (was 0 before the fix)
|
||||
}
|
||||
|
||||
// ----- F10b: surgical in-place tag-attribute writes (Writable / IsHistorized / HistorianTagname) -----
|
||||
|
||||
/// <summary>F10b — a deploy that ONLY flips an existing equipment tag's <c>Writable</c> bit (a plain,
|
||||
/// non-array, non-alarm value variable with stable identity) must SKIP the rebuild and apply the change
|
||||
/// IN PLACE via <c>ISurgicalAddressSpaceSink.UpdateTagAttributes</c>, preserving every client's
|
||||
/// subscriptions. Exactly one surgical call lands with the NEW Writable value; the edit still counts as
|
||||
/// a change (ChangedNodes == 1).</summary>
|
||||
[Fact]
|
||||
public void Changed_tag_writable_only_skips_rebuild_and_updates_in_place()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
||||
|
||||
var previous = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
|
||||
// Same TagId/identity; only Writable flips false → true.
|
||||
var next = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null));
|
||||
|
||||
var plan = Phase7Planner.Compute(previous, next);
|
||||
plan.ChangedEquipmentTags.Count.ShouldBe(1);
|
||||
plan.AddedEquipmentTags.ShouldBeEmpty();
|
||||
plan.RemovedEquipmentTags.ShouldBeEmpty();
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0); // NO RebuildAddressSpace — subscriptions preserved
|
||||
var call = sink.SurgicalCalls.ShouldHaveSingleItem();
|
||||
call.NodeId.ShouldBe(EquipmentNodeIds.Variable("eq-1", "", "Speed"));
|
||||
call.Writable.ShouldBeTrue(); // the NEW Writable value
|
||||
call.Historian.ShouldBeNull(); // not historized
|
||||
outcome.ChangedNodes.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>F10b — flipping <c>IsHistorized</c> false → true (no override) updates in place with the
|
||||
/// historian tagname defaulting to <c>FullName</c>; flipping true → false updates in place with a null
|
||||
/// historian tagname. Both skip the rebuild.</summary>
|
||||
[Fact]
|
||||
public void Changed_tag_is_historized_toggle_skips_rebuild_and_resolves_historian()
|
||||
{
|
||||
// false → true (no override) ⇒ historian defaults to FullName.
|
||||
var sinkOn = new RecordingSink();
|
||||
var applierOn = new Phase7Applier(sinkOn, NullLogger<Phase7Applier>.Instance);
|
||||
var planOn = Phase7Planner.Compute(
|
||||
CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
|
||||
FullName: "T.A", Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null)),
|
||||
CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
|
||||
FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: null)));
|
||||
planOn.ChangedEquipmentTags.Count.ShouldBe(1);
|
||||
|
||||
var outcomeOn = applierOn.Apply(planOn);
|
||||
|
||||
outcomeOn.RebuildCalled.ShouldBeFalse();
|
||||
sinkOn.RebuildCalls.ShouldBe(0);
|
||||
sinkOn.SurgicalCalls.ShouldHaveSingleItem().Historian.ShouldBe("T.A"); // default ⇒ FullName
|
||||
|
||||
// true → false ⇒ historian null.
|
||||
var sinkOff = new RecordingSink();
|
||||
var applierOff = new Phase7Applier(sinkOff, NullLogger<Phase7Applier>.Instance);
|
||||
var planOff = Phase7Planner.Compute(
|
||||
CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
|
||||
FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: null)),
|
||||
CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
|
||||
FullName: "T.A", Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null)));
|
||||
planOff.ChangedEquipmentTags.Count.ShouldBe(1);
|
||||
|
||||
var outcomeOff = applierOff.Apply(planOff);
|
||||
|
||||
outcomeOff.RebuildCalled.ShouldBeFalse();
|
||||
sinkOff.RebuildCalls.ShouldBe(0);
|
||||
sinkOff.SurgicalCalls.ShouldHaveSingleItem().Historian.ShouldBeNull(); // not historized ⇒ null
|
||||
}
|
||||
|
||||
/// <summary>F10b — changing ONLY the <c>HistorianTagname</c> override on an already-historized tag
|
||||
/// skips the rebuild and updates in place, passing the NEW override verbatim.</summary>
|
||||
[Fact]
|
||||
public void Changed_tag_historian_tagname_only_skips_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
||||
|
||||
var previous = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
|
||||
FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: "WW.Old"));
|
||||
var next = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float",
|
||||
FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: "WW.New"));
|
||||
|
||||
var plan = Phase7Planner.Compute(previous, next);
|
||||
plan.ChangedEquipmentTags.Count.ShouldBe(1);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeFalse();
|
||||
sink.RebuildCalls.ShouldBe(0);
|
||||
sink.SurgicalCalls.ShouldHaveSingleItem().Historian.ShouldBe("WW.New"); // override verbatim
|
||||
}
|
||||
|
||||
/// <summary>F10b safe-default — a tag delta whose <c>DataType</c> changed is NOT surgical-eligible (the
|
||||
/// node's value type would differ), so the applier must rebuild and make NO surgical call.</summary>
|
||||
[Fact]
|
||||
public void Changed_tag_data_type_change_rebuilds_and_no_surgical_call()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
||||
|
||||
var previous = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
|
||||
// DataType flips AND Writable flips — DataType is node-affecting, so this must rebuild.
|
||||
var next = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Int32", FullName: "40001", Writable: true, Alarm: null));
|
||||
|
||||
var plan = Phase7Planner.Compute(previous, next);
|
||||
plan.ChangedEquipmentTags.Count.ShouldBe(1);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
sink.SurgicalCalls.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>F10b safe-default — a tag delta whose <c>IsArray</c> flag changed is NOT surgical-eligible
|
||||
/// (array-ness drives ValueRank/ArrayDimensions on the node), so the applier rebuilds.</summary>
|
||||
[Fact]
|
||||
public void Changed_tag_is_array_change_rebuilds()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
||||
|
||||
var previous = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
|
||||
FullName: "40001", Writable: false, Alarm: null, IsArray: false, ArrayLength: null));
|
||||
var next = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16",
|
||||
FullName: "40001", Writable: false, Alarm: null, IsArray: true, ArrayLength: 16u));
|
||||
|
||||
var plan = Phase7Planner.Compute(previous, next);
|
||||
plan.ChangedEquipmentTags.Count.ShouldBe(1);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
sink.SurgicalCalls.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>F10b safe-default — a tag delta whose driver-side <c>FullName</c> changed is NOT
|
||||
/// surgical-eligible (it re-routes the node to a different driver point), so the applier rebuilds.</summary>
|
||||
[Fact]
|
||||
public void Changed_tag_full_name_change_rebuilds()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
||||
|
||||
var previous = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
|
||||
var next = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40002", Writable: false, Alarm: null));
|
||||
|
||||
var plan = Phase7Planner.Compute(previous, next);
|
||||
plan.ChangedEquipmentTags.Count.ShouldBe(1);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
sink.SurgicalCalls.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>F10b safe-default — a tag delta whose <c>Name</c> changed is NOT surgical-eligible (the
|
||||
/// folder-scoped NodeId + BrowseName derive from Name), so the applier rebuilds.</summary>
|
||||
[Fact]
|
||||
public void Changed_tag_name_change_rebuilds()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
||||
|
||||
var previous = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
|
||||
var next = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "SpeedRpm", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
|
||||
|
||||
var plan = Phase7Planner.Compute(previous, next);
|
||||
plan.ChangedEquipmentTags.Count.ShouldBe(1);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
sink.SurgicalCalls.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>F10b safe-default — a tag delta where an alarm appears (Alarm null → non-null) is NOT
|
||||
/// surgical-eligible: the tag flips from a plain value variable to a Part 9 condition node, which only
|
||||
/// a rebuild can materialise. No surgical call is made.</summary>
|
||||
[Fact]
|
||||
public void Changed_tag_alarm_presence_change_rebuilds()
|
||||
{
|
||||
var sink = new RecordingSink();
|
||||
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
||||
|
||||
var previous = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, Alarm: null));
|
||||
var next = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false,
|
||||
Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)));
|
||||
|
||||
var plan = Phase7Planner.Compute(previous, next);
|
||||
plan.ChangedEquipmentTags.Count.ShouldBe(1);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
sink.SurgicalCalls.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>F10b — a surgical-eligible tag delta MIXED with another change (here an added equipment)
|
||||
/// must still rebuild: the rebuild is forced by the OTHER change. The surgical path is taken ONLY when
|
||||
/// the tag deltas are the sole change. No surgical call is made (the rebuild materialises everything).</summary>
|
||||
[Fact]
|
||||
public void Surgical_eligible_tag_delta_mixed_with_added_equipment_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),
|
||||
},
|
||||
};
|
||||
// Surgical-eligible Writable flip on the tag AND a brand-new equipment node.
|
||||
var next = new Phase7CompositionResult(
|
||||
new[] { new EquipmentNode("eq-new", "New", "line-1") }, Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
{
|
||||
EquipmentTags = new[]
|
||||
{
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null),
|
||||
},
|
||||
};
|
||||
|
||||
var plan = Phase7Planner.Compute(previous, next);
|
||||
plan.ChangedEquipmentTags.Count.ShouldBe(1);
|
||||
plan.AddedEquipment.Count.ShouldBe(1);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
sink.SurgicalCalls.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>F10b fallback — a sink that does NOT implement <see cref="ISurgicalAddressSpaceSink"/> cannot
|
||||
/// apply the in-place update, so even a surgical-eligible (Writable-only) tag delta drives a full
|
||||
/// rebuild (safe default).</summary>
|
||||
[Fact]
|
||||
public void Surgical_eligible_delta_on_non_surgical_sink_rebuilds()
|
||||
{
|
||||
var sink = new PlainRecordingSink();
|
||||
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
||||
|
||||
var previous = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
|
||||
var next = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null));
|
||||
|
||||
var plan = Phase7Planner.Compute(previous, next);
|
||||
plan.ChangedEquipmentTags.Count.ShouldBe(1);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
sink.RebuildCalls.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>F10b fallback — when the surgical sink reports the node MISSING
|
||||
/// (<c>UpdateTagAttributes</c> returns false), the applier falls back to a full rebuild. The surgical
|
||||
/// call is still attempted (recorded once) before the fallback fires.</summary>
|
||||
[Fact]
|
||||
public void Surgical_sink_returning_false_node_missing_falls_back_to_rebuild()
|
||||
{
|
||||
var sink = new RecordingSink { SurgicalReturns = false };
|
||||
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
|
||||
|
||||
var previous = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null));
|
||||
var next = CompositionWithTags(
|
||||
new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null));
|
||||
|
||||
var plan = Phase7Planner.Compute(previous, next);
|
||||
plan.ChangedEquipmentTags.Count.ShouldBe(1);
|
||||
|
||||
var outcome = applier.Apply(plan);
|
||||
|
||||
outcome.RebuildCalled.ShouldBeTrue();
|
||||
sink.RebuildCalls.ShouldBe(1); // fell back to a full rebuild
|
||||
sink.SurgicalCalls.ShouldHaveSingleItem(); // the surgical update was attempted first
|
||||
}
|
||||
|
||||
private static Phase7CompositionResult CompositionWithTags(params EquipmentTagPlan[] tags) =>
|
||||
new(
|
||||
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
|
||||
@@ -1097,8 +1401,26 @@ public sealed class Phase7ApplierTests
|
||||
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
|
||||
ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>());
|
||||
|
||||
private sealed class RecordingSink : IOpcUaAddressSpaceSink
|
||||
private sealed class RecordingSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink
|
||||
{
|
||||
/// <summary>Gets the queue of surgical in-place tag-attribute update calls (F10b).</summary>
|
||||
public ConcurrentQueue<(string NodeId, bool Writable, string? Historian)> SurgicalQueue { get; } = new();
|
||||
/// <summary>Gets the list of recorded surgical in-place tag-attribute update calls.</summary>
|
||||
public List<(string NodeId, bool Writable, string? Historian)> SurgicalCalls => SurgicalQueue.ToList();
|
||||
/// <summary>When false, <see cref="UpdateTagAttributes"/> reports the node missing (returns false),
|
||||
/// driving the applier's rebuild fallback. Defaults to true (node present, update succeeds).</summary>
|
||||
public bool SurgicalReturns { get; init; } = true;
|
||||
|
||||
/// <summary>Records a surgical in-place tag-attribute update; returns <see cref="SurgicalReturns"/>.</summary>
|
||||
/// <param name="variableNodeId">The variable node ID to update in place.</param>
|
||||
/// <param name="writable">The new Writable (AccessLevel) for the node.</param>
|
||||
/// <param name="historianTagname">The resolved historian tagname (null ⇒ not historized).</param>
|
||||
public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname)
|
||||
{
|
||||
SurgicalQueue.Enqueue((variableNodeId, writable, historianTagname));
|
||||
return SurgicalReturns;
|
||||
}
|
||||
|
||||
/// <summary>Gets the queue of alarm condition write calls.</summary>
|
||||
public ConcurrentQueue<(string NodeId, AlarmConditionSnapshot State)> AlarmQueue { get; } = new();
|
||||
/// <summary>Gets the queue of folder creation calls.</summary>
|
||||
@@ -1172,6 +1494,28 @@ public sealed class Phase7ApplierTests
|
||||
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
||||
}
|
||||
|
||||
/// <summary>A recording sink that does NOT implement <see cref="ISurgicalAddressSpaceSink"/> — used to
|
||||
/// prove the F10b fallback: when the bound sink lacks the surgical capability, a surgical-eligible tag
|
||||
/// delta still drives a full <c>RebuildAddressSpace</c>.</summary>
|
||||
private sealed class PlainRecordingSink : IOpcUaAddressSpaceSink
|
||||
{
|
||||
/// <summary>Gets the number of rebuild calls made on this sink.</summary>
|
||||
public int RebuildCalls;
|
||||
|
||||
/// <summary>Records a value write (no-op in this sink).</summary>
|
||||
public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc) { }
|
||||
/// <summary>No-op alarm condition write call.</summary>
|
||||
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc) { }
|
||||
/// <summary>No-op alarm-condition materialise call.</summary>
|
||||
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, bool isNative = false) { }
|
||||
/// <summary>No-op folder creation call.</summary>
|
||||
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName) { }
|
||||
/// <summary>No-op variable creation call.</summary>
|
||||
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
|
||||
/// <summary>Records a rebuild address space call.</summary>
|
||||
public void RebuildAddressSpace() => Interlocked.Increment(ref RebuildCalls);
|
||||
}
|
||||
|
||||
private sealed class ThrowingSink : IOpcUaAddressSpaceSink
|
||||
{
|
||||
private readonly bool _throwOnAlarmWrite;
|
||||
|
||||
Reference in New Issue
Block a user