perf(opcuaserver): struct realm keys replace formatted NodeId string keys
This commit is contained in:
@@ -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=<id></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);
|
||||
|
||||
Reference in New Issue
Block a user