perf(opcua): de-quadratic the lock-time teardown paths (B3)

Review 01 P1/P2/P3/P5 — work done under the manager-wide SDK Lock on the
pinned publish thread, where every Read/Browse/Publish waits.

- P1 RebuildAddressSpace detaches the shared root's top-level children
  once instead of every leaf from its parent (NodeState.RemoveChild is a
  linear scan + RemoveAt, ~k²/2 compares per k-child folder); root
  notifiers are cleared as a list instead of one O(F) RemoveRootNotifier
  each; ForgetPredefinedSubtree / TombstoneMonitoredSubtree are iterative
  with one child buffer.
- P2 RemoveEquipmentSubtree walks a parent→children index maintained by
  EnsureFolder/EnsureVariable/MaterialiseAlarmCondition (keyed by the
  parent id the caller named) instead of prefix-scanning six maps per
  removed equipment; a surviving condition's wiring into a removed folder
  is found through the folder's own notifier list; descendants are not
  detached from a folder that is itself being discarded.
- P3 LowerAllEventsMonitoring(condition) replaces the full O(C) reconcile
  on RemoveAlarmConditionNode / RemoveEquipmentSubtree / the kind-swap.
- P5 AlarmNotifierWiring.Folders is a reference-identity HashSet and an
  already-wired pair skips the SDK's linear AddNotifier dedup.

Benchmark (NodeManagerLockTimeBenchmarkTests, M-series Mac under load,
three runs each): rebuild of a 10k-tag folder 75–150 ms → 5–6 ms;
removing 200 equipment (250 signals each) from a 50k-node space
471–681 ms → 58–64 ms; all-events bookkeeping visits for 10 condition
removals over 50 conditions 900 → 10. The two timing legs assert < 1 s;
the P3 leg asserts the visit count (machine-independent).

