feat(opcua): surgical mixed add+remove deploys (R2-07 T13)

This commit is contained in:
Joseph Doherty
2026-07-13 12:22:45 -04:00
parent f4ea834fac
commit e2b47638ec
3 changed files with 79 additions and 13 deletions
@@ -132,8 +132,11 @@ public sealed class AddressSpaceApplier
// subtree, via the ISurgicalAddressSpaceSink remove members below (each preceded by a terminal
// Bad / RemovedConditionState write so in-flight MonitoredItems observe the removal); subscribers
// of OTHER nodes are untouched. Any false/throw from a remove ⇒ full-rebuild fallback (ratchet).
// • AddRemoveMix ⇒ full rebuild UNTIL Phase 3 ships (R2-07 T13); mapping it here keeps each phase
// independently shippable with today's behaviour as the floor.
// • AddRemoveMix ⇒ NO full rebuild. Removes-then-adds within one apply: the removed nodes are torn
// down IN PLACE here (the PureRemove pass), then OpcUaPublishActor's idempotent Materialise passes
// create the added nodes and AnnounceAddedNodes announces them — natural remove-then-recreate
// order even when an id is reused across the remove + add sets (the recreated node is a fresh
// NodeState, correct — it is a different tag). Any remove false/throw ⇒ full-rebuild fallback.
// • Rebuild ⇒ the default-closed safety valve for any node-affecting change (ChangedEquipment /
// ChangedAlarms / non-surgical ChangedEquipmentTags / node-relevant ChangedEquipmentVirtualTags)
// — the classifier's rule 2, which also catches any future plan field that makes a changed
@@ -141,8 +144,10 @@ public sealed class AddressSpaceApplier
// ChangedDrivers is node-inert (routes through DriverHostActor's spawn plan) — the classifier leaves
// a driver-only plan AttributeOnly, so it never rebuilds here.
var kind = AddressSpaceChangeClassifier.Classify(plan);
var mustRebuild = kind is AddressSpaceChangeKind.Rebuild
or AddressSpaceChangeKind.AddRemoveMix;
var mustRebuild = kind is AddressSpaceChangeKind.Rebuild;
// PureRemove + AddRemoveMix both run the in-place remove pass below (AddRemoveMix's adds are handled
// afterward by the publish actor's Materialise passes + announce).
var hasRemovePass = kind is AddressSpaceChangeKind.PureRemove or AddressSpaceChangeKind.AddRemoveMix;
var surgicalTagDeltas = plan.ChangedEquipmentTags.Where(AddressSpaceChangeClassifier.TagDeltaIsSurgicalEligible).ToList();
// UNS Area / Line renames are surgically applicable (in-place DisplayName swap)
@@ -158,7 +163,7 @@ public sealed class AddressSpaceApplier
rebuildFailed = !SafeRebuild();
rebuilt = true;
}
else if (surgicalTagDeltas.Count > 0 || renamedFolders.Count > 0 || kind == AddressSpaceChangeKind.PureRemove)
else if (surgicalTagDeltas.Count > 0 || renamedFolders.Count > 0 || hasRemovePass)
{
if (_sink is ISurgicalAddressSpaceSink surgical)
{
@@ -194,10 +199,12 @@ public sealed class AddressSpaceApplier
}
if (!ok) { allApplied = false; break; }
}
// R2-07 Phase 2 — PureRemove scoped teardown (runs after any coincident surgical
// attribute updates / renames succeeded). Terminal Bad / RemovedConditionState writes then
// in-place removes; any false/throw flips allApplied → the rebuild ratchet below.
if (allApplied && kind == AddressSpaceChangeKind.PureRemove)
// R2-07 Phase 2/3scoped 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
// rebuild ratchet below. For AddRemoveMix the adds are materialised afterward by the publish
// actor's passes (remove-then-recreate order).
if (allApplied && hasRemovePass)
{
allApplied = ApplyPureRemove(surgical, plan, ts, ref failedNodes);
}