feat(opcua): surgical pure-remove deploys — scoped teardown, no full rebuild (R2-07 T11)
This commit is contained in:
@@ -128,8 +128,12 @@ public sealed class AddressSpaceApplier
|
||||
// every client subscription server-wide survives; coincident surgical tag deltas + folder
|
||||
// renames are applied IN PLACE via ISurgicalAddressSpaceSink below (any false/throw there falls
|
||||
// back to a full rebuild — the F10b contract).
|
||||
// • PureRemove / AddRemoveMix ⇒ full rebuild UNTIL their phases ship (R2-07 T11 / T13); mapping
|
||||
// them here keeps each phase independently shippable with today's behaviour as the floor.
|
||||
// • PureRemove ⇒ NO full rebuild. The removed nodes are torn down IN PLACE, scoped to the affected
|
||||
// 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.
|
||||
// • 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
|
||||
@@ -138,7 +142,6 @@ public sealed class AddressSpaceApplier
|
||||
// a driver-only plan AttributeOnly, so it never rebuilds here.
|
||||
var kind = AddressSpaceChangeClassifier.Classify(plan);
|
||||
var mustRebuild = kind is AddressSpaceChangeKind.Rebuild
|
||||
or AddressSpaceChangeKind.PureRemove
|
||||
or AddressSpaceChangeKind.AddRemoveMix;
|
||||
|
||||
var surgicalTagDeltas = plan.ChangedEquipmentTags.Where(AddressSpaceChangeClassifier.TagDeltaIsSurgicalEligible).ToList();
|
||||
@@ -155,7 +158,7 @@ public sealed class AddressSpaceApplier
|
||||
rebuildFailed = !SafeRebuild();
|
||||
rebuilt = true;
|
||||
}
|
||||
else if (surgicalTagDeltas.Count > 0 || renamedFolders.Count > 0)
|
||||
else if (surgicalTagDeltas.Count > 0 || renamedFolders.Count > 0 || kind == AddressSpaceChangeKind.PureRemove)
|
||||
{
|
||||
if (_sink is ISurgicalAddressSpaceSink surgical)
|
||||
{
|
||||
@@ -191,6 +194,13 @@ 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)
|
||||
{
|
||||
allApplied = ApplyPureRemove(surgical, plan, ts, ref failedNodes);
|
||||
}
|
||||
if (!allApplied) { rebuildFailed = !SafeRebuild(); rebuilt = true; }
|
||||
}
|
||||
else
|
||||
@@ -218,6 +228,108 @@ public sealed class AddressSpaceApplier
|
||||
return new AddressSpaceApplyOutcome(removedCount, addedCount, changedCount, rebuilt, rebuildFailed, failedNodes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R2-07 Phase 2 — apply a PureRemove plan by tearing down ONLY the affected nodes IN PLACE (no full
|
||||
/// rebuild). Removed equipment "own" their child tags/vtags/alarms, so any removed child whose
|
||||
/// <c>EquipmentId</c> is itself in <see cref="AddressSpacePlan.RemovedEquipment"/> is SUBSUMED by the
|
||||
/// subtree removal and skipped individually. For surviving-equipment removals: a removed value
|
||||
/// variable gets a terminal Bad <c>WriteValue</c> then <see cref="ISurgicalAddressSpaceSink.RemoveVariableNode"/>;
|
||||
/// a removed alarm-bearing tag (the pre-R2-07 today-gap: it got no terminal condition write) and a
|
||||
/// removed scripted alarm get a terminal <see cref="RemovedConditionState"/> write then
|
||||
/// <see cref="ISurgicalAddressSpaceSink.RemoveAlarmConditionNode"/>; each removed equipment gets one
|
||||
/// <see cref="ISurgicalAddressSpaceSink.RemoveEquipmentSubtree"/>. The terminal write is what an
|
||||
/// in-flight MonitoredItem observes (a final Bad); after the node is gone, re-subscription gets
|
||||
/// <c>BadNodeIdUnknown</c>. Returns false on the FIRST remove that reports the node unknown / throws
|
||||
/// (the caller's one-way rebuild ratchet takes over), leaving no further surgical work attempted.
|
||||
/// </summary>
|
||||
/// <param name="surgical">The surgical sink to route removes through.</param>
|
||||
/// <param name="plan">The PureRemove plan.</param>
|
||||
/// <param name="ts">The timestamp for the terminal writes.</param>
|
||||
/// <param name="failedNodes">Accumulator for swallowed terminal-write failures (archreview 01/S-1).</param>
|
||||
/// <returns>True when every removal succeeded; false on the first failure (caller rebuilds).</returns>
|
||||
private bool ApplyPureRemove(ISurgicalAddressSpaceSink surgical, AddressSpacePlan plan, DateTime ts, ref int failedNodes)
|
||||
{
|
||||
var removedEquipmentIds = new HashSet<string>(
|
||||
plan.RemovedEquipment.Select(e => e.EquipmentId), StringComparer.Ordinal);
|
||||
bool NotSubsumed(string equipmentId) => !removedEquipmentIds.Contains(equipmentId);
|
||||
|
||||
// Removed equipment tags — value variables OR alarm-bearing conditions — for SURVIVING equipment.
|
||||
foreach (var tag in plan.RemovedEquipmentTags.Where(t => NotSubsumed(t.EquipmentId)))
|
||||
{
|
||||
var nodeId = EquipmentNodeIds.Variable(tag.EquipmentId, tag.FolderPath, tag.Name);
|
||||
if (tag.Alarm is not null)
|
||||
{
|
||||
// Alarm-bearing tag → a Part 9 condition node. Terminal RemovedConditionState (today-gap
|
||||
// closed), then remove the condition in place.
|
||||
if (!SafeWriteAlarmCondition(nodeId, RemovedConditionState, ts)) failedNodes++;
|
||||
if (!SafeRemoveAlarmCondition(surgical, nodeId)) return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Plain value variable → terminal Bad so an in-flight MonitoredItem sees the removal.
|
||||
SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts);
|
||||
if (!SafeRemoveVariable(surgical, nodeId)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Removed VirtualTags (always plain value variables) for surviving equipment.
|
||||
foreach (var v in plan.RemovedEquipmentVirtualTags.Where(t => NotSubsumed(t.EquipmentId)))
|
||||
{
|
||||
var nodeId = EquipmentNodeIds.Variable(v.EquipmentId, v.FolderPath, v.Name);
|
||||
SafeWriteValue(nodeId, null, OpcUaQuality.Bad, ts);
|
||||
if (!SafeRemoveVariable(surgical, nodeId)) return false;
|
||||
}
|
||||
|
||||
// Removed scripted alarms for surviving equipment — the terminal RemovedConditionState was already
|
||||
// written by the top-of-Apply removal block; here we tear the condition node down in place.
|
||||
foreach (var alarm in plan.RemovedAlarms.Where(a => NotSubsumed(a.EquipmentId)))
|
||||
{
|
||||
if (!SafeRemoveAlarmCondition(surgical, alarm.ScriptedAlarmId)) return false;
|
||||
}
|
||||
|
||||
// Removed equipment — one subtree teardown each (subsumes their child tags/vtags/alarms). The
|
||||
// top-of-Apply block already wrote the equipment id's terminal condition state.
|
||||
foreach (var eq in plan.RemovedEquipment)
|
||||
{
|
||||
if (!SafeRemoveEquipmentSubtree(surgical, eq.EquipmentId)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Remove a value-variable node in place, treating a throw as a false (⇒ rebuild fallback).</summary>
|
||||
/// <returns><c>true</c> when the node was removed; <c>false</c> when unknown or the sink threw.</returns>
|
||||
private bool SafeRemoveVariable(ISurgicalAddressSpaceSink surgical, string nodeId)
|
||||
{
|
||||
try { return surgical.RemoveVariableNode(nodeId); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveVariableNode threw for {Node}", nodeId); return false; }
|
||||
}
|
||||
|
||||
/// <summary>Remove an alarm-condition node in place, treating a throw as a false (⇒ rebuild fallback).</summary>
|
||||
/// <returns><c>true</c> when the node was removed; <c>false</c> when unknown or the sink threw.</returns>
|
||||
private bool SafeRemoveAlarmCondition(ISurgicalAddressSpaceSink surgical, string nodeId)
|
||||
{
|
||||
try { return surgical.RemoveAlarmConditionNode(nodeId); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveAlarmConditionNode threw for {Node}", nodeId); return false; }
|
||||
}
|
||||
|
||||
/// <summary>Remove an equipment subtree in place, treating a throw as a false (⇒ rebuild fallback).</summary>
|
||||
/// <returns><c>true</c> when the subtree was removed; <c>false</c> when unknown or the sink threw.</returns>
|
||||
private bool SafeRemoveEquipmentSubtree(ISurgicalAddressSpaceSink surgical, string nodeId)
|
||||
{
|
||||
try { return surgical.RemoveEquipmentSubtree(nodeId); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "AddressSpaceApplier: surgical RemoveEquipmentSubtree threw for {Node}", nodeId); return false; }
|
||||
}
|
||||
|
||||
/// <summary>Write a value, swallowing (and Warning-logging) any sink fault — used for the terminal Bad
|
||||
/// on a removed variable so an in-flight MonitoredItem observes the removal.</summary>
|
||||
/// <returns><c>true</c> when the write landed; <c>false</c> when the sink threw.</returns>
|
||||
private bool SafeWriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime ts)
|
||||
{
|
||||
try { _sink.WriteValue(nodeId, value, quality, ts); return true; }
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: WriteValue threw for {Node}", nodeId); return false; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute the deduplicated, deterministically-ordered set of affected PARENT node ids to announce
|
||||
/// (Part 3 <c>NodeAdded</c>) after a pure-add apply's Materialise passes have created the new nodes.
|
||||
|
||||
Reference in New Issue
Block a user