diff --git a/src/Opc.Ua.Core/Stack/Tcp/UaSCBinaryClientChannel.cs b/src/Opc.Ua.Core/Stack/Tcp/UaSCBinaryClientChannel.cs index 8d5302449b..f5ff40bb43 100644 --- a/src/Opc.Ua.Core/Stack/Tcp/UaSCBinaryClientChannel.cs +++ b/src/Opc.Ua.Core/Stack/Tcp/UaSCBinaryClientChannel.cs @@ -367,7 +367,11 @@ public async ValueTask SendRequestAsync( using Activity? activity = CachedActivitySource.StartActivity(); WriteOperation? operation = null; - using (Gate.Enter()) + + // a request that waits for the gate (another request or a response is being + // processed) must not block its thread: a blocking wait spins first, and with + // many concurrent requests on one channel the spinning took a fifth of the CPU. + using (await Gate.EnterAsync(ct).ConfigureAwait(false)) { // Queue the operation while connecting and it will be played once connected. if (State == TcpChannelState.Connecting) diff --git a/src/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs b/src/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs index 34c27617f1..044b0e61fe 100644 --- a/src/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs +++ b/src/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs @@ -10602,13 +10602,24 @@ private async ValueTask SetDefaultPermissionsAsync( NodeState target, CancellationToken cancellationToken = default) { + ushort namespaceIndex = target.NodeId.NamespaceIndex; + OperationContext? operationContext = systemContext.OperationContext; + if (operationContext != null && + operationContext.TryGetDefaultPermissions(namespaceIndex, out NamespaceDefaultPermissions cached)) + { + return ( + cached.DefaultAccessRestrictions, + cached.DefaultRolePermissions, + cached.DefaultUserRolePermissions); + } + AccessRestrictionType defaultAccessRestrictions = AccessRestrictionType.None; ArrayOf defaultRolePermissions = default; ArrayOf defaultUserRolePermissions = default; // check if NamespaceMetadata is defined for NamespaceIndex of the node. NamespaceMetadataState? namespaceMetadataState = await - Server.NodeManager.ConfigurationNodeManager!.GetNamespaceMetadataStateAsync(target.NodeId.NamespaceIndex, cancellationToken) + Server.NodeManager.ConfigurationNodeManager!.GetNamespaceMetadataStateAsync(namespaceIndex, cancellationToken) .ConfigureAwait(false); if (namespaceMetadataState != null) @@ -10666,6 +10677,12 @@ private async ValueTask SetDefaultPermissionsAsync( } } + operationContext?.SetDefaultPermissions(new NamespaceDefaultPermissions( + namespaceIndex, + defaultAccessRestrictions, + defaultRolePermissions, + defaultUserRolePermissions)); + return (defaultAccessRestrictions, defaultRolePermissions, defaultUserRolePermissions); } diff --git a/src/Opc.Ua.Server/NodeManager/CustomNodeManager.cs b/src/Opc.Ua.Server/NodeManager/CustomNodeManager.cs index b0a8cb599d..6b55a8d6fb 100644 --- a/src/Opc.Ua.Server/NodeManager/CustomNodeManager.cs +++ b/src/Opc.Ua.Server/NodeManager/CustomNodeManager.cs @@ -6332,6 +6332,37 @@ private void SetDefaultPermissions( ServerSystemContext systemContext, NodeState target, NodeMetadata metadata) + { + // the defaults are read once per namespace and service request, like + // AsyncCustomNodeManager does: every read takes two server-wide locks and + // copies the role permission array. + ushort namespaceIndex = target.NodeId.NamespaceIndex; + OperationContext? operationContext = systemContext.OperationContext; + if (operationContext != null && + operationContext.TryGetDefaultPermissions(namespaceIndex, out NamespaceDefaultPermissions cached)) + { + metadata.DefaultAccessRestrictions = cached.DefaultAccessRestrictions; + metadata.DefaultRolePermissions = cached.DefaultRolePermissions; + metadata.DefaultUserRolePermissions = cached.DefaultUserRolePermissions; + return; + } + + ReadDefaultPermissions(systemContext, target, metadata); + + operationContext?.SetDefaultPermissions(new NamespaceDefaultPermissions( + namespaceIndex, + metadata.DefaultAccessRestrictions, + metadata.DefaultRolePermissions, + metadata.DefaultUserRolePermissions)); + } + + /// + /// Reads the namespace default permissions of the node's namespace into the metadata. + /// + private void ReadDefaultPermissions( + ServerSystemContext systemContext, + NodeState target, + NodeMetadata metadata) { // check if NamespaceMetadata is defined for NamespaceUri string? namespaceUri = Server.NamespaceUris.GetString(target.NodeId.NamespaceIndex); diff --git a/src/Opc.Ua.Server/NodeManager/MasterNodeManager.cs b/src/Opc.Ua.Server/NodeManager/MasterNodeManager.cs index 5fd17181db..eefcd5a5e8 100644 --- a/src/Opc.Ua.Server/NodeManager/MasterNodeManager.cs +++ b/src/Opc.Ua.Server/NodeManager/MasterNodeManager.cs @@ -1613,7 +1613,8 @@ void ISyncNodeManagerMonitoredItemRecovery.RecoverDetachedMonitoredItems( IAsyncNodeManager nodeManager, IReadOnlyCollection nodeIds) { - if (!Server.IsRunning) + // only items that are detached or marked deleted are recovered. + if (!Server.IsRunning || !MonitoredItem.HasRecoverableItems) { return; } @@ -1684,7 +1685,8 @@ async ValueTask IDynamicNodeManagerHost.RecoverDetachedMonitoredItemsAsync( IReadOnlyCollection? nodeIds, CancellationToken cancellationToken) { - if (!Server.IsRunning) + // only items that are detached or marked deleted are recovered. + if (!Server.IsRunning || !MonitoredItem.HasRecoverableItems) { return; } diff --git a/src/Opc.Ua.Server/NodeManager/ResourceManager.cs b/src/Opc.Ua.Server/NodeManager/ResourceManager.cs index f5756d7962..af48e1167d 100644 --- a/src/Opc.Ua.Server/NodeManager/ResourceManager.cs +++ b/src/Opc.Ua.Server/NodeManager/ResourceManager.cs @@ -316,11 +316,14 @@ public void Add(StatusCode statusCode, string locale, string text) Add(key, locale, text); - m_statusCodeMapping ??= []; - if (string.IsNullOrEmpty(locale) || locale == "en-US") { - m_statusCodeMapping[statusCode] = new TranslationInfo(key, locale, text); + // copy on write: lookups read the map without the lock. + Dictionary mapping = m_statusCodeMapping != null + ? new Dictionary(m_statusCodeMapping) + : []; + mapping[statusCode] = new TranslationInfo(key, locale, text); + Volatile.Write(ref m_statusCodeMapping, mapping); } } } @@ -338,11 +341,14 @@ public void Add(XmlQualifiedName symbolicId, string locale, string text) Add(key, locale, text); - m_symbolicIdMapping ??= []; - if (string.IsNullOrEmpty(locale) || locale == "en-US") { - m_symbolicIdMapping[symbolicId] = new TranslationInfo(key, locale, text); + // copy on write: lookups read the map without the lock. + Dictionary mapping = m_symbolicIdMapping != null + ? new Dictionary(m_symbolicIdMapping) + : []; + mapping[symbolicId] = new TranslationInfo(key, locale, text); + Volatile.Write(ref m_symbolicIdMapping, mapping); } } } @@ -511,9 +517,11 @@ public TranslationTable(string locale) public string Language { get; } /// - /// The translations by key. + /// The translations by key. Keys are identifiers, compared ordinally: the + /// default string comparer is culture-sensitive and made every lookup of a + /// Browse display name cost up to a microsecond. /// - public SortedDictionary Translations { get; init; } = []; + public Dictionary Translations { get; init; } = new(StringComparer.Ordinal); } /// @@ -541,7 +549,9 @@ private TranslationTable[] GetTranslationSnapshot() TranslationTable table = m_translationTables[ii]; snapshot[ii] = new TranslationTable(table.Locale) { - Translations = new SortedDictionary(table.Translations) + Translations = new Dictionary( + table.Translations, + StringComparer.Ordinal) }; } Volatile.Write(ref m_translationSnapshot, snapshot); @@ -959,19 +969,18 @@ private LocalizedText TranslateStatusCode( object[] args, string? symbolicId = null) { - lock (m_lock) + // the map is replaced, never changed, so it is read without the lock: a session + // that requests diagnostics translates the status of every notification. + Dictionary? mapping = Volatile.Read(ref m_statusCodeMapping); + if (mapping != null && mapping.TryGetValue(statusCode.Code, out TranslationInfo info)) { - if (m_statusCodeMapping != null && - m_statusCodeMapping.TryGetValue(statusCode.Code, out TranslationInfo info)) + // merge the argument list with the translation info cached for the status code. + if (args != null) { - // merge the argument list with the translation info cached for the status code. - if (args != null) - { - info = new TranslationInfo(info.Key, info.Locale, info.Text, args); - } - - return Translate(preferredLocales, default, info); + info = new TranslationInfo(info.Key, info.Locale, info.Text, args); } + + return Translate(preferredLocales, default, info); } return LocalizedText.From(symbolicId ?? Utils.Format("{0:X8}", statusCode.Code)); @@ -987,21 +996,17 @@ private LocalizedText TranslateSymbolicId( StatusCode statusCode, object[] args) { - lock (m_lock) + Dictionary? mapping = Volatile.Read(ref m_symbolicIdMapping); + if (mapping != null && + mapping.TryGetValue(new XmlQualifiedName(symbolicId, namespaceUri), out TranslationInfo info)) { - if (m_symbolicIdMapping != null && - m_symbolicIdMapping.TryGetValue( - new XmlQualifiedName(symbolicId, namespaceUri), - out TranslationInfo info)) + // merge the argument list with the translation info cached for the symbolic id. + if (args != null) { - // merge the argument list with the translation info cached for the symbolic id. - if (args != null) - { - info = new TranslationInfo(info.Key, info.Locale, info.Text, args); - } - - return Translate(preferredLocales, default, info); + info = new TranslationInfo(info.Key, info.Locale, info.Text, args); } + + return Translate(preferredLocales, default, info); } if ((string.IsNullOrEmpty(namespaceUri) || namespaceUri == Ua.Namespaces.OpcUa) && diff --git a/src/Opc.Ua.Server/Server/OperationContext.cs b/src/Opc.Ua.Server/Server/OperationContext.cs index d9bf203a62..9f581059a5 100644 --- a/src/Opc.Ua.Server/Server/OperationContext.cs +++ b/src/Opc.Ua.Server/Server/OperationContext.cs @@ -434,9 +434,92 @@ public SystemContextCacheEntry(ServerSystemContext template, ServerSystemContext public ServerSystemContext Copy { get; } } + /// + /// Returns the namespace default permissions remembered for this service + /// request by . + /// + /// + /// Permission checks read the DefaultAccessRestrictions, DefaultRolePermissions + /// and DefaultUserRolePermissions of the node's namespace for every node of a + /// request, which took two server-wide locks and deep-copied the role permission + /// array per node. One read per namespace and request is enough. Contexts that + /// are not a service request (monitored item and session contexts, which can be + /// kept for a long time) remember nothing, so a changed default applies to them + /// at once. + /// + internal bool TryGetDefaultPermissions( + ushort namespaceIndex, + out NamespaceDefaultPermissions permissions) + { + NamespaceDefaultPermissions[]? entries = m_defaultPermissions; + if (entries != null) + { + foreach (NamespaceDefaultPermissions entry in entries) + { + if (entry.NamespaceIndex == namespaceIndex) + { + permissions = entry; + return true; + } + } + } + + permissions = default; + return false; + } + + /// + /// Remembers the namespace default permissions for the rest of this service request. + /// + internal void SetDefaultPermissions(NamespaceDefaultPermissions permissions) + { + if (RequestType == RequestType.Unknown) + { + return; + } + + // copy on write: node managers can validate nodes of one request concurrently, + // and a lost update only costs a second read. + NamespaceDefaultPermissions[]? entries = m_defaultPermissions; + if (entries == null) + { + m_defaultPermissions = [permissions]; + return; + } + + var updated = new NamespaceDefaultPermissions[entries.Length + 1]; + Array.Copy(entries, updated, entries.Length); + updated[entries.Length] = permissions; + m_defaultPermissions = updated; + } + private IDisposable? m_requestScope; private IDisposable? m_sessionlessLease; private SystemContextCacheEntry? m_systemContextCache; + private NamespaceDefaultPermissions[]? m_defaultPermissions; private static uint s_lastRequestId; } + + /// + /// The default permissions of a namespace, read from its NamespaceMetadata. + /// + internal readonly struct NamespaceDefaultPermissions + { + public NamespaceDefaultPermissions( + ushort namespaceIndex, + AccessRestrictionType defaultAccessRestrictions, + ArrayOf defaultRolePermissions, + ArrayOf defaultUserRolePermissions) + { + NamespaceIndex = namespaceIndex; + DefaultAccessRestrictions = defaultAccessRestrictions; + DefaultRolePermissions = defaultRolePermissions; + DefaultUserRolePermissions = defaultUserRolePermissions; + } + + public ushort NamespaceIndex { get; } + public AccessRestrictionType DefaultAccessRestrictions { get; } + public ArrayOf DefaultRolePermissions { get; } + public ArrayOf DefaultUserRolePermissions { get; } + } } diff --git a/src/Opc.Ua.Server/Subscription/MonitoredItem/MonitoredItem.cs b/src/Opc.Ua.Server/Subscription/MonitoredItem/MonitoredItem.cs index 0d3264fb72..664242eaa7 100644 --- a/src/Opc.Ua.Server/Subscription/MonitoredItem/MonitoredItem.cs +++ b/src/Opc.Ua.Server/Subscription/MonitoredItem/MonitoredItem.cs @@ -354,8 +354,10 @@ public MonitoredItem( m_readyToTrigger = MonitoringMode == MonitoringMode.Reporting; } - m_isDeleted = storedMonitoredItem.IsDeleted; - m_isDetached = storedMonitoredItem.IsDetached; + lock (m_lock) + { + SetDetachState(storedMonitoredItem.IsDetached, storedMonitoredItem.IsDeleted); + } } /// @@ -393,6 +395,50 @@ private void Initialize() /// public IAsyncNodeManager NodeManager { get; private set; } + /// + /// Whether any live monitored item in the process is detached or marked deleted. + /// + /// + /// Every node a NodeManager adds asks the subscriptions for detached items to + /// reattach, and a scan reads every monitored item of every subscription. Creating a + /// subscription adds its diagnostics nodes, so with many monitored items the scans made + /// subscription creation quadratic. Recovery is skipped while this is false. The + /// count is process-wide, so items of another server instance only cost a scan. + /// + internal static bool HasRecoverableItems => Interlocked.Read(ref s_recoverableCount) > 0; + + /// + /// Whether this item is included in . + /// + internal bool IsCountedAsRecoverable + { + get + { + lock (m_lock) + { + return m_countedAsRecoverable; + } + } + } + + /// + /// Sets the detached and deleted flags and keeps + /// in step. The caller holds m_lock. + /// + private void SetDetachState(bool isDetached, bool isDeleted) + { + m_isDetached = isDetached; + m_isDeleted = isDeleted; + + // a disposed item cannot be attached again, so it no longer counts. + bool recoverable = (isDetached || isDeleted) && !m_isDisposed; + if (recoverable != m_countedAsRecoverable) + { + m_countedAsRecoverable = recoverable; + Interlocked.Add(ref s_recoverableCount, recoverable ? 1 : -1); + } + } + /// bool IDetachableMonitoredItem.IsDetached { @@ -610,7 +656,7 @@ void IDetachableMonitoredItem.MarkNodeDeleted() { lock (m_lock) { - m_isDeleted = true; + SetDetachState(m_isDetached, isDeleted: true); QueueNodeIdUnknown(); } } @@ -620,7 +666,7 @@ void IDetachableMonitoredItem.BeginDetach() { lock (m_lock) { - m_isDetached = true; + SetDetachState(isDetached: true, m_isDeleted); } } @@ -638,7 +684,7 @@ void IDetachableMonitoredItem.Detach(IServerInternal server) { NodeManager = owner; ManagerHandle = DetachedHandle; - m_isDetached = true; + SetDetachState(isDetached: true, m_isDeleted); } } @@ -673,10 +719,7 @@ void IDetachableMonitoredItem.QueueNodeIdUnknown() { lock (m_lock) { - if (!m_isDeleted) - { - m_isDeleted = true; - } + SetDetachState(m_isDetached, isDeleted: true); QueueNodeIdUnknown(); } @@ -689,8 +732,7 @@ void IDetachableMonitoredItem.Rebind(IAsyncNodeManager nodeManager, object manag { NodeManager = nodeManager ?? throw new ArgumentNullException(nameof(nodeManager)); ManagerHandle = managerHandle; - m_isDetached = false; - m_isDeleted = false; + SetDetachState(isDetached: false, isDeleted: false); } } @@ -2678,6 +2720,7 @@ protected virtual void Dispose(bool disposing) } m_isDisposed = true; + SetDetachState(m_isDetached, m_isDeleted); // A NodeManager reload may be handing this item to its replacement right now. // Tearing the queues down underneath it would leave the replacement sampling a @@ -2753,6 +2796,8 @@ private void DisposeQueueHandlers() private RejectedSelectClauses? m_rejectedSelectClauses; private bool m_isDetached; private bool m_isDeleted; + private bool m_countedAsRecoverable; + private static long s_recoverableCount; } /// diff --git a/tests/Opc.Ua.Server.Tests/MonitoredItemRecoverableCountTests.cs b/tests/Opc.Ua.Server.Tests/MonitoredItemRecoverableCountTests.cs new file mode 100644 index 0000000000..5846178c05 --- /dev/null +++ b/tests/Opc.Ua.Server.Tests/MonitoredItemRecoverableCountTests.cs @@ -0,0 +1,201 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using Moq; +using NUnit.Framework; +using Opc.Ua.Tests; + +namespace Opc.Ua.Server.Tests +{ + /// + /// Tests that every detach state transition of a keeps + /// exact. Recovery of detached items is + /// skipped while no item is counted, so a missed increment would suppress reattachment + /// and a missed decrement would bring back the scans of every monitored item. + /// + /// + /// The count is process-wide and other tests run in parallel, so the tests assert the + /// per-item flag, and only that the count is positive while an item is counted. + /// + [TestFixture] + [Category("MonitoredItem")] + [SetCulture("en-us")] + [SetUICulture("en-us")] + [Parallelizable] + public class MonitoredItemRecoverableCountTests + { + [Test] + public void NewItemIsNotCounted() + { + using MonitoredItemQueueFactory queueFactory = CreateQueueFactory(out ITelemetryContext telemetry); + using MonitoredItem item = CreateItem(CreateServer(telemetry, queueFactory)); + + Assert.That(item.IsCountedAsRecoverable, Is.False); + } + + [Test] + public void DeletedItemIsCountedUntilRebound() + { + using MonitoredItemQueueFactory queueFactory = CreateQueueFactory(out ITelemetryContext telemetry); + using MonitoredItem item = CreateItem(CreateServer(telemetry, queueFactory)); + var lifecycle = (IDetachableMonitoredItem)item; + + lifecycle.MarkNodeDeleted(); + Assert.That(item.IsCountedAsRecoverable, Is.True); + Assert.That(MonitoredItem.HasRecoverableItems, Is.True); + + // a second transition into the same state does not count the item twice. + lifecycle.QueueNodeIdUnknown(); + Assert.That(item.IsCountedAsRecoverable, Is.True); + + lifecycle.Rebind(new Mock().Object, new object()); + Assert.That(item.IsCountedAsRecoverable, Is.False); + } + + [Test] + public void DetachedItemIsCountedUntilRebound() + { + using MonitoredItemQueueFactory queueFactory = CreateQueueFactory(out ITelemetryContext telemetry); + Mock server = CreateServer(telemetry, queueFactory); + server.Setup(s => s.CoreNodeManager).Returns(new Mock().Object); + using MonitoredItem item = CreateItem(server); + var lifecycle = (IDetachableMonitoredItem)item; + + lifecycle.BeginDetach(); + Assert.That(item.IsCountedAsRecoverable, Is.True); + Assert.That(MonitoredItem.HasRecoverableItems, Is.True); + + lifecycle.Detach(server.Object); + Assert.That(lifecycle.IsDetached, Is.True); + Assert.That(item.IsCountedAsRecoverable, Is.True); + + lifecycle.Rebind(new Mock().Object, new object()); + Assert.That(lifecycle.IsDetached, Is.False); + Assert.That(item.IsCountedAsRecoverable, Is.False); + } + + [Test] + public void DisposedItemIsNotCounted() + { + using MonitoredItemQueueFactory queueFactory = CreateQueueFactory(out ITelemetryContext telemetry); + MonitoredItem item = CreateItem(CreateServer(telemetry, queueFactory)); + var lifecycle = (IDetachableMonitoredItem)item; + + lifecycle.MarkNodeDeleted(); + Assert.That(item.IsCountedAsRecoverable, Is.True); + + item.Dispose(); + Assert.That(item.IsCountedAsRecoverable, Is.False); + + // a disposed item cannot be attached again, so later transitions do not count it. + lifecycle.MarkNodeDeleted(); + Assert.That(item.IsCountedAsRecoverable, Is.False); + } + + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(true, true)] + [TestCase(false, false)] + public void RestoredItemIsCountedWhenStoredDetachedOrDeleted(bool isDetached, bool isDeleted) + { + using MonitoredItemQueueFactory queueFactory = CreateQueueFactory(out ITelemetryContext telemetry); + var stored = new StoredMonitoredItem + { + Id = 2, + SubscriptionId = 1, + TypeMask = MonitoredItemTypeMask.DataChange, + MonitoringMode = MonitoringMode.Reporting, + NodeId = new NodeId(1), + AttributeId = Attributes.Value, + QueueSize = 10, + DiscardOldest = true, + SamplingInterval = 1000, + TimestampsToReturn = TimestampsToReturn.Both, + DiagnosticsMasks = DiagnosticsMasks.All, + FilterToUse = new MonitoringFilter(), + IndexRange = string.Empty, + ParsedIndexRange = NumericRange.Null, + LastValue = new DataValue(new Variant(0)), + IsDetached = isDetached, + IsDeleted = isDeleted + }; + + using var item = new MonitoredItem( + CreateServer(telemetry, queueFactory).Object, + new Mock().Object, + new object(), + stored, + null); + + Assert.That(item.IsCountedAsRecoverable, Is.EqualTo(isDetached || isDeleted)); + } + + private static MonitoredItemQueueFactory CreateQueueFactory(out ITelemetryContext telemetry) + { + telemetry = NUnitTelemetryContext.Create(); + return new MonitoredItemQueueFactory(telemetry); + } + + private static Mock CreateServer( + ITelemetryContext telemetry, + MonitoredItemQueueFactory queueFactory) + { + var server = new Mock(); + server.Setup(s => s.Telemetry).Returns(telemetry); + server.Setup(s => s.NamespaceUris).Returns(new NamespaceTable()); + server.Setup(s => s.TypeTree).Returns(new TypeTable(new NamespaceTable())); + server.Setup(s => s.MonitoredItemQueueFactory).Returns(queueFactory); + server.Setup(s => s.SubscriptionStore).Returns(new Mock().Object); + server.Setup(s => s.DiagnosticsNodeManager).Returns(new Mock().Object); + return server; + } + + private static MonitoredItem CreateItem(Mock server) + { + return new MonitoredItem( + server.Object, + new Mock().Object, + null!, + 1, + 2, + new ReadValueId { NodeId = new NodeId(1), AttributeId = Attributes.Value }, + DiagnosticsMasks.All, + TimestampsToReturn.Server, + MonitoringMode.Reporting, + 3, + null, + new MonitoringFilter(), + null, + 1000, + 10, + true, + 1000); + } + } +} diff --git a/tests/Opc.Ua.Server.Tests/OperationContextDefaultPermissionsCacheTests.cs b/tests/Opc.Ua.Server.Tests/OperationContextDefaultPermissionsCacheTests.cs new file mode 100644 index 0000000000..88a0cb738f --- /dev/null +++ b/tests/Opc.Ua.Server.Tests/OperationContextDefaultPermissionsCacheTests.cs @@ -0,0 +1,101 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using NUnit.Framework; + +namespace Opc.Ua.Server.Tests +{ + /// + /// Tests for the namespace default permissions remembered on + /// for the duration of one service request. + /// + [TestFixture] + [Category("OperationContext")] + [SetCulture("en-us")] + [SetUICulture("en-us")] + [Parallelizable] + public class OperationContextDefaultPermissionsCacheTests + { + /// + /// A service request remembers the defaults of each namespace separately. + /// + [Test] + public void ServiceRequestRemembersDefaultsPerNamespace() + { + using OperationContext operation = CreateOperation(RequestType.Read); + ArrayOf roles = new[] + { + new RolePermissionType + { + RoleId = ObjectIds.WellKnownRole_Anonymous, + Permissions = (uint)PermissionType.Browse + } + }.ToArrayOf(); + + Assert.That(operation.TryGetDefaultPermissions(2, out _), Is.False); + + operation.SetDefaultPermissions(new NamespaceDefaultPermissions( + 2, AccessRestrictionType.EncryptionRequired, roles, default)); + operation.SetDefaultPermissions(new NamespaceDefaultPermissions( + 3, AccessRestrictionType.None, default, default)); + + Assert.That(operation.TryGetDefaultPermissions(2, out NamespaceDefaultPermissions ns2), Is.True); + Assert.That(ns2.DefaultAccessRestrictions, Is.EqualTo(AccessRestrictionType.EncryptionRequired)); + Assert.That(ns2.DefaultRolePermissions.Count, Is.EqualTo(1)); + Assert.That(operation.TryGetDefaultPermissions(3, out NamespaceDefaultPermissions ns3), Is.True); + Assert.That(ns3.DefaultAccessRestrictions, Is.EqualTo(AccessRestrictionType.None)); + Assert.That(ns3.DefaultRolePermissions.IsNull, Is.True); + Assert.That(operation.TryGetDefaultPermissions(4, out _), Is.False); + } + + /// + /// A context that is not a service request, such as one kept for a monitored item, + /// remembers nothing, so a changed default applies to it on the next check. + /// + [Test] + public void NonRequestContextRemembersNothing() + { + using OperationContext operation = CreateOperation(RequestType.Unknown); + + operation.SetDefaultPermissions(new NamespaceDefaultPermissions( + 2, AccessRestrictionType.EncryptionRequired, default, default)); + + Assert.That(operation.TryGetDefaultPermissions(2, out _), Is.False); + } + + private static OperationContext CreateOperation(RequestType requestType) + { + return new OperationContext( + new RequestHeader(), + null, + requestType, + RequestLifetime.None); + } + } +} diff --git a/tests/Opc.Ua.Sessions.Tests/LoadTest.cs b/tests/Opc.Ua.Sessions.Tests/LoadTest.cs index cd746025d9..d9b571b980 100644 --- a/tests/Opc.Ua.Sessions.Tests/LoadTest.cs +++ b/tests/Opc.Ua.Sessions.Tests/LoadTest.cs @@ -78,8 +78,11 @@ public override Task OneTimeSetUpAsync() // fixture for the largest case: one secure channel and one subscription // per session plus head-room for the extra writer session. These are // caps, not pre-allocations, so a small case pays nothing for the head-room. - MaxChannelCount = 10100; + // Resource isolation reserves a bootstrap and a reconnect channel and + // requires one more shared channel than sessions, so the channel cap + // needs the same +3 margin as the framework defaults (100 / 103). MaxSessionCount = 10100; + MaxChannelCount = MaxSessionCount + 3; MaxSubscriptionCount = 10100; // Each session keeps one long-polled Publish request outstanding. With @@ -152,9 +155,7 @@ public async Task ServerSubscribeLoadTestAsync(bool useManagedSession) var sessions = new ConcurrentBag(); var subscriptions = new ConcurrentBag(); - var valueChanges = new ConcurrentDictionary(); var monitoredItems = new ConcurrentDictionary(); - var clientHandles = new ConcurrentDictionary(); using var connectCts = new CancellationTokenSource(TimeSpan.FromSeconds(testDurationSeconds)); // Resolve the endpoint once so ManagedSession creation does not @@ -178,6 +179,7 @@ public async Task ServerSubscribeLoadTestAsync(bool useManagedSession) } TestContext.Out.WriteLine($"Subscribing to {nodeIds.Count} nodes."); + var valueChanges = new NodeChangeCounters(nodeIds.Keys); // Create reader sessions and subscriptions in parallel var createSessionTasks = new List(); @@ -197,7 +199,7 @@ public async Task ServerSubscribeLoadTestAsync(bool useManagedSession) session = managedSession; sessions.Add(session); - var handler = new LoadTestDataChangeHandler(valueChanges, clientHandles); + var handler = new LoadTestDataChangeHandler(valueChanges); for (int j = 0; j < subscriptionsPerSession; j++) { @@ -211,7 +213,6 @@ public async Task ServerSubscribeLoadTestAsync(bool useManagedSession) foreach (NodeId nodeId in nodeIds.Keys) { - valueChanges.TryAdd(nodeId, 0); subscription.TryAddMonitoredItem( nodeId.ToString(), nodeId, @@ -226,7 +227,7 @@ public async Task ServerSubscribeLoadTestAsync(bool useManagedSession) if (item != null) { - clientHandles.TryAdd(item.ClientHandle, nodeId); + valueChanges.Register(item.ClientHandle, nodeId); } } } @@ -254,8 +255,7 @@ public async Task ServerSubscribeLoadTestAsync(bool useManagedSession) MonitoringMode = MonitoringMode.Reporting, SamplingInterval = 0 }; - valueChanges.TryAdd(nodeId, 0); - clientHandles.TryAdd(item.ClientHandle, nodeId); + valueChanges.Register(item.ClientHandle, nodeId); monitoredItems.TryAdd(item.ClientHandle, item); subscription.AddItem(item); @@ -273,10 +273,7 @@ public async Task ServerSubscribeLoadTestAsync(bool useManagedSession) dv.DiagnosticInfo.LocalizedText); } - valueChanges.AddOrUpdate( - clientHandles[dv.ClientHandle], - 1, - (key, count) => count + 1); + valueChanges.Increment(dv.ClientHandle); } }; @@ -349,15 +346,26 @@ public async Task ServerSubscribeLoadTestAsync(bool useManagedSession) /* expected */ } - // Wait for server to process last write (testDurationSeconds / writeCount -> time for a single write) - // + some publishing intervals for notifications to be processed - await Task.Delay((testDurationSeconds / writeCount) + (publishingInterval * 50)).ConfigureAwait(false); + // Wait until the notifications of the last writes have been delivered: the + // received count stops growing for 10 publishing intervals. A fixed wait counted + // notifications that were still queued under load as lost. + var drain = System.Diagnostics.Stopwatch.StartNew(); + long lastReceived = -1; + int stableIntervals = 0; + while (stableIntervals < 10 && drain.Elapsed < TimeSpan.FromSeconds(testDurationSeconds)) + { + await Task.Delay(publishingInterval).ConfigureAwait(false); + long received = valueChanges.Total; + stableIntervals = received == lastReceived ? stableIntervals + 1 : 0; + lastReceived = received; + } + TestContext.Out.WriteLine($"Notifications drained {drain.Elapsed.TotalSeconds:F1} s after the last write."); // Verification TestContext.Out.WriteLine($"Writer task wrote {writeCount} times."); const int totalNotifications = sessionCount * subscriptionsPerSession; int expectedNotifications = (writeCount + 1) * totalNotifications * nodeIds.Count; - int receivedNotifications = valueChanges.Values.Sum(); + long receivedNotifications = valueChanges.Total; TestContext.Out.WriteLine($"Expected notifications multiplier per node: {totalNotifications}"); TestContext.Out.WriteLine($"Total expected notifications: {expectedNotifications}"); @@ -366,7 +374,8 @@ public async Task ServerSubscribeLoadTestAsync(bool useManagedSession) bool allNodesReceivedChanges = true; foreach (NodeId nodeId in nodeIds.Keys) { - if (valueChanges.TryGetValue(nodeId, out int changes)) + long changes = valueChanges.GetCount(nodeId); + if (changes > 0) { TestContext.Out.WriteLine($"Node {nodeId} received {changes} changes."); if (changes < writeCount) @@ -754,10 +763,19 @@ public async Task ServerEventSubscribeLoadTestAsync() TestContext.Out.WriteLine($"Generated {eventCount} events in {sw.ElapsedMilliseconds} ms " + $"({eventCount / sw.Elapsed.TotalSeconds:F0} events/sec)."); - // Wait for subscriptions to deliver the events. - // Allow enough publishing intervals for all notifications to be sent and acknowledged. - const int publishingIntervalsToWait = 20; - await Task.Delay(publishingInterval * publishingIntervalsToWait).ConfigureAwait(false); + // Wait until the subscriptions have delivered the queued events: the received + // count stops growing for 10 publishing intervals. + var drain = System.Diagnostics.Stopwatch.StartNew(); + long lastReceived = -1; + int stableIntervals = 0; + while (stableIntervals < 10 && drain.Elapsed < TimeSpan.FromSeconds(testDurationSeconds * 2)) + { + await Task.Delay(publishingInterval).ConfigureAwait(false); + long receivedSoFar = Interlocked.Read(ref eventsReceived); + stableIntervals = receivedSoFar == lastReceived ? stableIntervals + 1 : 0; + lastReceived = receivedSoFar; + } + TestContext.Out.WriteLine($"Events drained {drain.Elapsed.TotalSeconds:F1} s after the last event."); long expectedTotal = (long)eventCount * totalSubscriptions; long received = Interlocked.Read(ref eventsReceived); @@ -1160,22 +1178,87 @@ private static void AppendBenchmarkResult(string line) } } + /// + /// Counts the data changes received per node from every subscription. + /// + /// + /// The counters are updated for every notification of 750 subscriptions, so they + /// are plain array slots updated with interlocked increments: a shared + /// counter per node made the + /// callbacks contend on its bucket locks, which slowed the client down enough to + /// lose notifications. + /// + private sealed class NodeChangeCounters + { + public NodeChangeCounters(IEnumerable nodeIds) + { + foreach (NodeId nodeId in nodeIds) + { + m_indexByNode[nodeId] = m_indexByNode.Count; + } + m_counts = new long[m_indexByNode.Count]; + } + + /// + /// Remembers which node a monitored item's client handle belongs to. + /// + public void Register(uint clientHandle, NodeId nodeId) + { + m_indexByHandle[clientHandle] = m_indexByNode[nodeId]; + } + + /// + /// Counts a data change of the monitored item with the client handle. + /// + public void Increment(uint clientHandle) + { + if (m_indexByHandle.TryGetValue(clientHandle, out int index)) + { + Interlocked.Increment(ref m_counts[index]); + } + } + + /// + /// The data changes received for a node. + /// + public long GetCount(NodeId nodeId) + { + return Interlocked.Read(ref m_counts[m_indexByNode[nodeId]]); + } + + /// + /// The data changes received for all nodes. + /// + public long Total + { + get + { + long total = 0; + for (int ii = 0; ii < m_counts.Length; ii++) + { + total += Interlocked.Read(ref m_counts[ii]); + } + return total; + } + } + + private readonly Dictionary m_indexByNode = []; + private readonly ConcurrentDictionary m_indexByHandle = new(); + private readonly long[] m_counts; + } + /// /// Notification handler for the V2 SubscriptionManager path in load tests. - /// Routes each back to the shared - /// counter dict via . + /// Counts each in the shared + /// . /// private sealed class LoadTestDataChangeHandler : Client.Subscriptions.ISubscriptionNotificationHandler { - private readonly ConcurrentDictionary m_valueChanges; - private readonly ConcurrentDictionary m_clientHandles; + private readonly NodeChangeCounters m_valueChanges; - public LoadTestDataChangeHandler( - ConcurrentDictionary valueChanges, - ConcurrentDictionary clientHandles) + public LoadTestDataChangeHandler(NodeChangeCounters valueChanges) { m_valueChanges = valueChanges; - m_clientHandles = clientHandles; } public ValueTask OnDataChangeNotificationAsync( @@ -1199,10 +1282,9 @@ public ValueTask OnDataChangeNotificationAsync( change.DiagnosticInfo.LocalizedText); } - if (change.MonitoredItem != null && - m_clientHandles.TryGetValue(change.MonitoredItem.ClientHandle, out NodeId nodeId)) + if (change.MonitoredItem != null) { - m_valueChanges.AddOrUpdate(nodeId, 1, (_, count) => count + 1); + m_valueChanges.Increment(change.MonitoredItem.ClientHandle); } } return default;