feat(commons): add surgical remove members to ISurgicalAddressSpaceSink + deferred forwarding (guard-verified) (R2-07 T7)

This commit is contained in:
Joseph Doherty
2026-07-13 12:07:01 -04:00
parent 4f1853b21f
commit 438004e371
9 changed files with 210 additions and 1 deletions
@@ -68,4 +68,21 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
=> _inner is ISurgicalAddressSpaceSink surgical
&& surgical.UpdateFolderDisplayName(folderNodeId, displayName);
// R2-07 T7 — surgical remove forwards. Same capability-sniffing pattern as UpdateTagAttributes /
// UpdateFolderDisplayName: forward when the inner sink supports the surgical capability; return false
// otherwise (before the real SdkAddressSpaceSink is swapped in, or any non-surgical inner) so the caller
// (AddressSpaceApplier) falls back to a full rebuild. Without these forwards the surgical remove path is
// inert on every driver-role host — the F10b / PR#423 trap the reflection guard exists to catch.
/// <inheritdoc />
public bool RemoveVariableNode(string variableNodeId)
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveVariableNode(variableNodeId);
/// <inheritdoc />
public bool RemoveAlarmConditionNode(string alarmNodeId)
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveAlarmConditionNode(alarmNodeId);
/// <inheritdoc />
public bool RemoveEquipmentSubtree(string equipmentNodeId)
=> _inner is ISurgicalAddressSpaceSink surgical && surgical.RemoveEquipmentSubtree(equipmentNodeId);
}
@@ -34,4 +34,34 @@ public interface ISurgicalAddressSpaceSink
/// <param name="displayName">The new display name to apply.</param>
/// <returns>True when the in-place update was applied; false when the folder is missing (caller rebuilds).</returns>
bool UpdateFolderDisplayName(string folderNodeId, string displayName);
/// <summary>Remove ONE existing value-variable node IN PLACE (detach from its parent, drop it from the
/// predefined-node set, drop any historized-tagname registration), then raise a Part 3
/// GeneralModelChangeEvent (verb=NodeDeleted) outside the lock — so subscribers of OTHER nodes are
/// untouched and their MonitoredItems survive. The caller has already written a final Bad quality to the
/// node so in-flight MonitoredItems on the removed node observe the removal; after the node is gone,
/// re-subscription attempts get BadNodeIdUnknown from the SDK. Returns false when the node id is unknown
/// (the node-manager maps drifted from the plan — caller falls back to a full rebuild).</summary>
/// <param name="variableNodeId">The folder-scoped node id of the variable to remove in place.</param>
/// <returns>True when the node was removed; false when the id is unknown (caller rebuilds).</returns>
bool RemoveVariableNode(string variableNodeId);
/// <summary>Remove ONE existing Part 9 alarm-condition node IN PLACE (detach, drop from the
/// predefined-node set, clear the native-alarm flag), then raise NodeDeleted outside the lock. The
/// caller has already written the terminal RemovedConditionState (Retain=false) so a removed condition
/// stops replaying on ConditionRefresh. Returns false when the condition id is unknown (caller
/// rebuilds).</summary>
/// <param name="alarmNodeId">The alarm condition node id (== ScriptedAlarmId, or a native alarm tag's folder-scoped id).</param>
/// <returns>True when the condition was removed; false when the id is unknown (caller rebuilds).</returns>
bool RemoveAlarmConditionNode(string alarmNodeId);
/// <summary>Remove an equipment folder AND its entire descendant subtree (sub-folders, value variables,
/// condition nodes) IN PLACE: per-descendant map/registration cleanup (variables, historized tagnames,
/// conditions, native flags), notifier demotion (sever the Server↔folder root-notifier ref +
/// notifier-folder + event-notifier-source registrations for the equipment id), then one NodeDeleted for
/// the equipment folder outside the lock. Returns false when the folder id is unknown (caller
/// rebuilds).</summary>
/// <param name="equipmentNodeId">The equipment folder node id whose entire subtree to remove.</param>
/// <returns>True when the subtree was removed; false when the id is unknown (caller rebuilds).</returns>
bool RemoveEquipmentSubtree(string equipmentNodeId);
}
@@ -1746,6 +1746,19 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
}
// R2-07 Phase 2 (T7 placeholder) — surgical single-node / subtree removal. These return false (the
// rebuild-fallback contract) so the tree stays shippable at T7 while T8/T9/T10 implement the real
// in-place teardown (mirroring the per-node loops inside RebuildAddressSpace, scoped to the id/subtree,
// with a NodeDeleted model-change reported outside Lock). Implemented in T8/T9/T10.
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveVariableNode"/>
public bool RemoveVariableNode(string variableNodeId) => false;
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveAlarmConditionNode"/>
public bool RemoveAlarmConditionNode(string alarmNodeId) => false;
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveEquipmentSubtree"/>
public bool RemoveEquipmentSubtree(string equipmentNodeId) => false;
private FolderState ResolveParentFolder(string? parentNodeId)
{
EnsureAddressSpaceCreated();
@@ -48,6 +48,18 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
public bool UpdateFolderDisplayName(string folderNodeId, string displayName)
=> _nodeManager.UpdateFolderDisplayName(folderNodeId, displayName);
/// <inheritdoc />
public bool RemoveVariableNode(string variableNodeId)
=> _nodeManager.RemoveVariableNode(variableNodeId);
/// <inheritdoc />
public bool RemoveAlarmConditionNode(string alarmNodeId)
=> _nodeManager.RemoveAlarmConditionNode(alarmNodeId);
/// <inheritdoc />
public bool RemoveEquipmentSubtree(string equipmentNodeId)
=> _nodeManager.RemoveEquipmentSubtree(equipmentNodeId);
/// <inheritdoc />
public void RebuildAddressSpace() => _nodeManager.RebuildAddressSpace();