/* ======================================================================== * 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.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using NUnit.Framework; using Opc.Ua.Client; using Opc.Ua.Client.Subscriptions; using Opc.Ua.Client.Subscriptions.MonitoredItems; using Opc.Ua.Client.TestFramework; using V2SubscriptionOptions = Opc.Ua.Client.Subscriptions.SubscriptionOptions; namespace Opc.Ua.Subscriptions.Tests { /// /// Integration tests for the V2 unbounded-item mode of /// against the in-process /// reference server. The reference server's default /// MaxMonitoredItemsPerSubscription is large, so these tests /// drive partition splitting by setting /// /// to a small value rather than reconfiguring the server. /// [TestFixture] [Category("Client")] [Category("V2")] [Category("LogicalSubscription")] [SetCulture("en-us")] [SetUICulture("en-us")] public class UnboundedSubscriptionTests : ClientTestFramework { [OneTimeSetUp] public override Task OneTimeSetUpAsync() { SupportsExternalServerUrl = true; SingleSession = false; return OneTimeSetUpCoreAsync(securityNone: true); } [OneTimeTearDown] public override Task OneTimeTearDownAsync() { return base.OneTimeTearDownAsync(); } [SetUp] public override Task SetUpAsync() { return base.SetUpAsync(); } [TearDown] public override Task TearDownAsync() { return base.TearDownAsync(); } [Test] [Order(100)] [CancelAfter(120_000)] public async Task ItemsOverflowSplitIntoMultiplePartitionsAsync( CancellationToken ct) { ManagedSession session = await ConnectV2Async( nameof(ItemsOverflowSplitIntoMultiplePartitionsAsync), ct) .ConfigureAwait(false); try { var handler = new RecordingSubscriptionHandler(); const uint perPartitionCap = 25; const int totalItems = 100; ISubscription subscription = session.AddSubscription(handler, new V2SubscriptionOptions { PublishingInterval = TimeSpan.FromMilliseconds(500), KeepAliveCount = 10, LifetimeCount = 100, PublishingEnabled = true, Priority = 0, MaxMonitoredItemsPerPartition = perPartitionCap }); Assert.That(subscription, Is.InstanceOf()); Assert.That(subscription, Is.InstanceOf()); bool created = await WaitForAsync(() => subscription.Created, TimeSpan.FromSeconds(15), ct).ConfigureAwait(false); Assert.That(created, Is.True); // Add `totalItems` items all monitoring the same // server variable but with unique composite-level // names so they coexist. With per-partition cap of // `perPartitionCap`, the engine must mint // ceil(totalItems / perPartitionCap) partitions. NodeId timeNode = VariableIds.Server_ServerStatus_CurrentTime; for (int i = 0; i < totalItems; i++) { Assert.That( subscription.TryAddMonitoredItem( $"unbounded_{i}", timeNode, o => o with { SamplingInterval = TimeSpan.FromMilliseconds(500) }, out IMonitoredItem? _), Is.True, $"item {i} should add when the engine partitions transparently"); } Assert.That(subscription.MonitoredItems.Count, Is.EqualTo((uint)totalItems)); const int expectedPartitions = (totalItems + (int)perPartitionCap - 1) / (int)perPartitionCap; var partitioned = (IPartitionedSubscription)subscription; Assert.That(partitioned.PartitionCount, Is.EqualTo(expectedPartitions), "engine must mint exactly ceil(totalItems / cap) partitions"); Assert.That(partitioned.PartitionIds, Has.Count.EqualTo(expectedPartitions)); // Wait until every partition becomes Created. bool allCreated = await WaitForAsync( () => subscription.Created, TimeSpan.FromSeconds(30), ct).ConfigureAwait(false); Assert.That(allCreated, Is.True, "all partitions must reach Created"); // Notifications must flow. bool gotData = await handler.WaitForFirstDataAsync( TimeSpan.FromSeconds(15), ct).ConfigureAwait(false); Assert.That(gotData, Is.True); // Items in different partitions carry the same names // but distinct ClientHandles — verify global handle // uniqueness and that each item belongs to exactly // one partition's collection. IMonitoredItem[] allItems = [.. subscription.MonitoredItems.Items]; Assert.That(allItems, Has.Length.EqualTo(totalItems)); Assert.That(allItems.Select(i => i.ClientHandle).Distinct().ToArray(), Has.Length.EqualTo(totalItems), "ClientHandle must be globally unique across partitions"); await subscription.DisposeAsync().ConfigureAwait(false); } finally { try { await session.CloseAsync(ct: CancellationToken.None).ConfigureAwait(false); } catch { /* best effort */ } try { await session.DisposeAsync().ConfigureAwait(false); } catch { /* best effort */ } } } [Test] [Order(200)] [CancelAfter(120_000)] public async Task StrictAffinityPinsItemsToSamePartitionAsync( CancellationToken ct) { ManagedSession session = await ConnectV2Async( nameof(StrictAffinityPinsItemsToSamePartitionAsync), ct) .ConfigureAwait(false); try { var handler = new RecordingSubscriptionHandler(); ISubscription subscription = session.AddSubscription(handler, new V2SubscriptionOptions { PublishingInterval = TimeSpan.FromMilliseconds(500), KeepAliveCount = 10, LifetimeCount = 100, PublishingEnabled = true, Priority = 0, MaxMonitoredItemsPerPartition = 5 }); Assert.That(await WaitForAsync(() => subscription.Created, TimeSpan.FromSeconds(10), ct).ConfigureAwait(false), Is.True); NodeId timeNode = VariableIds.Server_ServerStatus_CurrentTime; // Fill the primary with no-affinity items to force a // secondary on the first tagged add. for (int i = 0; i < 5; i++) { Assert.That(subscription.TryAddMonitoredItem( $"primary_{i}", timeNode, o => o, out IMonitoredItem? _), Is.True); } // First tagged item — picks any partition with capacity // (the secondary, since primary is full). Pin GROUP-A // to that partition. Assert.That(subscription.TryAddMonitoredItem( "tagged_0", timeNode, o => o with { Affinity = "GROUP-A" }, out IMonitoredItem? first), Is.True); // 4 more tagged items — must land in the same partition. for (int i = 1; i < 5; i++) { Assert.That(subscription.TryAddMonitoredItem( $"tagged_{i}", timeNode, o => o with { Affinity = "GROUP-A" }, out IMonitoredItem? _), Is.True, $"affinity-tagged item {i} should fit in the pinned partition"); } // 6th tagged item — pinned partition is now full, // strict affinity contract REJECTS rather than splits. Assert.That(subscription.TryAddMonitoredItem( "tagged_5", timeNode, o => o with { Affinity = "GROUP-A" }, out IMonitoredItem? overflow), Is.False, "strict affinity must reject the 6th tagged item"); Assert.That(overflow, Is.Null); // A different affinity group still works. Assert.That(subscription.TryAddMonitoredItem( "groupB_0", timeNode, o => o with { Affinity = "GROUP-B" }, out IMonitoredItem? _), Is.True); await subscription.DisposeAsync().ConfigureAwait(false); } finally { try { await session.CloseAsync(ct: CancellationToken.None).ConfigureAwait(false); } catch { /* best effort */ } try { await session.DisposeAsync().ConfigureAwait(false); } catch { /* best effort */ } } } [Test] [Order(300)] [CancelAfter(120_000)] public async Task SaveLoadRoundTripsAllPartitionsAsync( CancellationToken ct) { // Capture phase — add enough items to force at least 3 // partitions, snapshot every partition via // SnapshotAllPartitions(), close the session. ManagedSession originSession = await ConnectV2Async( nameof(SaveLoadRoundTripsAllPartitionsAsync) + "_origin", ct) .ConfigureAwait(false); const uint perPartitionCap = 10; const int totalItems = 30; IReadOnlyList savedSnapshots; try { var originHandler = new RecordingSubscriptionHandler(); ISubscription origin = originSession.AddSubscription(originHandler, new V2SubscriptionOptions { PublishingInterval = TimeSpan.FromMilliseconds(500), KeepAliveCount = 10, LifetimeCount = 100, PublishingEnabled = true, Priority = 0, MaxMonitoredItemsPerPartition = perPartitionCap }); Assert.That(await WaitForAsync(() => origin.Created, TimeSpan.FromSeconds(15), ct).ConfigureAwait(false), Is.True); NodeId timeNode = VariableIds.Server_ServerStatus_CurrentTime; for (int i = 0; i < totalItems; i++) { Assert.That(origin.TryAddMonitoredItem( $"saveload_{i}", timeNode, o => o with { SamplingInterval = TimeSpan.FromMilliseconds(500) }, out _), Is.True); } Assert.That(((IPartitionedSubscription)origin).PartitionCount, Is.EqualTo((totalItems + (int)perPartitionCap - 1) / (int)perPartitionCap)); // Wait until all partitions and their items are // Created so the snapshot captures real server ids. bool itemsCreated = await WaitForAsync( () => origin.MonitoredItems.Items.All(i => i.Created), TimeSpan.FromSeconds(30), ct).ConfigureAwait(false); Assert.That(itemsCreated, Is.True); savedSnapshots = originSession.SnapshotSubscriptions(); Assert.That(savedSnapshots, Has.Count.EqualTo(((IPartitionedSubscription)origin).PartitionCount), "every partition must contribute one snapshot"); Assert.That(savedSnapshots.Select(s => s.LogicalGroupId).Distinct().Count(), Is.EqualTo(1), "all snapshots must share the same LogicalGroupId"); Assert.That(savedSnapshots.Select(s => s.PartitionIndex).OrderBy(i => i), Is.EqualTo(Enumerable.Range(0, savedSnapshots.Count)), "PartitionIndex values must be contiguous 0..N-1"); } finally { try { await originSession.CloseAsync(ct: CancellationToken.None).ConfigureAwait(false); } catch { /* best effort */ } try { await originSession.DisposeAsync().ConfigureAwait(false); } catch { /* best effort */ } } // Restore phase — fresh session, regroup the snapshots // into one LogicalSubscription with all items. ManagedSession targetSession = await ConnectV2Async( nameof(SaveLoadRoundTripsAllPartitionsAsync) + "_target", ct) .ConfigureAwait(false); try { var targetHandler = new RecordingSubscriptionHandler(); IReadOnlyList restored = await targetSession .RestoreSubscriptionsAsync(savedSnapshots, _ => targetHandler, transferSubscriptions: false, ct) .ConfigureAwait(false); Assert.That(restored, Has.Count.EqualTo(1), "regrouped snapshots must restore as one logical subscription"); ISubscription restoredSub = restored[0]; Assert.That(restoredSub, Is.InstanceOf()); bool restoredCreated = await WaitForAsync(() => restoredSub.Created, TimeSpan.FromSeconds(30), ct).ConfigureAwait(false); Assert.That(restoredCreated, Is.True, "every restored partition must reach Created"); Assert.That(restoredSub.MonitoredItems.Count, Is.EqualTo((uint)totalItems)); Assert.That(((IPartitionedSubscription)restoredSub).PartitionCount, Is.EqualTo(savedSnapshots.Count), "restored partition count must match the saved snapshot count"); for (int i = 0; i < totalItems; i++) { Assert.That(restoredSub.MonitoredItems .TryGetMonitoredItemByName($"saveload_{i}", out _), Is.True, $"item saveload_{i} must round-trip through save/load"); } await restoredSub.DisposeAsync().ConfigureAwait(false); } finally { try { await targetSession.CloseAsync(ct: CancellationToken.None).ConfigureAwait(false); } catch { /* best effort */ } try { await targetSession.DisposeAsync().ConfigureAwait(false); } catch { /* best effort */ } } } [Test] [Order(400)] [CancelAfter(120_000)] public async Task DurableLifetimeAppliesToSecondaryPartitionsAsync( CancellationToken ct) { // SetSubscriptionDurable requires server-side support // not present in the in-process Quickstart reference // server — the comprehensive end-to-end coverage of the // multi-partition durable hook lives in // Opc.Ua.Subscriptions.Durable.Tests // (DurableLifetimeAppliesToAllPartitionsV2Async), which // uses a ReferenceServer fixture configured with // DurableSubscriptionsEnabled=true. This stub exists so // CI runners against the Quickstart server document the // coverage location and ignore gracefully. Assert.Ignore("Multi-partition durable coverage lives in " + "Opc.Ua.Subscriptions.Durable.Tests." + "SubscriptionDurableTests." + "DurableLifetimeAppliesToAllPartitionsV2Async — " + "requires DurableSubscriptionsEnabled on the server."); await Task.CompletedTask.ConfigureAwait(false); } private async Task ConnectV2Async( string sessionName, CancellationToken ct) { ConfiguredEndpoint endpoint = await ClientFixture .GetEndpointAsync(ServerUrl, SecurityPolicies.None) .ConfigureAwait(false); return await new ManagedSessionBuilder(ClientFixture.Config, Telemetry) .UseEndpoint(endpoint) .WithSessionName(sessionName) .WithSessionTimeout(TimeSpan.FromSeconds(120)) .ConnectAsync(ct) .ConfigureAwait(false); } private static async Task WaitForAsync( Func predicate, TimeSpan timeout, CancellationToken ct) { DateTime deadline = DateTime.UtcNow + timeout; while (DateTime.UtcNow < deadline) { ct.ThrowIfCancellationRequested(); if (predicate()) { return true; } await Task.Delay(50, ct).ConfigureAwait(false); } return predicate(); } } }