perf(opcuaserver): struct realm keys replace formatted NodeId string keys

This commit is contained in:
Joseph Doherty
2026-08-15 12:44:13 -04:00
parent d7edc709cf
commit 4f177bc3e8
@@ -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.</summary>
public const string DefaultNamespaceUri = V3NodeIds.UnsNamespaceUri;
private readonly ConcurrentDictionary<string, BaseDataVariableState> _variables = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, FolderState> _folders = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, AlarmConditionState> _alarmConditions = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<RealmKey, BaseDataVariableState> _variables = new();
private readonly ConcurrentDictionary<RealmKey, FolderState> _folders = new();
private readonly ConcurrentDictionary<RealmKey, AlarmConditionState> _alarmConditions = new();
/// <summary>H6a: the subset of <see cref="_alarmConditions"/> 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 <see cref="_alarmConditions"/>:
/// a native re-materialise adds, and <see cref="RebuildAddressSpace"/> clears it alongside
/// <see cref="_alarmConditions"/> so a re-materialise as the other kind is correct.</summary>
private readonly HashSet<string> _nativeAlarmNodeIds = new(StringComparer.Ordinal);
private readonly HashSet<RealmKey> _nativeAlarmNodeIds = new();
/// <summary>Phase C: NodeId → resolved historian tagname for every variable materialised
/// Historizing. Populated by <see cref="EnsureVariable"/> when a historian tagname is supplied; the
/// (later) HistoryRead override resolves a HistoryRead request's NodeId against this map. Cleared on
/// <see cref="RebuildAddressSpace"/>.</summary>
private readonly ConcurrentDictionary<string, string> _historizedTagnames = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<RealmKey, string> _historizedTagnames = new();
/// <summary>Folders we have already promoted to event-notifiers + registered as root notifiers,
/// so repeated <see cref="MaterialiseAlarmCondition"/> calls don't double-add (idempotent guard).
/// Keyed by NodeId → the actual <see cref="FolderState"/> so <see cref="RebuildAddressSpace"/> can
@@ -81,7 +81,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// each wired pair is torn down bidirectionally (<c>RemoveNotifier(bidirectional:true)</c>) so an
/// inverse-notifier entry never leaks across redeploys. Guarded by the same <c>Lock</c> as
/// <see cref="_alarmConditions"/> / <see cref="_notifierFolders"/>.</summary>
private readonly Dictionary<string, AlarmNotifierWiring> _alarmNotifierWiring = new(StringComparer.Ordinal);
private readonly Dictionary<RealmKey, AlarmNotifierWiring> _alarmNotifierWiring = new();
/// <summary>Phase C: event-notifier folder NodeId-identifier → the event-history source
/// name passed to <see cref="IHistorianDataSource.ReadEventsAsync"/>. 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 <see cref="HistoryReadEvents"/> override resolves an inbound request's notifier NodeId
/// against it (a miss ⇒ <c>BadHistoryOperationUnsupported</c>). Cleared on
/// <see cref="RebuildAddressSpace"/>.</summary>
private readonly ConcurrentDictionary<string, string> _eventNotifierSources = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<RealmKey, string> _eventNotifierSources = new();
private FolderState? _root;
/// <summary>Initializes a new instance of the <see cref="OtOpcUaNodeManager"/> class with the OPC UA server and configuration.</summary>
@@ -123,20 +123,30 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
private AddressSpaceRealm RealmOf(NodeId nodeId) =>
nodeId.NamespaceIndex == NamespaceIndexes[0] ? AddressSpaceRealm.Raw : AddressSpaceRealm.Uns;
/// <summary>Build the internal map key for a realm-qualified string id. The key is the node's full
/// namespace-qualified NodeId string (e.g. <c>ns=2;s=&lt;id&gt;</c>) so a Raw node and a UNS node that
/// share the same <c>s=</c> identifier are distinct keys — a bare id is no longer globally unique across
/// the two namespaces. Matches <see cref="MapKey(NodeId)"/> for the same node.</summary>
/// <summary>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).</summary>
/// <param name="Realm">The realm the id lives in.</param>
/// <param name="Id">The bare <c>s=</c> identifier.</param>
private readonly record struct RealmKey(AddressSpaceRealm Realm, string Id);
/// <summary>Build the internal map key for a realm-qualified string id. The key pairs the realm with the
/// bare <c>s=</c> 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
/// <see cref="MapKey(NodeId)"/> for the same node.</summary>
/// <param name="realm">The realm the id lives in.</param>
/// <param name="id">The bare <c>s=</c> identifier.</param>
/// <returns>The namespace-qualified map key.</returns>
private string MapKey(AddressSpaceRealm realm, string id) => new NodeId(id, NamespaceIndexForRealm(realm)).ToString();
/// <returns>The realm-qualified map key.</returns>
private static RealmKey MapKey(AddressSpaceRealm realm, string id) => new(realm, id);
/// <summary>Build the internal map key for an already-resolved SDK <see cref="NodeId"/> (the SDK-facing
/// seams). Equal to <see cref="MapKey(AddressSpaceRealm, string)"/> for the same node.</summary>
/// seams). Equal to <see cref="MapKey(AddressSpaceRealm, string)"/> for the same node. The SDK-facing
/// seams only ever pass nodes this manager minted (string <c>s=</c> identifiers); a non-string
/// identifier falls back to its string form rather than throwing.</summary>
/// <param name="nodeId">The resolved SDK node id.</param>
/// <returns>The namespace-qualified map key.</returns>
private static string MapKey(NodeId nodeId) => nodeId.ToString();
/// <returns>The realm-qualified map key.</returns>
private RealmKey MapKey(NodeId nodeId) =>
new(RealmOf(nodeId), nodeId.Identifier as string ?? nodeId.Identifier?.ToString() ?? string.Empty);
/// <summary>Gets the count of variable nodes currently managed.</summary>
public int VariableCount => _variables.Count;
@@ -615,7 +625,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <param name="NamespaceIndex">The namespace index resolved from the item's realm.</param>
/// <param name="Key">The realm-qualified map key.</param>
/// <param name="IsValid">False when the item failed the pre-pass and must be skipped.</param>
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);
/// <summary>The body of a single value write. <b>Caller MUST hold <c>Lock</c></b> — this is factored out
/// solely so <see cref="WriteValues"/> can apply a whole batch under one acquisition; it is byte-identical
@@ -626,7 +636,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <param name="value">The new value to write.</param>
/// <param name="statusCode">The full OPC UA status code, sub-code included.</param>
/// <param name="sourceTimestampUtc">The timestamp of the value in UTC.</param>
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
/// <summary>Tear down (bidirectionally) every notifier wired for the condition at <paramref name="alarmKey"/>
/// and drop its tracking entry. MUST be called under <c>Lock</c>. Used on condition-removal + full rebuild
/// so no inverse-notifier entry leaks. A no-op when the condition has no wired notifiers.</summary>
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
/// <see cref="AddReference"/> to link two already-materialised nodes regardless of their kind.</summary>
/// <param name="key">The namespace-qualified map key (see <see cref="MapKey(AddressSpaceRealm, string)"/>).</param>
/// <returns>The node state, or null when unknown.</returns>
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=<equipmentId>"). Every
// descendant in the SAME realm shares that key exactly or begins with "<equipKey>/" (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 "<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));
// 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);