From 4504ed930c33d0a3062e351cae2260b92fb6962e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 12:12:09 -0400 Subject: [PATCH] feat(opcua): surgical RemoveEquipmentSubtree with notifier demotion (R2-07 T10) --- ...2-07-surgical-pure-adds-plan.md.tasks.json | 2 +- .../OtOpcUaNodeManager.cs | 68 ++++++++++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json b/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json index df9b97d7..6e179a63 100644 --- a/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json +++ b/archreview/plans/R2-07-surgical-pure-adds-plan.md.tasks.json @@ -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" diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index cbcb0de2..27919037 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -1847,7 +1847,73 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } /// - 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 + // "/" (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) {