/* ======================================================================== * 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 System; using System.Collections.Concurrent; using System.Linq; using System.Threading; using System.Threading.Tasks; using Moq; using NUnit.Framework; namespace Opc.Ua.Server.Tests.NodeManager { /// /// Verifies per-item event ordering without serializing independent monitored-item permission checks. /// [TestFixture] [Category("MonitoredNode")] public sealed class EventDeliveryOrderRegressionTests { /// /// Verifies that a blocked event item retains enqueue order while another item continues delivery /// independently. /// [TestCase(false)] [TestCase(true)] public async Task ParallelEventItemsKeepEnqueueOrderWhileIndependentItemsProgressAsync(bool serverNode) { var node = new BaseObjectState(null) { NodeId = serverNode ? ObjectIds.Server : new NodeId(100, 1) }; var server = new Mock(); var manager = new Mock(); IFilterTarget first = Mock.Of(); IFilterTarget second = Mock.Of(); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var independentFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var secondEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var finished = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); int blocked = 0; int delivered = 0; manager.Setup(value => value.ValidateEventRolePermissionsAsync( It.IsAny(), It.IsAny(), It.IsAny())) .Returns(async (IEventMonitoredItem _, IFilterTarget target, CancellationToken ct) => { if (ReferenceEquals(target, first) && Interlocked.CompareExchange(ref blocked, 1, 0) == 0) { entered.TrySetResult(true); await release.Task.WaitAsync(ct).ConfigureAwait(false); } return ServiceResult.Good; }); using var monitored = new MonitoredNode2(manager.Object, server.Object, node, !serverNode); var queues = new ConcurrentQueue[2]; for (int i = 0; i < queues.Length; i++) { var queue = new ConcurrentQueue(); queues[i] = queue; var item = new Mock(); item.SetupGet(value => value.Id).Returns((uint)i + 1); item.Setup(value => value.QueueEvent(It.IsAny())) .Callback(target => { int value = ReferenceEquals(target, first) ? 1 : 2; queue.Enqueue(value); if (value == 1 && !release.Task.IsCompleted) { independentFirst.TrySetResult(true); } if (value == 2) { secondEntered.TrySetResult(true); } if (Interlocked.Increment(ref delivered) == 4) { finished.TrySetResult(true); } }); monitored.Add(item.Object); } ISystemContext context = Mock.Of(); await monitored.OnReportEventAsync(context, node, first).ConfigureAwait(false); bool independentProgress = false; try { await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); await monitored.OnReportEventAsync(context, node, second).ConfigureAwait(false); await Task.WhenAny(independentFirst.Task, secondEntered.Task) .WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); independentProgress = independentFirst.Task.IsCompleted; } finally { release.TrySetResult(true); } await finished.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); Assert.Multiple(() => { Assert.That(independentProgress, Is.True); Assert.That(queues[0].ToArray(), Is.EqualTo(s_order)); Assert.That(queues[1].ToArray(), Is.EqualTo(s_order)); }); } /// /// Every item gets the events of a notifier in the order they were reported when /// the items of each event are queued in ranges on several threads, also when some /// permission checks complete asynchronously. /// [Test] public async Task SplitEventsKeepTheOrderOfEveryItemAsync([Values(false, true)] bool asyncPermissions) { const int itemCount = 200; const int eventCount = 50; var node = new BaseObjectState(null) { NodeId = ObjectIds.Server }; IFilterTarget[] events = CreateEvents(eventCount); var manager = new Mock(); manager.Setup(value => value.ValidateEventRolePermissionsAsync( It.IsAny(), It.IsAny(), It.IsAny())) .Returns(async (IEventMonitoredItem item, IFilterTarget target, CancellationToken ct) => { int index = Array.IndexOf(events, target); if (asyncPermissions && (item.Id + index) % 5 == 0) { // a slow check overtaken by the next event shows as disorder. if (index % 2 == 0) { await Task.Delay(2, ct).ConfigureAwait(false); } else { await Task.Yield(); } } return ServiceResult.Good; }); using var monitored = new MonitoredNode2(manager.Object, new Mock().Object, node) { ParallelEventDeliveryBudget = -1, MinItemsPerEventDeliveryRange = 8 }; ConcurrentQueue[] queues = AddRecordingItems(monitored, itemCount, events, null); ISystemContext context = Mock.Of(); foreach (IFilterTarget target in events) { await monitored.OnReportEventAsync(context, node, target).ConfigureAwait(false); } await monitored.WaitForPendingNotificationsAsync(CancellationToken.None) .AsTask().WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false); int[] expected = [.. Enumerable.Range(0, eventCount)]; for (int ii = 0; ii < queues.Length; ii++) { Assert.That(queues[ii].ToArray(), Is.EqualTo(expected), $"item {ii + 1}"); } } /// /// The ranges of an event are queued at the same time on different threads. /// [Test] public async Task SplitEventIsQueuedOnSeveralThreadsAtOnceAsync() { Assume.That(MonitoredNode2.MaxEventDeliveryRanges, Is.GreaterThan(1), "needs two cores"); var node = new BaseObjectState(null) { NodeId = ObjectIds.Server }; IFilterTarget[] events = CreateEvents(1); var manager = new Mock(); manager.Setup(value => value.ValidateEventRolePermissionsAsync( It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new ValueTask(ServiceResult.Good)); using var monitored = new MonitoredNode2(manager.Object, new Mock().Object, node) { ParallelEventDeliveryBudget = -1, MinItemsPerEventDeliveryRange = 16 }; // the second item queued waits inside QueueEvent until another item is queued // at the same time, which only a delivery on another thread can do. int calls = 0; int inside = 0; int mostInside = 0; bool concurrent = false; AddRecordingItems(monitored, 64, events, () => { int now = Interlocked.Increment(ref inside); InterlockedMax(ref mostInside, now); try { if (Interlocked.Increment(ref calls) == 2) { concurrent = SpinWait.SpinUntil( () => Volatile.Read(ref mostInside) > 1, TimeSpan.FromSeconds(10)); } } finally { Interlocked.Decrement(ref inside); } }); await monitored.OnReportEventAsync(Mock.Of(), node, events[0]).ConfigureAwait(false); await monitored.WaitForPendingNotificationsAsync(CancellationToken.None) .AsTask().WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false); Assert.That(calls, Is.EqualTo(64)); Assert.That(concurrent, Is.True); } /// /// An item whose delivery waits for its permission check gets the next event only /// after the previous one, while the other items of the split events go on. /// [Test] public async Task ItemGetsTheNextEventOnlyAfterThePreviousOneAsync() { const int itemCount = 100; const uint blockedItem = 50; var node = new BaseObjectState(null) { NodeId = ObjectIds.Server }; IFilterTarget[] events = CreateEvents(2); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var manager = new Mock(); manager.Setup(value => value.ValidateEventRolePermissionsAsync( It.IsAny(), It.IsAny(), It.IsAny())) .Returns(async (IEventMonitoredItem item, IFilterTarget target, CancellationToken ct) => { if (item.Id == blockedItem && ReferenceEquals(target, events[0])) { entered.TrySetResult(true); await release.Task.WaitAsync(ct).ConfigureAwait(false); } return ServiceResult.Good; }); using var monitored = new MonitoredNode2(manager.Object, new Mock().Object, node) { ParallelEventDeliveryBudget = -1, MinItemsPerEventDeliveryRange = 8 }; ConcurrentQueue[] queues = AddRecordingItems(monitored, itemCount, events, null); ISystemContext context = Mock.Of(); int[][] beforeRelease; try { await monitored.OnReportEventAsync(context, node, events[0]).ConfigureAwait(false); await monitored.OnReportEventAsync(context, node, events[1]).ConfigureAwait(false); await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false); bool othersQueued = SpinWait.SpinUntil( () => queues.Sum(queue => queue.Count) >= itemCount - 1, TimeSpan.FromSeconds(10)); Assert.That(othersQueued, Is.True, "the other items got the first event"); // give a wrongly started second event time to arrive. await Task.Delay(100).ConfigureAwait(false); beforeRelease = [.. queues.Select(queue => queue.ToArray())]; } finally { release.TrySetResult(true); } await monitored.WaitForPendingNotificationsAsync(CancellationToken.None) .AsTask().WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false); // the second event may have reached the other items, the blocked item gets // nothing before its first event. Assert.That(beforeRelease[blockedItem - 1], Is.Empty, "blocked item before release"); for (int ii = 0; ii < itemCount; ii++) { if (ii + 1 != blockedItem) { Assert.That( beforeRelease[ii], Is.EqualTo(s_firstEvent).Or.EqualTo(s_twoEvents), $"item {ii + 1} before release"); } Assert.That(queues[ii].ToArray(), Is.EqualTo(s_twoEvents), $"item {ii + 1}"); } } /// /// Events that wait behind each other for different items, audit events that only /// reach the items of secure sessions between other events, are queued to each item /// in order and none is lost. /// [Test] public async Task WaitingEventsForDifferentItemsKeepTheOrderOfEveryItemAsync() { const int itemCount = 100; const int eventCount = 30; var node = new BaseObjectState(null) { NodeId = ObjectIds.Server }; var events = new IFilterTarget[eventCount]; for (int ii = 0; ii < eventCount; ii++) { // a snapshot is reported as it is, a node state would be cloned. events[ii] = ii % 3 == 1 ? new InstanceStateSnapshot { Handle = new AuditEventState(null) } : new Mock().Object; } var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var manager = new Mock(); manager.Setup(value => value.ValidateEventRolePermissionsAsync( It.IsAny(), It.IsAny(), It.IsAny())) .Returns(async (IEventMonitoredItem item, IFilterTarget target, CancellationToken ct) => { // holds the first event until the next ones wait in the channel. if (item.Id == 1 && ReferenceEquals(target, events[0])) { await release.Task.WaitAsync(ct).ConfigureAwait(false); } return ServiceResult.Good; }); var server = new Mock(); server.SetupGet(value => value.Auditing).Returns(true); using var monitored = new MonitoredNode2(manager.Object, server.Object, node) { ParallelEventDeliveryBudget = -1, MinItemsPerEventDeliveryRange = 8 }; var secureSession = new Mock(); secureSession.SetupGet(value => value.EndpointDescription) .Returns(new EndpointDescription { SecurityMode = MessageSecurityMode.SignAndEncrypt }); var queues = new ConcurrentQueue[itemCount]; for (int ii = 0; ii < itemCount; ii++) { var queue = new ConcurrentQueue(); queues[ii] = queue; var item = new Mock(); item.SetupGet(value => value.Id).Returns((uint)ii + 1); if (ii % 2 == 0) { item.SetupGet(value => value.Session).Returns(secureSession.Object); } item.Setup(value => value.QueueEvent(It.IsAny())) .Callback(target => queue.Enqueue(Array.IndexOf(events, target))); monitored.Add(item.Object); } ISystemContext context = Mock.Of(); await monitored.OnReportEventAsync(context, node, events[0]).ConfigureAwait(false); await monitored.OnReportEventAsync(context, node, events[1]).ConfigureAwait(false); await monitored.OnReportEventAsync(context, node, events[2]).ConfigureAwait(false); release.TrySetResult(true); for (int ii = 3; ii < eventCount; ii++) { await monitored.OnReportEventAsync(context, node, events[ii]).ConfigureAwait(false); } await monitored.WaitForPendingNotificationsAsync(CancellationToken.None) .AsTask().WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false); int[] all = [.. Enumerable.Range(0, eventCount)]; int[] withoutAudit = [.. all.Where(index => index % 3 != 1)]; for (int ii = 0; ii < itemCount; ii++) { Assert.That( queues[ii].ToArray(), Is.EqualTo(ii % 2 == 0 ? all : withoutAudit), $"item {ii + 1}"); } } /// /// When the budget of a consumer that keeps up runs out, the items it has not /// queued yet are split, while the delivery to an item it queued itself still /// waits for its permission check. The next event and a notification fence wait for /// that delivery as well as for the ranges, and every item gets every event once, /// in order. /// [Test] public async Task BudgetHandoffWaitsForTheItemsQueuedBeforeTheSplitAsync() { const int itemCount = 100; var node = new BaseObjectState(null) { NodeId = ObjectIds.Server }; IFilterTarget[] events = CreateEvents(2); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); int blocked = 0; var manager = new Mock(); manager.Setup(value => value.ValidateEventRolePermissionsAsync( It.IsAny(), It.IsAny(), It.IsAny())) .Returns(async (IEventMonitoredItem item, IFilterTarget target, CancellationToken ct) => { // the first delivery of the first event is queued by the consumer itself. if (ReferenceEquals(target, events[0]) && Interlocked.Exchange(ref blocked, 1) == 0) { entered.TrySetResult(item.Id); await release.Task.WaitAsync(ct).ConfigureAwait(false); } return ServiceResult.Good; }); // one tick per reading at a thousand ticks per second: the budget of a // millisecond runs out after the second item the consumer queued itself. var clock = new SteppingTimeProvider(); using var monitored = new MonitoredNode2( manager.Object, new Mock().Object, node, false, clock) { MinItemsPerEventDeliveryRange = 8 }; ConcurrentQueue[] queues = AddRecordingItems(monitored, itemCount, events, null); ISystemContext context = Mock.Of(); Task fence; int[][] beforeRelease; uint blockedItem; long readings; try { // no other notification waits, so the consumer starts on its own. await monitored.OnReportEventAsync(context, node, events[0]).ConfigureAwait(false); blockedItem = await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false); bool othersQueued = SpinWait.SpinUntil( () => queues.Sum(queue => queue.Count) >= itemCount - 1, TimeSpan.FromSeconds(10)); Assert.That(othersQueued, Is.True, "the other items got the first event"); readings = clock.Readings; await monitored.OnReportEventAsync(context, node, events[1]).ConfigureAwait(false); fence = monitored.WaitForPendingNotificationsAsync(CancellationToken.None).AsTask(); // give a wrongly started second event or fence time to complete. await Task.Delay(100).ConfigureAwait(false); beforeRelease = [.. queues.Select(queue => queue.ToArray())]; Assert.That(fence.IsCompleted, Is.False, "the fence waits for the blocked delivery"); } finally { release.TrySetResult(true); } await fence.WaitAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false); // the consumer read the clock once to start and once per item it queued // itself, so it split the event after a few items instead of queueing all. Assert.That(readings, Is.LessThan(10), "split after the budget ran out"); for (int ii = 0; ii < itemCount; ii++) { int[] expectedBefore = ii + 1 == blockedItem ? [] : s_firstEvent; Assert.That(beforeRelease[ii], Is.EqualTo(expectedBefore), $"item {ii + 1} before release"); Assert.That(queues[ii].ToArray(), Is.EqualTo(s_twoEvents), $"item {ii + 1}"); } } /// /// A clock that advances one tick, a millisecond, each time it is read. /// private sealed class SteppingTimeProvider : TimeProvider { public long Readings => Interlocked.Read(ref m_ticks); public override long TimestampFrequency => 1000; public override long GetTimestamp() { return Interlocked.Increment(ref m_ticks); } private long m_ticks; } private static IFilterTarget[] CreateEvents(int count) { var events = new IFilterTarget[count]; for (int ii = 0; ii < count; ii++) { events[ii] = new Mock().Object; } return events; } /// /// Adds items with ids 1 to that record the index of each /// event queued to them. /// private static ConcurrentQueue[] AddRecordingItems( MonitoredNode2 monitored, int count, IFilterTarget[] events, Action? onQueued) { var queues = new ConcurrentQueue[count]; for (int ii = 0; ii < count; ii++) { var queue = new ConcurrentQueue(); queues[ii] = queue; var item = new Mock(); item.SetupGet(value => value.Id).Returns((uint)ii + 1); item.Setup(value => value.QueueEvent(It.IsAny())) .Callback(target => { queue.Enqueue(Array.IndexOf(events, target)); onQueued?.Invoke(); }); monitored.Add(item.Object); } return queues; } private static void InterlockedMax(ref int location, int value) { int current = Volatile.Read(ref location); while (value > current) { int previous = Interlocked.CompareExchange(ref location, value, current); if (previous == current) { return; } current = previous; } } /// /// Defines the event order that each monitored item must preserve independently. /// private static readonly int[] s_order = [1, 2]; private static readonly int[] s_firstEvent = [0]; private static readonly int[] s_twoEvents = [0, 1]; } }