feat(opcuaserver): applier applies raw-tag + UNS-reference deltas surgically
Task 4 (9f68f272) taught AddressSpaceChangeClassifier that a non-alarm
RawTagDelta and any UnsReferenceDelta are surgical-eligible, so such plans
started classifying AttributeOnly/PureAdd instead of Rebuild. The applier's
surgical branch did not know about either delta set, so those deploys fell
through as SILENT NO-OPS (or, on a non-surgical sink, applied nothing at all
where a rebuild was the safe answer). This closes that gap:
- Raw-tag loop: an eligible ChangedRawTags delta is applied IN PLACE via
ISurgicalAddressSpaceSink.UpdateTagAttributes in the RAW realm, computing
writable (`Writable && !IsArray`) and the historian tagname (blank override
⇒ the RawPath) exactly as MaterialiseRawSubtree does. A delta whose ONLY
difference is ReferencingEquipmentPaths is node-inert for a value tag and
is SKIPPED entirely — that is the shape a single UnsTagReference add/drop
produces, and it used to rebuild the world.
- UNS-reference loop: a ChangedUnsReferenceVariables delta removes the
PREVIOUS node in place (terminal Bad, then RemoveVariableNode, Uns realm).
Remove+recreate rather than an in-place update because a re-point must move
the Organizes edge (the sink has no RemoveReference) and a display-name
edit changes the NodeId itself; the cost is that one node's monitored
items, versus every subscription server-wide today.
- The branch trigger now fires for both sets, so a non-surgical sink falls
back to a full rebuild instead of applying nothing.
Every new operation is inside the existing one-way rebuild ratchet: any false
or throw ⇒ SafeRebuild(). The eligibility filter is defense-in-depth — when
kind != Rebuild the classifier already guarantees every delta is eligible.
The recreate of a changed UNS reference is NOT done here. Until Task 6 the
publish actor still materialises the FULL composition after Apply returns, so
the existing MaterialiseUnsReferences pass recreates it and the system is
correct end to end after this commit; the remove-then-recreate ordering holds
because Apply returns before the materialise passes run. Task 6 narrows those
passes to the plan's delta and keeps the same ordering.
Tests: 7 new applier tests (in-place raw update incl. array/override parity,
reference-only skip, UNS-ref remove, both ratchet fallbacks, non-surgical-sink
rebuild). RecordingSink now captures the realm on surgical / remove / value-write
calls, so the existing UNS-realm assertions are explicit too.
This commit is contained in:
@@ -17,8 +17,10 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
/// create exactly the added folders/variables/conditions (no-op'ing existing nodes), then
|
||||
/// <c>AnnounceAddedNodes</c> raises a Part 3 <c>NodeAdded</c> per affected parent so
|
||||
/// model-aware clients re-browse. Existing MonitoredItems are untouched.</item>
|
||||
/// <item><b>AttributeOnly</b> — no rebuild. Surgical-eligible changed tags + UNS folder renames
|
||||
/// are applied IN PLACE via <see cref="ISurgicalAddressSpaceSink"/> (F10b). Any <c>false</c>
|
||||
/// <item><b>AttributeOnly</b> — no rebuild. Surgical-eligible changed equipment tags, changed RAW
|
||||
/// tags (in the Raw realm) and UNS folder renames are applied IN PLACE via
|
||||
/// <see cref="ISurgicalAddressSpaceSink"/> (F10b); a changed UNS <i>reference</i> variable is a
|
||||
/// remove-previous here + recreate-current in the materialise pass. Any <c>false</c>
|
||||
/// or throw from a surgical call falls back to a full rebuild (safe default).</item>
|
||||
/// <item><b>PureRemove</b> — scoped in-place teardown of only the removed subtree (writes a
|
||||
/// terminal Bad / <c>RemovedConditionState</c> first so in-flight MonitoredItems observe the
|
||||
@@ -160,6 +162,12 @@ public sealed class AddressSpaceApplier
|
||||
// MaterialiseHierarchy re-creates every folder with the new display names, so the renames are
|
||||
// covered for free and need no separate surgical pass.
|
||||
var renamedFolders = plan.RenamedFolders;
|
||||
// v3 surgical raw-tag + UNS-reference deltas (2026-08-15 surgical-deploys, Task 5). When kind is not
|
||||
// Rebuild the classifier has ALREADY guaranteed every ChangedRawTags entry is surgical-eligible; the
|
||||
// filter is defense-in-depth so a future classifier change can only ever under-apply here (the
|
||||
// non-eligible remainder is left to the rebuild the classifier would have demanded).
|
||||
var surgicalRawTagDeltas = plan.ChangedRawTags.Where(AddressSpaceChangeClassifier.RawTagDeltaIsSurgicalEligible).ToList();
|
||||
var unsRefDeltas = plan.ChangedUnsReferenceVariables;
|
||||
var rebuilt = false;
|
||||
var rebuildFailed = false;
|
||||
|
||||
@@ -168,7 +176,8 @@ public sealed class AddressSpaceApplier
|
||||
rebuildFailed = !SafeRebuild();
|
||||
rebuilt = true;
|
||||
}
|
||||
else if (surgicalTagDeltas.Count > 0 || renamedFolders.Count > 0 || hasRemovePass)
|
||||
else if (surgicalTagDeltas.Count > 0 || renamedFolders.Count > 0 || hasRemovePass ||
|
||||
surgicalRawTagDeltas.Count > 0 || unsRefDeltas.Count > 0)
|
||||
{
|
||||
if (_sink is ISurgicalAddressSpaceSink surgical)
|
||||
{
|
||||
@@ -204,6 +213,58 @@ public sealed class AddressSpaceApplier
|
||||
}
|
||||
if (!ok) { allApplied = false; break; }
|
||||
}
|
||||
// v3 RAW tags — the same in-place attribute update, in the RAW realm. Adding or dropping ONE
|
||||
// UnsTagReference flips its backing raw tag to Changed purely through ReferencingEquipmentPaths,
|
||||
// and before this path existed that tore down every node on the server for an edit that creates
|
||||
// a single UNS variable.
|
||||
foreach (var d in allApplied ? surgicalRawTagDeltas : Enumerable.Empty<AddressSpacePlan.RawTagDelta>())
|
||||
{
|
||||
// ReferencingEquipmentPaths is node-INERT for a plain value tag (it only drives native-alarm
|
||||
// notifier wiring, and an alarm-bearing delta never reaches here — the classifier routes it
|
||||
// to Rebuild). So a delta whose ONLY difference is the referencing set needs NOTHING said to
|
||||
// the sink: compare the two sides with the referencing set copied across, and skip on equal.
|
||||
if ((d.Previous with { ReferencingEquipmentPaths = d.Current.ReferencingEquipmentPaths }).Equals(d.Current))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute writable + historian EXACTLY as MaterialiseRawSubtree does so the in-place update
|
||||
// matches what a rebuild would have produced: arrays are forced read-only (the driver write
|
||||
// path can't handle them) and a blank historian override falls back to the RawPath (== NodeId).
|
||||
var writable = d.Current.Writable && !d.Current.IsArray;
|
||||
var historian = d.Current.IsHistorized
|
||||
? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.NodeId : d.Current.HistorianTagname)
|
||||
: null;
|
||||
bool ok;
|
||||
try { ok = surgical.UpdateTagAttributes(d.Current.NodeId, writable, historian, d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength, AddressSpaceRealm.Raw); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "AddressSpaceApplier: surgical UpdateTagAttributes threw for raw {Node}", d.Current.NodeId);
|
||||
ok = false;
|
||||
}
|
||||
if (!ok) { allApplied = false; break; }
|
||||
}
|
||||
// v3 UNS reference variables — REMOVE the previous node (terminal Bad first, so an in-flight
|
||||
// MonitoredItem observes the removal), and let the CURRENT node be (re)created by the
|
||||
// materialise pass that runs after Apply returns.
|
||||
//
|
||||
// WHY remove+recreate rather than an in-place update: a UnsReferenceDelta is either a re-point
|
||||
// (BackingRawPath moved — the node's Organizes edge must move with it, and the surgical sink
|
||||
// surface has no RemoveReference) or a display-name-override edit (the effective name is part of
|
||||
// the UNS NodeId, so the identity itself changes — an OPC UA NodeId is immutable). Remove+recreate
|
||||
// covers both shapes with one mechanism. The cost is that ONE node's monitored items, against
|
||||
// today's cost of a full rebuild killing every subscription server-wide.
|
||||
//
|
||||
// WHERE the recreate happens: the publish actor's materialise passes, which run after Apply
|
||||
// returns — full-composition MaterialiseUnsReferences today, narrowed to the plan's added +
|
||||
// changed-current references by Task 6 of the surgical-deploys plan. Either way the ordering
|
||||
// holds (remove here, recreate strictly after), and the recreated node inherits its shape /
|
||||
// historian tagname from the FULL composition's backing raw tag.
|
||||
foreach (var d in allApplied ? unsRefDeltas : Enumerable.Empty<AddressSpacePlan.UnsReferenceDelta>())
|
||||
{
|
||||
if (!SafeWriteValue(d.Previous.NodeId, null, OpcUaStatus.Bad, ts, AddressSpaceRealm.Uns)) failedNodes++;
|
||||
if (!SafeRemoveVariable(surgical, d.Previous.NodeId, AddressSpaceRealm.Uns)) { allApplied = false; break; }
|
||||
}
|
||||
// R2-07 Phase 2/3 — scoped remove teardown for PureRemove AND AddRemoveMix (runs after any
|
||||
// coincident surgical attribute updates / renames succeeded). Terminal Bad /
|
||||
// RemovedConditionState writes then in-place removes; any false/throw flips allApplied → the
|
||||
@@ -224,8 +285,10 @@ public sealed class AddressSpaceApplier
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"AddressSpaceApplier: applied plan (kind={Kind}, added={Added}, removed={Removed}, changed={Changed}, surgicalTags={Surgical}, renamedFolders={Renamed}, rebuild={Rebuild})",
|
||||
kind, addedCount, removedCount, changedCount, rebuilt ? 0 : surgicalTagDeltas.Count, rebuilt ? 0 : renamedFolders.Count, rebuilt);
|
||||
"AddressSpaceApplier: applied plan (kind={Kind}, added={Added}, removed={Removed}, changed={Changed}, surgicalTags={Surgical}, surgicalRawTags={SurgicalRaw}, unsRefDeltas={UnsRefs}, renamedFolders={Renamed}, rebuild={Rebuild})",
|
||||
kind, addedCount, removedCount, changedCount, rebuilt ? 0 : surgicalTagDeltas.Count,
|
||||
rebuilt ? 0 : surgicalRawTagDeltas.Count, rebuilt ? 0 : unsRefDeltas.Count,
|
||||
rebuilt ? 0 : renamedFolders.Count, rebuilt);
|
||||
|
||||
// After the address-space work has completed, auto-provision the historian for the added
|
||||
// historized tags. This is fully detached (fire-and-forget) and wrapped so it can NEVER block
|
||||
|
||||
Reference in New Issue
Block a user