feat(opcua): surgical RemoveEquipmentSubtree with notifier demotion (R2-07 T10)

This commit is contained in:
Joseph Doherty
2026-07-13 12:12:09 -04:00
parent 9653493905
commit 4504ed930c
2 changed files with 68 additions and 2 deletions
@@ -87,7 +87,7 @@
{
"id": "T10",
"subject": "Phase 2: OtOpcUaNodeManager.RemoveEquipmentSubtree (descendants + notifier demotion) + SdkAddressSpaceSink forwards (high-risk)",
"status": "pending",
"status": "completed",
"blockedBy": [
"T8",
"T9"
@@ -1847,7 +1847,73 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
/// <inheritdoc cref="ISurgicalAddressSpaceSink.RemoveEquipmentSubtree"/>
public bool RemoveEquipmentSubtree(string equipmentNodeId) => false;
public bool RemoveEquipmentSubtree(string equipmentNodeId)
{
ArgumentException.ThrowIfNullOrEmpty(equipmentNodeId);
EnsureAddressSpaceCreated();
GeneralModelChangeEventState e;
lock (Lock)
{
// Unknown equipment id ⇒ map drift ⇒ false (caller rebuilds).
if (!_folders.ContainsKey(equipmentNodeId)) return false;
// Folder-scoped NodeIds mean every descendant's id is the equipment id itself or begins with
// "<equipmentId>/" (sub-folders, variables, condition nodes). Snapshot each map's in-scope keys
// first (ToList) so we don't mutate while enumerating.
var prefix = equipmentNodeId + "/";
bool InScope(string id) => id == equipmentNodeId || id.StartsWith(prefix, StringComparison.Ordinal);
// Value variables (+ their historized-tagname registrations).
foreach (var id in _variables.Keys.Where(InScope).ToList())
{
if (_variables.TryRemove(id, out var v))
{
v.Parent?.RemoveChild(v);
PredefinedNodes?.Remove(v.NodeId);
}
_historizedTagnames.TryRemove(id, out _);
}
// Part 9 condition nodes (+ their native flags).
foreach (var id in _alarmConditions.Keys.Where(InScope).ToList())
{
if (_alarmConditions.TryRemove(id, out var c))
{
c.Parent?.RemoveChild(c);
PredefinedNodes?.Remove(c.NodeId);
}
_nativeAlarmNodeIds.Remove(id);
}
// Notifier demotion BEFORE dropping the folders: sever the Server↔folder HasNotifier ref for
// every promoted folder in the subtree (an equipment folder, or a sub-folder that hosted an
// alarm), else the removal leaks an orphaned root-notifier reference on the Server object.
// _notifierFolders is keyed by the folder's NodeId; match on its string identifier.
foreach (var kvp in _notifierFolders.Where(kv => InScope(kv.Key.Identifier?.ToString() ?? string.Empty)).ToList())
{
RemoveRootNotifier(kvp.Value);
_notifierFolders.Remove(kvp.Key);
}
// Drop the event-history source registrations for the subtree (keyed by the folder id string).
foreach (var src in _eventNotifierSources.Keys.Where(InScope).ToList())
_eventNotifierSources.TryRemove(src, out _);
// Sub-folders + the equipment folder itself (children already detached above).
foreach (var id in _folders.Keys.Where(InScope).ToList())
{
if (_folders.TryRemove(id, out var f))
{
f.Parent?.RemoveChild(f);
PredefinedNodes?.Remove(f.NodeId);
}
}
e = BuildNodesRemovedModelChange(equipmentNodeId);
}
ReportModelChangeOutsideLock(e, equipmentNodeId);
return true;
}
private FolderState ResolveParentFolder(string? parentNodeId)
{