Refs #732

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 10:17:49 -04:00
parent eaac65286d
commit dca822dc59
2 changed files with 397 additions and 124 deletions
@@ -111,6 +111,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// against it (a miss ⇒ <c>BadHistoryOperationUnsupported</c>). Cleared on
/// <see cref="RebuildAddressSpace"/>.</summary>
private readonly ConcurrentDictionary<RealmKey, string> _eventNotifierSources = new();
/// <summary>Review 01 P2 — parent key → the keys of the folders / variables / conditions materialised
/// under it (by the parent id the CALLER named, so a node whose parent was unknown at creation and hangs
/// under the shared root is still found under its intended parent). <see cref="RemoveEquipmentSubtree"/>
/// walks this instead of prefix-scanning every map per removed equipment. Guarded by <c>Lock</c>.</summary>
private readonly Dictionary<RealmKey, HashSet<RealmKey>> _childIndex = new();
/// <summary>The reverse of <see cref="_childIndex"/>: child key → parent key. Guarded by <c>Lock</c>.</summary>
private readonly Dictionary<RealmKey, RealmKey> _parentIndex = new();
private FolderState? _root;
private readonly ILogger<OtOpcUaNodeManager> _logger;
@@ -1400,8 +1407,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
ForgetPredefinedSubtree(existing);
// The materialise tail raises only the new instance's unit (no full reconcile), so give the
// dropped instance's back here rather than leave it tracked until the next full reconcile.
if (_allEventsMonitoredConditions.Remove(existing))
existing.SetAreEventsMonitored(SystemContext, false, includeChildren: true);
LowerAllEventsMonitoring(existing);
}
// H6a: re-materialising the same id as the OTHER kind (native↔scripted) must reflect the new
@@ -1579,6 +1585,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
AddPredefinedNode(SystemContext, alarm);
_alarmConditions[conditionKey] = alarm;
if (!string.IsNullOrEmpty(equipmentNodeId)) IndexChild(MapKey(realm, equipmentNodeId), conditionKey);
// Only the new condition can be missing its unit here, so raise just that one — a full reconcile
// walks every condition, which made a deploy of N alarms O(N²) under Lock whenever any Server-object
// item was live (always, in production). Removal, rebuild and (un)subscribe keep the full reconcile.
@@ -1690,11 +1697,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
/// <summary>One materialised native-alarm condition together with the equipment folders wired as extra
/// event-notifier roots for it. The <see cref="Folders"/> list is mutated in place under <c>Lock</c> as
/// notifiers are wired / torn down; <see cref="Alarm"/> is the concrete <see cref="AlarmConditionState"/>
/// instance the notifiers were wired against (a rebuild recreates the instance, so the entry is reset when
/// the instance changes).</summary>
private sealed record AlarmNotifierWiring(AlarmConditionState Alarm, List<FolderState> Folders);
/// event-notifier roots for it. The <see cref="Folders"/> set (reference identity — review 01 P5: the
/// membership probe per wired folder was a linear <c>ReferenceEquals</c> walk) is mutated in place under
/// <c>Lock</c> as notifiers are wired / torn down; <see cref="Alarm"/> is the concrete
/// <see cref="AlarmConditionState"/> instance the notifiers were wired against (a rebuild recreates the
/// instance, so the entry is reset when the instance changes).</summary>
private sealed record AlarmNotifierWiring(AlarmConditionState Alarm, HashSet<FolderState> Folders);
// This is a RECONCILE, not additive-only: it wires the supplied set AND unwires any
// previously-tracked folder whose id is no longer in notifierFolderNodeIds
@@ -1735,7 +1743,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// the entry (the old folder list referenced the discarded instance) when the instance differs.
if (!_alarmNotifierWiring.TryGetValue(alarmKey, out var wiring) || !ReferenceEquals(wiring.Alarm, alarm))
{
wiring = new AlarmNotifierWiring(alarm, new List<FolderState>());
wiring = new AlarmNotifierWiring(
alarm, new HashSet<FolderState>(System.Collections.Generic.ReferenceEqualityComparer.Instance));
_alarmNotifierWiring[alarmKey] = wiring;
}
@@ -1772,13 +1781,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// alarm.AddNotifier(isInverse:true, folder) — the upward bubble to the equipment folder
// folder.AddNotifier(isInverse:false, alarm) — the downward AreEventsMonitored link
// EnsureFolderIsEventNotifier(folder) — SubscribeToEvents + AddRootNotifier (idempotent)
// AddNotifier dedups by ReferenceEquals(Node), so re-wiring the same pair (idempotent re-apply)
// updates rather than duplicates.
// A pair this wiring already holds is skipped outright (review 01 P5): the SDK's AddNotifier
// dedups by a linear ReferenceEquals walk of the condition's notifier list, so re-wiring K
// folders on an idempotent re-apply cost O(K²) for nothing.
if (!wiring.Folders.Add(folder)) continue;
alarm.AddNotifier(SystemContext, null, isInverse: true, folder);
folder.AddNotifier(SystemContext, null, isInverse: false, alarm);
EnsureFolderIsEventNotifier(folder);
if (!wiring.Folders.Any(f => ReferenceEquals(f, folder))) wiring.Folders.Add(folder);
}
}
}
@@ -2826,10 +2835,18 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <param name="node">The node whose subtree is forgotten.</param>
private void ForgetPredefinedSubtree(NodeState node)
{
PredefinedNodes?.Remove(node.NodeId);
// Iterative with one reused child buffer — the recursive form allocated a list per node (review 01 P1).
var pending = new Stack<NodeState>();
pending.Push(node);
var children = new List<BaseInstanceState>();
node.GetChildren(SystemContext, children);
foreach (var child in children) ForgetPredefinedSubtree(child);
while (pending.Count > 0)
{
var current = pending.Pop();
PredefinedNodes?.Remove(current.NodeId);
children.Clear();
current.GetChildren(SystemContext, children);
foreach (var child in children) pending.Push(child);
}
}
/// <summary>Promote <paramref name="folder"/> to <see cref="EventNotifiers.SubscribeToEvents"/> and
@@ -2922,9 +2939,33 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
parent.AddChild(folder);
AddPredefinedNode(SystemContext, folder);
_folders[key] = folder;
if (!string.IsNullOrEmpty(parentNodeId)) IndexChild(MapKey(realm, parentNodeId), key);
}
}
/// <summary>Records <paramref name="child"/> under <paramref name="parent"/> in the P2 subtree index. MUST be
/// called under <c>Lock</c>. Idempotent for the same pair; a child re-indexed under another parent moves.</summary>
private void IndexChild(RealmKey parent, RealmKey child)
{
if (_parentIndex.TryGetValue(child, out var previous))
{
if (previous == parent) return;
UnindexChild(child);
}
if (!_childIndex.TryGetValue(parent, out var children)) _childIndex[parent] = children = new HashSet<RealmKey>();
children.Add(child);
_parentIndex[child] = parent;
}
/// <summary>Drops <paramref name="child"/> from the P2 subtree index. MUST be called under <c>Lock</c>.</summary>
private void UnindexChild(RealmKey child)
{
if (!_parentIndex.Remove(child, out var parent)) return;
if (_childIndex.TryGetValue(parent, out var children) && children.Remove(child) && children.Count == 0)
_childIndex.Remove(parent);
}
/// <summary>
/// Update an EXISTING folder node's <see cref="FolderState.DisplayName"/> in place and notify
/// subscribers, WITHOUT a rebuild — the in-place counterpart of <see cref="EnsureFolder"/> for a
@@ -3036,6 +3077,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
parent.AddChild(variable);
AddPredefinedNode(SystemContext, variable);
_variables[key] = variable;
if (!string.IsNullOrEmpty(parentFolderNodeId)) IndexChild(MapKey(realm, parentFolderNodeId), key);
// Phase C: register the resolved historian tagname so the HistoryRead override can map this
// NodeId back to its Aveva/historian source. A historized UNS reference node passes the SAME
// tagname as its backing raw node — each NodeId keys its own entry, both mapping to one tagname.
@@ -3500,16 +3542,6 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// TombstoneMonitoredNode). The shared root survives the rebuild and keeps its items.
TombstoneAllMonitoredNodes();
foreach (var v in _variables.Values)
{
v.Parent?.RemoveChild(v);
PredefinedNodes?.Remove(v.NodeId);
}
_variables.Clear();
// Phase C: drop the NodeId→historian-tagname registrations alongside the variables they map.
_historizedTagnames.Clear();
_arrayOnScalarRefused.Clear();
// Capture every NATIVE condition's live state before the conditions are discarded, so the
// re-materialise that follows in this same apply can put it back (see CaptureCarryOver). Scripted
// conditions are not captured — their engine re-asserts them after the reload.
@@ -3520,38 +3552,56 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (_nativeAlarmNodeIds.Contains(key)) _rebuildCarryOver[key] = CaptureCarryOver(alarm);
}
foreach (var alarm in _alarmConditions.Values)
{
alarm.Parent?.RemoveChild(alarm);
ForgetPredefinedSubtree(alarm);
}
// tear down every native-alarm→equipment-folder notifier pair bidirectionally
// BEFORE the conditions + folders are cleared below, so no inverse-notifier entry leaks across the
// rebuild. The condition + folder NodeState objects still exist here (the loops above/below only
// detach them from their parents + PredefinedNodes), so RemoveNotifier is valid; then clear the
// tracking map so the re-materialise + re-wire on the next apply starts from a clean slate.
// rebuild. The condition + folder NodeState objects still exist here, so RemoveNotifier is valid;
// then clear the tracking map so the re-materialise + re-wire on the next apply starts clean.
foreach (var alarmKey in _alarmNotifierWiring.Keys.ToList())
UnwireAlarmNotifiers(alarmKey);
// Review 01 P1 — detach whole subtrees, not leaves. NodeState.RemoveChild is a linear scan +
// RemoveAt, so detaching each of a folder's k children from it cost ~k²/2 compares under Lock
// (a 10k-tag device folder ≈ 50M). Everything this manager minted hangs under the shared root,
// which survives the rebuild, so detaching the root's own children once discards every subtree
// beneath them; the SDK lookup dictionary is still cleared per id (O(1) each) because
// PredefinedNodes indexes every node, condition children included.
if (_root is { } root)
{
var topLevel = new List<BaseInstanceState>();
root.GetChildren(SystemContext, topLevel);
// Last-first: RemoveChild still scans for the reference, but RemoveAt on the tail shifts nothing.
for (var i = topLevel.Count - 1; i >= 0; i--) root.RemoveChild(topLevel[i]);
}
foreach (var v in _variables.Values) PredefinedNodes?.Remove(v.NodeId);
_variables.Clear();
// Phase C: drop the NodeId→historian-tagname registrations alongside the variables they map.
_historizedTagnames.Clear();
_arrayOnScalarRefused.Clear();
foreach (var alarm in _alarmConditions.Values) ForgetPredefinedSubtree(alarm);
_alarmConditions.Clear();
// H6a: drop the native-alarm flags in lock-step with the conditions they classify, so a
// re-materialise on the next apply (possibly as the other kind) starts from a clean slate.
_nativeAlarmNodeIds.Clear();
foreach (var f in _folders.Values)
{
f.Parent?.RemoveChild(f);
PredefinedNodes?.Remove(f.NodeId);
}
foreach (var f in _folders.Values) PredefinedNodes?.Remove(f.NodeId);
_folders.Clear();
_childIndex.Clear();
_parentIndex.Clear();
// Detach the Server↔folder HasNotifier ref for every promoted folder before dropping the
// guard, otherwise the rebuild leaks an orphaned root-notifier reference on the Server
// object. RemoveRootNotifier just severs that link, so its order relative to the folder
// teardown above doesn't matter — but it must run under this same Lock.
foreach (var folder in _notifierFolders.Values)
// object. Done the way RemoveRootNotifier does it, but over the whole list at once: the SDK's
// per-folder removal scans RootNotifiers each time (O(F²) for F promoted folders).
if (RootNotifiers is { Count: > 0 } rootNotifiers)
{
RemoveRootNotifier(folder);
foreach (var notifier in rootNotifiers)
{
notifier.OnReportEvent = null;
notifier.RemoveReference(ReferenceTypeIds.HasNotifier, true, ObjectIds.Server);
}
rootNotifiers.Clear();
}
// Drop the notifier-folder guard so re-materialised alarms re-promote their (rebuilt)
@@ -3611,6 +3661,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// caller (AddressSpaceApplier) falls back to a full rebuild (resync). Mirrors the per-node
// teardown inside RebuildAddressSpace, scoped to this one id.
if (!_variables.TryRemove(key, out var variable)) return false;
UnindexChild(key);
TombstoneMonitoredNode(variable.NodeId, UtcNow);
// 2026-08-15 review I3: sever the INVERSE half of every cross-tree Organizes edge this node holds
// BEFORE detaching it — the same hazard (and the same fix shape) as UnwireAlarmNotifiers on a
@@ -3678,6 +3729,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// lingering notifier folder is harmless (RemoveEquipmentSubtree demotes it when the whole
// equipment goes).
if (!_alarmConditions.TryRemove(key, out var condition)) return false;
UnindexChild(key);
TombstoneMonitoredSubtree(condition, UtcNow);
// unwire this condition's extra equipment-folder notifiers bidirectionally BEFORE
// the condition drops — the folders (equipment, UNS realm) SURVIVE this scoped remove, so without
@@ -3686,7 +3738,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
condition.Parent?.RemoveChild(condition);
ForgetPredefinedSubtree(condition);
_nativeAlarmNodeIds.Remove(key);
ReconcileAllEventsMonitoring();
LowerAllEventsMonitoring(condition);
e = BuildNodesRemovedModelChange(alarmNodeId, realm);
}
ReportModelChangeOutsideLock(e, alarmNodeId);
@@ -3706,84 +3758,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// Unknown equipment id ⇒ map drift ⇒ false (caller rebuilds).
if (!_folders.ContainsKey(equipKey)) return false;
// Maps key on the (realm, bare id) pair. Every descendant in the SAME realm shares that id exactly
// or begins with "<equipmentNodeId>/" (sub-folders, variables, condition nodes); the explicit realm
// compare is what makes the match realm-scoped — it replaces the old string key's leading "ns=N;s="
// — so a node in the OTHER namespace that happens to share a bare id is never in scope. Snapshot
// each map's in-scope keys first (ToList) so we don't mutate while enumerating.
var prefix = equipmentNodeId + "/";
bool InScope(RealmKey key) => key.Realm == realm
&& (key.Id == equipmentNodeId || key.Id.StartsWith(prefix, StringComparison.Ordinal));
var now = UtcNow;
// Value variables (+ their historized-tagname registrations).
foreach (var id in _variables.Keys.Where(InScope).ToList())
{
if (_variables.TryRemove(id, out var v))
{
TombstoneMonitoredNode(v.NodeId, now);
// Sever the inverse half of each cross-tree Organizes edge
// first, exactly as RemoveVariableNode does — a UNS reference variable in scope points at a
// RAW node that survives this UNS-scoped removal and would otherwise keep a dangling edge.
SeverOutgoingReferences(v);
v.Parent?.RemoveChild(v);
PredefinedNodes?.Remove(v.NodeId);
}
_historizedTagnames.TryRemove(id, out _);
_arrayOnScalarRefused.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))
{
TombstoneMonitoredSubtree(c, now);
c.Parent?.RemoveChild(c);
ForgetPredefinedSubtree(c);
}
_nativeAlarmNodeIds.Remove(id);
}
ReconcileAllEventsMonitoring();
// a native alarm condition lives at its raw tag (Raw realm) and SURVIVES this
// UNS-scoped subtree removal, but it may hold an in-scope equipment folder as an extra
// event-notifier root. Unwire those condition↔folder pairs bidirectionally BEFORE the folders drop,
// else the surviving condition keeps a dangling inverse-notifier entry to a removed folder. (The
// condition's own scoped removal — if its raw tag is also removed this apply — runs later via
// RemoveAlarmConditionNode; this only reaches surviving conditions.)
foreach (var wiring in _alarmNotifierWiring.Values)
{
foreach (var folder in wiring.Folders.Where(f => InScope(MapKey(f.NodeId))).ToList())
{
wiring.Alarm.RemoveNotifier(SystemContext, folder, bidirectional: true);
wiring.Folders.Remove(folder);
}
}
// 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 InScope on its realm-qualified map key.
foreach (var kvp in _notifierFolders.Where(kv => InScope(MapKey(kv.Key))).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))
{
TombstoneMonitoredNode(f.NodeId, now);
f.Parent?.RemoveChild(f);
PredefinedNodes?.Remove(f.NodeId);
}
}
// Review 01 P2 — walk the parent→children index the materialise calls maintain instead of
// prefix-scanning every map (variables, conditions, folders, notifier folders, event sources and
// every alarm's wiring) per removed equipment, which made a PureRemove of M equipment O(M·N)
// under Lock. A node is detached from its SDK parent only when that parent SURVIVES (the
// equipment folder from its line; a node hung under the shared root because its parent was
// unknown at creation) — detaching k children from a folder that is itself being discarded cost
// k²/2 compares for nothing (NodeState.RemoveChild is a linear scan).
var removedParents = new HashSet<NodeState>(System.Collections.Generic.ReferenceEqualityComparer.Instance);
RemoveSubtree(equipKey, removedParents, UtcNow);
e = BuildNodesRemovedModelChange(equipmentNodeId, realm);
}
@@ -3791,6 +3774,84 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
return true;
}
/// <summary>Removes the node at <paramref name="key"/> and, when it is a folder, everything indexed beneath
/// it (children first). MUST be called under <c>Lock</c>.</summary>
/// <param name="key">The node to remove.</param>
/// <param name="removedParents">The folders already discarded by this removal — a node under one of them is
/// not detached from it.</param>
/// <param name="now">The timestamp the removal statuses carry.</param>
private void RemoveSubtree(RealmKey key, HashSet<NodeState> removedParents, DateTime now)
{
if (_folders.TryGetValue(key, out var folder))
{
removedParents.Add(folder);
if (_childIndex.TryGetValue(key, out var children))
{
foreach (var child in children.ToArray()) RemoveSubtree(child, removedParents, now);
_childIndex.Remove(key);
}
_folders.TryRemove(key, out _);
UnindexChild(key);
TombstoneMonitoredNode(folder.NodeId, now);
// A native alarm condition lives at its raw tag (Raw realm) and SURVIVES this UNS-scoped
// subtree removal, but it may hold this folder as an extra event-notifier root. Unwire those
// condition↔folder pairs bidirectionally BEFORE the folder drops, else the surviving condition
// keeps a dangling inverse-notifier entry to a removed folder. The folder's own notifier list
// names exactly the conditions wired into it (the forward half of each pair).
var notifiers = new List<NodeState.Notifier>();
folder.GetNotifiers(SystemContext, notifiers);
foreach (var entry in notifiers)
{
if (entry.IsInverse || entry.Node is not AlarmConditionState alarm) continue;
if (!_alarmNotifierWiring.TryGetValue(MapKey(alarm.NodeId), out var wiring) || !ReferenceEquals(wiring.Alarm, alarm)) continue;
alarm.RemoveNotifier(SystemContext, folder, bidirectional: true);
wiring.Folders.Remove(folder);
}
// Notifier demotion BEFORE dropping the folder: sever the Server↔folder HasNotifier ref for a
// promoted folder (an equipment folder, or a sub-folder that hosted an alarm), else the removal
// leaks an orphaned root-notifier reference on the Server object.
if (_notifierFolders.Remove(folder.NodeId, out var promoted)) RemoveRootNotifier(promoted);
_eventNotifierSources.TryRemove(key, out _);
if (folder.Parent is { } parent && !removedParents.Contains(parent)) parent.RemoveChild(folder);
PredefinedNodes?.Remove(folder.NodeId);
return;
}
if (_variables.TryRemove(key, out var v))
{
UnindexChild(key);
TombstoneMonitoredNode(v.NodeId, now);
// Sever the inverse half of each cross-tree Organizes edge first, exactly as RemoveVariableNode
// does — a UNS reference variable in scope points at a RAW node that survives this UNS-scoped
// removal and would otherwise keep a dangling edge.
SeverOutgoingReferences(v);
if (v.Parent is { } parent && !removedParents.Contains(parent)) parent.RemoveChild(v);
PredefinedNodes?.Remove(v.NodeId);
_historizedTagnames.TryRemove(key, out _);
_arrayOnScalarRefused.TryRemove(key, out _);
return;
}
if (_alarmConditions.TryRemove(key, out var c))
{
UnindexChild(key);
TombstoneMonitoredSubtree(c, now);
UnwireAlarmNotifiers(key);
if (c.Parent is { } parent && !removedParents.Contains(parent)) parent.RemoveChild(c);
ForgetPredefinedSubtree(c);
_nativeAlarmNodeIds.Remove(key);
LowerAllEventsMonitoring(c);
return;
}
// Indexed but already gone (a surgical remove dropped it first) — nothing to do.
UnindexChild(key);
}
private FolderState ResolveParentFolder(string? parentNodeId, AddressSpaceRealm realm)
{
EnsureAddressSpaceCreated();
@@ -4296,10 +4357,18 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// children are monitorable in their own right). MUST be called under <c>Lock</c>.</summary>
private void TombstoneMonitoredSubtree(NodeState node, DateTime now)
{
TombstoneMonitoredNode(node.NodeId, now);
if (MonitoredNodes.Count == 0 && Volatile.Read(ref _suppressedItems) == 0) return;
var pending = new Stack<NodeState>();
pending.Push(node);
var children = new List<BaseInstanceState>();
node.GetChildren(SystemContext, children);
foreach (var child in children) TombstoneMonitoredSubtree(child, now);
while (pending.Count > 0)
{
var current = pending.Pop();
TombstoneMonitoredNode(current.NodeId, now);
children.Clear();
current.GetChildren(SystemContext, children);
foreach (var child in children) pending.Push(child);
}
}
/// <summary>The rebuild form: every monitored node this manager serves except the shared root, which the
@@ -4560,6 +4629,20 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
condition.SetAreEventsMonitored(SystemContext, true, includeChildren: true);
}
/// <summary>
/// The removal-side mirror of <see cref="RaiseAllEventsMonitoring"/> (review 01 P3): gives back the one
/// condition's unit, touching no other condition. Call under <c>Lock</c>, for a condition being removed
/// or replaced. The surgical removal sites used to run the full reconcile — O(C) per removed condition
/// while any Server-object item was live, i.e. always in production.
/// </summary>
/// <param name="condition">The condition leaving <c>_alarmConditions</c>.</param>
private void LowerAllEventsMonitoring(AlarmConditionState condition)
{
AllEventsMonitoringConditionVisits++;
if (_allEventsMonitoredConditions.Remove(condition))
condition.SetAreEventsMonitored(SystemContext, false, includeChildren: true);
}
/// <summary>
/// How many conditions the Server-object monitored bookkeeping has visited since start — every
/// condition a full <see cref="ReconcileAllEventsMonitoring"/> walks, plus one per incremental