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>
|
/// v3 URIs are the real namespaces; this alias is removed once those tests target Raw/UNS explicitly.</summary>
|
||||||
public const string DefaultNamespaceUri = V3NodeIds.UnsNamespaceUri;
|
public const string DefaultNamespaceUri = V3NodeIds.UnsNamespaceUri;
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, BaseDataVariableState> _variables = new(StringComparer.Ordinal);
|
private readonly ConcurrentDictionary<RealmKey, BaseDataVariableState> _variables = new();
|
||||||
private readonly ConcurrentDictionary<string, FolderState> _folders = new(StringComparer.Ordinal);
|
private readonly ConcurrentDictionary<RealmKey, FolderState> _folders = new();
|
||||||
private readonly ConcurrentDictionary<string, AlarmConditionState> _alarmConditions = new(StringComparer.Ordinal);
|
private readonly ConcurrentDictionary<RealmKey, AlarmConditionState> _alarmConditions = new();
|
||||||
/// <summary>H6a: the subset of <see cref="_alarmConditions"/> node ids materialised as NATIVE
|
/// <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
|
/// (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
|
/// 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"/>:
|
/// 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
|
/// 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>
|
/// <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
|
/// <summary>Phase C: NodeId → resolved historian tagname for every variable materialised
|
||||||
/// Historizing. Populated by <see cref="EnsureVariable"/> when a historian tagname is supplied; the
|
/// 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
|
/// (later) HistoryRead override resolves a HistoryRead request's NodeId against this map. Cleared on
|
||||||
/// <see cref="RebuildAddressSpace"/>.</summary>
|
/// <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,
|
/// <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).
|
/// 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
|
/// 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
|
/// 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
|
/// inverse-notifier entry never leaks across redeploys. Guarded by the same <c>Lock</c> as
|
||||||
/// <see cref="_alarmConditions"/> / <see cref="_notifierFolders"/>.</summary>
|
/// <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
|
/// <summary>Phase C: event-notifier folder NodeId-identifier → the event-history source
|
||||||
/// name passed to <see cref="IHistorianDataSource.ReadEventsAsync"/>. The equipment-folder NodeId
|
/// 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;
|
/// 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
|
/// and the <see cref="HistoryReadEvents"/> override resolves an inbound request's notifier NodeId
|
||||||
/// against it (a miss ⇒ <c>BadHistoryOperationUnsupported</c>). Cleared on
|
/// against it (a miss ⇒ <c>BadHistoryOperationUnsupported</c>). Cleared on
|
||||||
/// <see cref="RebuildAddressSpace"/>.</summary>
|
/// <see cref="RebuildAddressSpace"/>.</summary>
|
||||||
private readonly ConcurrentDictionary<string, string> _eventNotifierSources = new(StringComparer.Ordinal);
|
private readonly ConcurrentDictionary<RealmKey, string> _eventNotifierSources = new();
|
||||||
private FolderState? _root;
|
private FolderState? _root;
|
||||||
|
|
||||||
/// <summary>Initializes a new instance of the <see cref="OtOpcUaNodeManager"/> class with the OPC UA server and configuration.</summary>
|
/// <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) =>
|
private AddressSpaceRealm RealmOf(NodeId nodeId) =>
|
||||||
nodeId.NamespaceIndex == NamespaceIndexes[0] ? AddressSpaceRealm.Raw : AddressSpaceRealm.Uns;
|
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
|
/// <summary>Zero-allocation realm-qualified map key. Replaces the formatted "ns=N;s=id" string key —
|
||||||
/// namespace-qualified NodeId string (e.g. <c>ns=2;s=<id></c>) so a Raw node and a UNS node that
|
/// the (realm, id) pair is the same identity without the per-write NodeId + string allocations
|
||||||
/// share the same <c>s=</c> identifier are distinct keys — a bare id is no longer globally unique across
|
/// (the id string is already in hand at every call site).</summary>
|
||||||
/// 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>
|
||||||
|
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="realm">The realm the id lives in.</param>
|
||||||
/// <param name="id">The bare <c>s=</c> identifier.</param>
|
/// <param name="id">The bare <c>s=</c> identifier.</param>
|
||||||
/// <returns>The namespace-qualified map key.</returns>
|
/// <returns>The realm-qualified map key.</returns>
|
||||||
private string MapKey(AddressSpaceRealm realm, string id) => new NodeId(id, NamespaceIndexForRealm(realm)).ToString();
|
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
|
/// <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>
|
/// <param name="nodeId">The resolved SDK node id.</param>
|
||||||
/// <returns>The namespace-qualified map key.</returns>
|
/// <returns>The realm-qualified map key.</returns>
|
||||||
private static string MapKey(NodeId nodeId) => nodeId.ToString();
|
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>
|
/// <summary>Gets the count of variable nodes currently managed.</summary>
|
||||||
public int VariableCount => _variables.Count;
|
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="NamespaceIndex">The namespace index resolved from the item's realm.</param>
|
||||||
/// <param name="Key">The realm-qualified map key.</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>
|
/// <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
|
/// <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
|
/// 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="value">The new value to write.</param>
|
||||||
/// <param name="statusCode">The full OPC UA status code, sub-code included.</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>
|
/// <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),
|
// CreateVariable mutates the SDK address space (_root.AddChild + AddPredefinedNode),
|
||||||
// so it MUST run under Lock — the SDK's subscription/ConditionRefresh threads take it too.
|
// 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.
|
// supplied, regardless of whether each is currently materialised. Reconcile against it below.
|
||||||
var desiredKeys = notifierFolderNodeIds
|
var desiredKeys = notifierFolderNodeIds
|
||||||
.Select(id => MapKey(notifierFolderRealm, id))
|
.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
|
// 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
|
// 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"/>
|
/// <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
|
/// 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>
|
/// 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;
|
if (!_alarmNotifierWiring.TryGetValue(alarmKey, out var wiring)) return;
|
||||||
foreach (var folder in wiring.Folders)
|
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>
|
/// <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>
|
/// <param name="key">The namespace-qualified map key (see <see cref="MapKey(AddressSpaceRealm, string)"/>).</param>
|
||||||
/// <returns>The node state, or null when unknown.</returns>
|
/// <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 (_variables.TryGetValue(key, out var variable)) return variable;
|
||||||
if (_folders.TryGetValue(key, out var folder)) return folder;
|
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).
|
// Unknown equipment id ⇒ map drift ⇒ false (caller rebuilds).
|
||||||
if (!_folders.ContainsKey(equipKey)) return false;
|
if (!_folders.ContainsKey(equipKey)) return false;
|
||||||
|
|
||||||
// Maps key on the full namespace-qualified NodeId string (e.g. "ns=3;s=<equipmentId>"). Every
|
// Maps key on the (realm, bare id) pair. Every descendant in the SAME realm shares that id exactly
|
||||||
// descendant in the SAME realm shares that key exactly or begins with "<equipKey>/" (sub-folders,
|
// or begins with "<equipmentNodeId>/" (sub-folders, variables, condition nodes); the explicit realm
|
||||||
// variables, condition nodes); the "ns=N;s=" prefix makes the match realm-scoped for free, so a
|
// compare is what makes the match realm-scoped — it replaces the old string key's leading "ns=N;s="
|
||||||
// node in the OTHER namespace that happens to share a bare id is never in scope. Snapshot each
|
// — so a node in the OTHER namespace that happens to share a bare id is never in scope. Snapshot
|
||||||
// map's in-scope keys first (ToList) so we don't mutate while enumerating.
|
// each map's in-scope keys first (ToList) so we don't mutate while enumerating.
|
||||||
var prefix = equipKey + "/";
|
var prefix = equipmentNodeId + "/";
|
||||||
bool InScope(string key) => key == equipKey || key.StartsWith(prefix, StringComparison.Ordinal);
|
bool InScope(RealmKey key) => key.Realm == realm
|
||||||
|
&& (key.Id == equipmentNodeId || key.Id.StartsWith(prefix, StringComparison.Ordinal));
|
||||||
|
|
||||||
// Value variables (+ their historized-tagname registrations).
|
// Value variables (+ their historized-tagname registrations).
|
||||||
foreach (var id in _variables.Keys.Where(InScope).ToList())
|
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
|
// 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
|
// 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.
|
// 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.
|
// _notifierFolders is keyed by the folder's NodeId; match InScope on its realm-qualified map key.
|
||||||
foreach (var kvp in _notifierFolders.Where(kv => InScope(kv.Key.ToString())).ToList())
|
foreach (var kvp in _notifierFolders.Where(kv => InScope(MapKey(kv.Key))).ToList())
|
||||||
{
|
{
|
||||||
RemoveRootNotifier(kvp.Value);
|
RemoveRootNotifier(kvp.Value);
|
||||||
_notifierFolders.Remove(kvp.Key);
|
_notifierFolders.Remove(kvp.Key);
|
||||||
|
|||||||
Reference in New Issue
Block a user