diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 5c9c0643..11de032c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -52,21 +52,21 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// v3 URIs are the real namespaces; this alias is removed once those tests target Raw/UNS explicitly. public const string DefaultNamespaceUri = V3NodeIds.UnsNamespaceUri; - private readonly ConcurrentDictionary _variables = new(StringComparer.Ordinal); - private readonly ConcurrentDictionary _folders = new(StringComparer.Ordinal); - private readonly ConcurrentDictionary _alarmConditions = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _variables = new(); + private readonly ConcurrentDictionary _folders = new(); + private readonly ConcurrentDictionary _alarmConditions = new(); /// H6a: the subset of node ids materialised as NATIVE /// (driver-fed, e.g. Galaxy equipment-tag alarms) rather than scripted. A later task routes a native /// condition's inbound Acknowledge to the driver instead of the scripted engine, so the node manager /// must know which conditions are native. Maintained in lock-step with : /// a native re-materialise adds, and clears it alongside /// so a re-materialise as the other kind is correct. - private readonly HashSet _nativeAlarmNodeIds = new(StringComparer.Ordinal); + private readonly HashSet _nativeAlarmNodeIds = new(); /// Phase C: NodeId → resolved historian tagname for every variable materialised /// Historizing. Populated by when a historian tagname is supplied; the /// (later) HistoryRead override resolves a HistoryRead request's NodeId against this map. Cleared on /// . - private readonly ConcurrentDictionary _historizedTagnames = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _historizedTagnames = new(); /// Folders we have already promoted to event-notifiers + registered as root notifiers, /// so repeated calls don't double-add (idempotent guard). /// Keyed by NodeId → the actual so can @@ -81,7 +81,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// each wired pair is torn down bidirectionally (RemoveNotifier(bidirectional:true)) so an /// inverse-notifier entry never leaks across redeploys. Guarded by the same Lock as /// / . - private readonly Dictionary _alarmNotifierWiring = new(StringComparer.Ordinal); + private readonly Dictionary _alarmNotifierWiring = new(); /// Phase C: event-notifier folder NodeId-identifier → the event-history source /// name passed to . The equipment-folder NodeId /// identifier IS the equipment id, which IS the sourceName, so key and value are the same string; @@ -90,7 +90,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// and the override resolves an inbound request's notifier NodeId /// against it (a miss ⇒ BadHistoryOperationUnsupported). Cleared on /// . - private readonly ConcurrentDictionary _eventNotifierSources = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _eventNotifierSources = new(); private FolderState? _root; /// Initializes a new instance of the class with the OPC UA server and configuration. @@ -123,20 +123,30 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 private AddressSpaceRealm RealmOf(NodeId nodeId) => nodeId.NamespaceIndex == NamespaceIndexes[0] ? AddressSpaceRealm.Raw : AddressSpaceRealm.Uns; - /// Build the internal map key for a realm-qualified string id. The key is the node's full - /// namespace-qualified NodeId string (e.g. ns=2;s=<id>) so a Raw node and a UNS node that - /// share the same s= identifier are distinct keys — a bare id is no longer globally unique across - /// the two namespaces. Matches for the same node. + /// Zero-allocation realm-qualified map key. Replaces the formatted "ns=N;s=id" string key — + /// the (realm, id) pair is the same identity without the per-write NodeId + string allocations + /// (the id string is already in hand at every call site). + /// The realm the id lives in. + /// The bare s= identifier. + private readonly record struct RealmKey(AddressSpaceRealm Realm, string Id); + + /// Build the internal map key for a realm-qualified string id. The key pairs the realm with the + /// bare s= identifier so a Raw node and a UNS node that share the same identifier are distinct + /// keys — a bare id is not globally unique across the two namespaces. Matches + /// for the same node. /// The realm the id lives in. /// The bare s= identifier. - /// The namespace-qualified map key. - private string MapKey(AddressSpaceRealm realm, string id) => new NodeId(id, NamespaceIndexForRealm(realm)).ToString(); + /// The realm-qualified map key. + private static RealmKey MapKey(AddressSpaceRealm realm, string id) => new(realm, id); /// Build the internal map key for an already-resolved SDK (the SDK-facing - /// seams). Equal to for the same node. + /// seams). Equal to for the same node. The SDK-facing + /// seams only ever pass nodes this manager minted (string s= identifiers); a non-string + /// identifier falls back to its string form rather than throwing. /// The resolved SDK node id. - /// The namespace-qualified map key. - private static string MapKey(NodeId nodeId) => nodeId.ToString(); + /// The realm-qualified map key. + private RealmKey MapKey(NodeId nodeId) => + new(RealmOf(nodeId), nodeId.Identifier as string ?? nodeId.Identifier?.ToString() ?? string.Empty); /// Gets the count of variable nodes currently managed. public int VariableCount => _variables.Count; @@ -615,7 +625,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The namespace index resolved from the item's realm. /// The realm-qualified map key. /// False when the item failed the pre-pass and must be skipped. - private readonly record struct ResolvedWrite(string NodeId, ushort NamespaceIndex, string Key, bool IsValid); + private readonly record struct ResolvedWrite(string NodeId, ushort NamespaceIndex, RealmKey Key, bool IsValid); /// The body of a single value write. Caller MUST hold Lock — this is factored out /// solely so can apply a whole batch under one acquisition; it is byte-identical @@ -626,7 +636,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The new value to write. /// The full OPC UA status code, sub-code included. /// The timestamp of the value in UTC. - private void WriteValueLocked(string nodeId, ushort ns, string key, object? value, uint statusCode, DateTime sourceTimestampUtc) + private void WriteValueLocked(string nodeId, ushort ns, RealmKey key, object? value, uint statusCode, DateTime sourceTimestampUtc) { // CreateVariable mutates the SDK address space (_root.AddChild + AddPredefinedNode), // so it MUST run under Lock — the SDK's subscription/ConditionRefresh threads take it too. @@ -1269,7 +1279,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // supplied, regardless of whether each is currently materialised. Reconcile against it below. var desiredKeys = notifierFolderNodeIds .Select(id => MapKey(notifierFolderRealm, id)) - .ToHashSet(StringComparer.Ordinal); + .ToHashSet(); // Reconcile (M3): unwire any previously-wired folder whose id is NO LONGER in the supplied set — a // genuine de-reference (the equipment dropped its reference to the alarm's raw tag). Matched by the @@ -1314,7 +1324,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// Tear down (bidirectionally) every notifier wired for the condition at /// and drop its tracking entry. MUST be called under Lock. Used on condition-removal + full rebuild /// so no inverse-notifier entry leaks. A no-op when the condition has no wired notifiers. - private void UnwireAlarmNotifiers(string alarmKey) + private void UnwireAlarmNotifiers(RealmKey alarmKey) { if (!_alarmNotifierWiring.TryGetValue(alarmKey, out var wiring)) return; foreach (var folder in wiring.Folders) @@ -2353,7 +2363,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// to link two already-materialised nodes regardless of their kind. /// The namespace-qualified map key (see ). /// The node state, or null when unknown. - private NodeState? ResolveNodeState(string key) + private NodeState? ResolveNodeState(RealmKey key) { if (_variables.TryGetValue(key, out var variable)) return variable; if (_folders.TryGetValue(key, out var folder)) return folder; @@ -2641,13 +2651,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // Unknown equipment id ⇒ map drift ⇒ false (caller rebuilds). if (!_folders.ContainsKey(equipKey)) return false; - // Maps key on the full namespace-qualified NodeId string (e.g. "ns=3;s="). Every - // descendant in the SAME realm shares that key exactly or begins with "/" (sub-folders, - // variables, condition nodes); the "ns=N;s=" prefix makes the match realm-scoped for free, 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 = equipKey + "/"; - bool InScope(string key) => key == equipKey || key.StartsWith(prefix, StringComparison.Ordinal); + // Maps key on the (realm, bare id) pair. Every descendant in the SAME realm shares that id exactly + // or begins with "/" (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)); // Value variables (+ their historized-tagname registrations). foreach (var id in _variables.Keys.Where(InScope).ToList()) @@ -2689,8 +2700,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // 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 full (ns-qualified) string. - foreach (var kvp in _notifierFolders.Where(kv => InScope(kv.Key.ToString())).ToList()) + // _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);