/* ======================================================================== * 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.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Threading; using System.Threading.Tasks; using NUnit.Framework; using Opc.Ua.Client; using Opc.Ua.Client.TestFramework; using Opc.Ua.Tests; namespace Opc.Ua.Subscriptions.Classic.Tests { /// /// Tests for subscription transfer scenarios. /// Each fixture combines one with acknowledged or retained /// origin notifications, initial values, and sequential publishing. /// [TestFixture] [Category("Client")] [Category("TransferSubscription")] [SetCulture("en-us")] [SetUICulture("en-us")] [TestFixtureSource(nameof(TransferTypeArgs))] [Parallelizable(ParallelScope.Fixtures)] public class TransferSubscriptionTest : ClientTestFramework { /// /// Fixture argument list: one fixture instance per value. /// public static readonly object[] TransferTypeArgs = [ new object[] { TransferType.KeepOpen }, new object[] { TransferType.CloseSession }, new object[] { TransferType.DisconnectedAck }, new object[] { TransferType.DisconnectedRepublish }, new object[] { TransferType.DisconnectedRepublishDelayedAck } ]; /// /// Describes how a subscription transfer scenario is set up. /// public enum TransferType { /// /// The origin session remains open and gives up the subscription. /// KeepOpen, /// /// The origin session is gracefully closed with /// DeleteSubscriptionsOnClose set to false. /// CloseSession, /// /// The origin session gets network disconnected; available sequence /// numbers are just acknowledged. /// DisconnectedAck, /// /// The origin session gets network disconnected; available sequence /// numbers are republished. /// DisconnectedRepublish, /// /// The origin session gets network disconnected; available sequence /// numbers are republished but the target delays the acknowledgement. /// DisconnectedRepublishDelayedAck } [Test] public void TransferredSequencesRemainAvailableForRepublish() { using var subscription = new Subscription(Session.DefaultSubscription) { RepublishAfterTransfer = true }; ArrayOf available = new uint[] { 9, 10, 11 }.ToArrayOf(); MethodInfo processTransfer = typeof(Subscription).GetMethod( "ProcessTransferredSequenceNumbers", BindingFlags.Instance | BindingFlags.NonPublic)!; processTransfer.Invoke(subscription, [available]); Assert.That(subscription.AvailableSequenceNumbers.ToArray(), Is.EquivalentTo(available.ToArray()!)); } /// /// Initializes a new fixture instance for the given . /// public TransferSubscriptionTest(TransferType transferType) : base(Utils.UriSchemeOpcTcp) { m_transferType = transferType; } /// /// Start one server per fixture instance and establish a single shared session. /// Tests create their own sessions independently. /// [OneTimeSetUp] public override Task OneTimeSetUpAsync() { // Use a shared session for namespace URI lookups; tests create their own sessions. SingleSession = true; MaxChannelCount = 1000; ClientFixtureSubscriptionEngineFactory = ClassicSubscriptionEngineFactory.Instance; 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(); } /// /// Transfer subscriptions for all combinations of /// , , /// and . /// [Test] [Combinatorial] [Order(811)] [Parallelizable] public Task TransferSubscriptionAsync( [Values] bool sendInitialValues, [Values] bool sequentialPublishing, [Values] bool retainOriginNotifications) { return InternalTransferSubscriptionAsync( m_transferType, sendInitialValues, sequentialPublishing, retainOriginNotifications); } /// /// Debug variant that repeats the transfer test 30 times (explicit, not for normal CI runs). /// [Test] [Combinatorial] [Order(812)] [Explicit] public async Task TransferSubscriptionDebugAsync( [Values] bool sendInitialValues, [Values] bool sequentialPublishing, [Values] bool retainOriginNotifications) { const int loopCount = 30; for (int i = 0; i < loopCount; i++) { await InternalTransferSubscriptionAsync( m_transferType, sendInitialValues, sequentialPublishing, retainOriginNotifications).ConfigureAwait(false); TestContext.Out.WriteLine("==========================================="); TestContext.Out.WriteLine("==========================================="); TestContext.Out.WriteLine($"Completed iteration {i + 1} of {loopCount}."); TestContext.Out.WriteLine("==========================================="); TestContext.Out.WriteLine("==========================================="); } } private async Task InternalTransferSubscriptionAsync( TransferType transferType, bool sendInitialValues, bool sequentialPublishing, bool retainOriginNotifications) { using ISession originSession = await ClientFixture .ConnectAsync(ServerUrl, SecurityPolicies.Basic256Sha256) .ConfigureAwait(false); string filePath = Path.GetTempFileName(); try { await InternalTransferSubscriptionAsync( originSession, transferType, sendInitialValues, sequentialPublishing, retainOriginNotifications, filePath).ConfigureAwait(false); } finally { File.Delete(filePath); } } private async Task InternalTransferSubscriptionAsync( ISession originSession, TransferType transferType, bool sendInitialValues, bool sequentialPublishing, bool retainOriginNotifications, string filePath) { const int kTestSubscriptions = 5; const int kDelay = 2_000; const int kQueueSize = 10; if (retainOriginNotifications) { originSession.PublishSequenceNumbersToAcknowledge += DeferSubscriptionAcknowledge; } bool originSessionOpen = transferType == TransferType.KeepOpen; // create subscriptions var originSubscriptions = new SubscriptionCollection(kTestSubscriptions); int[] originSubscriptionCounters = new int[kTestSubscriptions]; int[] originSubscriptionFastDataCounters = new int[kTestSubscriptions]; int[] targetSubscriptionCounters = new int[kTestSubscriptions]; int[] targetSubscriptionFastDataCounters = new int[kTestSubscriptions]; int[] originSubscriptionTransferred = new int[kTestSubscriptions]; var staticTargetNotifications = new ConcurrentQueue(); int targetKeepAliveCount = 0; bool expectRepublish = retainOriginNotifications && transferType >= TransferType.DisconnectedRepublish; int expectedStaticBatchCount = (expectRepublish ? 1 : 0) + (sendInitialValues ? 1 : 0); using var subscriptionTemplate = new TestableSubscription(originSession.DefaultSubscription) { PublishingInterval = 1_000, LifetimeCount = 30, KeepAliveCount = 1, PublishingEnabled = false, RepublishAfterTransfer = transferType >= TransferType.DisconnectedRepublish, SequentialPublishing = sequentialPublishing }; await CreateSubscriptionsAsync( originSession, subscriptionTemplate, originSubscriptions, originSubscriptionCounters, originSubscriptionFastDataCounters, kTestSubscriptions, kQueueSize).ConfigureAwait(false); if (TransferType.KeepOpen == transferType) { foreach (Subscription subscription in originSubscriptions) { subscription.PublishStatusChanged += (s, e) => { TestContext.Out.WriteLine( $"PublishStatusChanged: {s.Session!.SessionId}-{s.Id}-{e.Status}"); if ((e.Status & PublishStateChangedMask.Transferred) != 0) { // subscription transferred Interlocked.Increment(ref originSubscriptionTransferred[(int)s.Handle!]); } }; } } foreach (Subscription subscription in originSubscriptions) { await subscription.SetPublishingModeAsync(true).ConfigureAwait(false); } await TestPolling.WaitUntilAsync( () => Enumerable.Range(0, kTestSubscriptions).All(index => Volatile.Read(ref originSubscriptionCounters[index]) >= originSubscriptions[index].MonitoredItemCount && Volatile.Read(ref originSubscriptionFastDataCounters[index]) > 0), timeoutMessage: "The origin did not deliver every initial monitored-item value.") .ConfigureAwait(false); Subscription staticOrigin = originSubscriptions[0]; if (!retainOriginNotifications) { await TestPolling.WaitUntilAsync( () => staticOrigin.AvailableSequenceNumbers.IsEmpty, timeoutMessage: "The origin's initial notification was not acknowledged by the server.") .ConfigureAwait(false); } else { Assert.That( staticOrigin.AvailableSequenceNumbers.ToArray(), Is.EqualTo([staticOrigin.SequenceNumber])); } NotificationMessage originStaticMessage = staticOrigin.Notifications.Single(); Assert.That(originStaticMessage.NotificationData.Count, Is.EqualTo(1)); Assert.That( originStaticMessage.NotificationData[0].TryGetValue(out DataChangeNotification? originStaticData), Is.True); Assert.That(originStaticData!.MonitoredItems.Count, Is.EqualTo(staticOrigin.MonitoredItemCount)); // close session, do not delete subscription if (transferType != TransferType.KeepOpen) { originSession.DeleteSubscriptionsOnClose = false; // save with custom Subscription subclass information originSession.Save(filePath); if (transferType == TransferType.CloseSession) { // graceful close StatusCode close = await originSession.CloseAsync().ConfigureAwait(false); Assert.That(ServiceResult.IsGood(close), Is.True); } else { // force a socket dispose, to emulate network disconnect // without closing session on server originSession.TransportChannel.Dispose(); } } // wait await Task.Delay(kDelay).ConfigureAwait(false); // close session, do not delete subscription if (transferType > TransferType.CloseSession) { StatusCode closeResult2 = await originSession .CloseAsync() .ConfigureAwait(false); } // create target session using ISession targetSession = await ClientFixture .ConnectAsync(ServerUrl, SecurityPolicies.Basic256Sha256) .ConfigureAwait(false); if (transferType == TransferType.DisconnectedRepublishDelayedAck) { targetSession.PublishSequenceNumbersToAcknowledge += DeferSubscriptionAcknowledge; } // restore client state var transferSubscriptions = new SubscriptionCollection(); if (transferType != TransferType.KeepOpen) { // load subscriptions for transfer transferSubscriptions.AddRange(targetSession.Load(filePath, true)); } else { transferSubscriptions.AddRange((SubscriptionCollection)originSubscriptions.Clone()); } for (int ii = 0; ii < transferSubscriptions.Count; ii++) { Subscription subscription = transferSubscriptions[ii]; if (originSessionOpen) { Assert.That(targetSession.AddSubscription(subscription), Is.True); } subscription.Handle = ii; subscription.FastDataChangeCallback = (s, notification, _) => { TestContext.Out.WriteLine( "FastDataChangeHandlerTarget: {0}-{1}-{2}", s.Id, notification.SequenceNumber, notification.MonitoredItems.Count); int index = (int)s.Handle!; Interlocked.Increment(ref targetSubscriptionFastDataCounters[index]); if (index == 0) { staticTargetNotifications.Enqueue(notification); } }; if (ii == 0) { subscription.FastKeepAliveCallback = (_, _) => Interlocked.Increment(ref targetKeepAliveCount); } foreach (MonitoredItem monitoredItem in subscription.MonitoredItems) { monitoredItem.Notification += (item, _) => { Interlocked.Increment(ref targetSubscriptionCounters[(int)subscription.Handle]); foreach (DataValue value in item.DequeueValues()) { TestContext.Out.WriteLine( "Tra:{0}: {1:20}, {2}, {3}, {4}", subscription.Id, item.DisplayName, value.WrappedValue, value.SourceTimestamp, value.StatusCode); } }; } subscription.StateChanged += (s, e) => TestContext.Out.WriteLine($"StateChanged: {s.Session!.SessionId}-{s.Id}-{e.Status}"); subscription.PublishStatusChanged += (s, e) => TestContext.Out.WriteLine($"PublishStatusChanged: {s.Session!.SessionId}-{s.Id}-{e.Status}"); } // transfer restored subscriptions bool result = await targetSession .TransferSubscriptionsAsync(transferSubscriptions, sendInitialValues) .ConfigureAwait(false); Assert.That(result, Is.True); // validate results for (int ii = 0; ii < transferSubscriptions.Count; ii++) { Assert.That(transferSubscriptions[ii].Created, Is.True); } TestContext.Out .WriteLine("TargetSession is now SessionId={0}", targetSession.SessionId); await TestPolling.WaitUntilAsync( () => staticTargetNotifications.Count >= expectedStaticBatchCount && Volatile.Read(ref targetKeepAliveCount) > 0 && Enumerable.Range(1, kTestSubscriptions - 1).All(index => Volatile.Read(ref targetSubscriptionCounters[index]) > 0 && Volatile.Read(ref targetSubscriptionFastDataCounters[index]) > 0), timeoutMessage: "The target did not deliver expected notifications and a keepalive.") .ConfigureAwait(false); if (TransferType.KeepOpen == transferType) { await TestPolling.WaitUntilAsync( () => Enumerable.Range(0, kTestSubscriptions).All(index => Volatile.Read(ref originSubscriptionTransferred[index]) > 0), timeoutMessage: "The origin did not receive every subscription-transferred notification.") .ConfigureAwait(false); foreach (Subscription subscription in originSubscriptions) { Assert.That(originSubscriptionTransferred[(int)subscription.Handle!], Is.EqualTo(1)); } } // stop publishing foreach (Subscription subscription in transferSubscriptions) { TestContext.Out.WriteLine( "SetPublishingMode(false) for SessionId={0}, SubscriptionId={1}", subscription.Session!.SessionId, subscription.Id); await subscription.SetPublishingModeAsync(false).ConfigureAwait(false); } // validate expected counts for (int jj = 0; jj < kTestSubscriptions; jj++) { TestContext.Out.WriteLine( "-- Subscription {0}: OriginCounts {1}, TargetCounts {2} ", jj, originSubscriptionCounters[jj], targetSubscriptionCounters[jj]); TestContext.Out.WriteLine( "-- Subscription {0}: OriginFastDataCounts {1}, TargetFastDataCounts {2} ", jj, originSubscriptionFastDataCounters[jj], targetSubscriptionFastDataCounters[jj]); uint monitoredItemCount = transferSubscriptions[jj].MonitoredItemCount; uint originExpectedCount = monitoredItemCount; uint targetExpectedCount = sendInitialValues ? monitoredItemCount : 0; if (jj == 0) { targetExpectedCount = (uint)expectedStaticBatchCount * monitoredItemCount; Assert.That(originSubscriptionCounters[jj], Is.EqualTo(originExpectedCount)); Assert.That(originSubscriptionFastDataCounters[jj], Is.EqualTo(1)); Assert.That(targetSubscriptionCounters[jj], Is.EqualTo(targetExpectedCount)); Assert.That(targetSubscriptionFastDataCounters[jj], Is.EqualTo(expectedStaticBatchCount)); AssertStaticNotifications( staticTargetNotifications, originStaticData, expectRepublish, sendInitialValues); } else { // dynamic nodes, expect only one set of changes, another one if send initial values was set Assert.That(originSubscriptionCounters[jj], Is.GreaterThanOrEqualTo(originExpectedCount)); Assert.That(targetSubscriptionCounters[jj], Is.GreaterThanOrEqualTo(targetExpectedCount)); } } int[] countsBeforeResume = [ .. Enumerable.Range(0, kTestSubscriptions) .Select(index => Volatile.Read(ref targetSubscriptionCounters[index])) ]; int[] batchesBeforeResume = [ .. Enumerable.Range(0, kTestSubscriptions) .Select(index => Volatile.Read(ref targetSubscriptionFastDataCounters[index])) ]; int keepAlivesBeforeResume = Volatile.Read(ref targetKeepAliveCount); // restart publishing foreach (Subscription subscription in transferSubscriptions) { TestContext.Out.WriteLine( "SetPublishingMode(true) for SessionId={0}, SubscriptionId={1}", subscription.Session!.SessionId, subscription.Id); await subscription.SetPublishingModeAsync(true).ConfigureAwait(false); } await TestPolling.WaitUntilAsync( () => Volatile.Read(ref targetKeepAliveCount) > keepAlivesBeforeResume && Enumerable.Range(1, kTestSubscriptions - 1).All(index => Volatile.Read(ref targetSubscriptionCounters[index]) > countsBeforeResume[index] && Volatile.Read(ref targetSubscriptionFastDataCounters[index]) > batchesBeforeResume[index]), timeoutMessage: "Transferred subscriptions did not resume dynamic values and static keepalives.") .ConfigureAwait(false); // validate expected counts for (int jj = 0; jj < kTestSubscriptions; jj++) { TestContext.Out.WriteLine( "-- Subscription {0}: OriginCounts {1}, TargetCounts {2} ", jj, originSubscriptionCounters[jj], targetSubscriptionCounters[jj]); TestContext.Out.WriteLine( "-- Subscription {0}: OriginFastDataCounts {1}, TargetFastDataCounts {2} ", jj, originSubscriptionFastDataCounters[jj], targetSubscriptionFastDataCounters[jj]); if (jj == 0) { Assert.That(targetSubscriptionCounters[jj], Is.EqualTo(countsBeforeResume[jj])); Assert.That(targetSubscriptionFastDataCounters[jj], Is.EqualTo(batchesBeforeResume[jj])); AssertStaticNotifications( staticTargetNotifications, originStaticData, expectRepublish, sendInitialValues); } else { Assert.That(targetSubscriptionCounters[jj], Is.GreaterThan(countsBeforeResume[jj])); Assert.That(targetSubscriptionFastDataCounters[jj], Is.GreaterThan(batchesBeforeResume[jj])); } } targetSession.DeleteSubscriptionsOnClose = true; // close sessions StatusCode closeResult = await targetSession.CloseAsync().ConfigureAwait(false); Assert.That(ServiceResult.IsGood(closeResult), Is.True); if (originSessionOpen) { closeResult = await originSession.CloseAsync().ConfigureAwait(false); Assert.That(ServiceResult.IsGood(closeResult), Is.True); } } private static void AssertStaticNotifications( ConcurrentQueue received, DataChangeNotification origin, bool expectRepublish, bool sendInitialValues) { DataChangeNotification[] notifications = [.. received]; Assert.That( notifications.Count(notification => notification.SequenceNumber == origin.SequenceNumber), Is.EqualTo(expectRepublish ? 1 : 0), "Only a retained origin message requested for republish should reuse its sequence number."); Assert.That( notifications.Count(notification => notification.SequenceNumber != origin.SequenceNumber), Is.EqualTo(sendInitialValues ? 1 : 0), "Requested initial values must arrive in one new notification message."); Assert.That(notifications.Select(notification => notification.SequenceNumber), Is.Unique); Dictionary originalItems = origin.MonitoredItems.ToArray()!.ToDictionary(item => item.ClientHandle); foreach (DataChangeNotification notification in notifications) { Assert.That(notification.SequenceNumber, Is.GreaterThanOrEqualTo(origin.SequenceNumber)); Assert.That( notification.MonitoredItems.ToArray()!.Select(item => item.ClientHandle), Is.EquivalentTo(originalItems.Keys)); foreach (MonitoredItemNotification item in notification.MonitoredItems) { Assert.That( originalItems.TryGetValue(item.ClientHandle, out MonitoredItemNotification? original), Is.True); Assert.That(item.Value.WrappedValue, Is.EqualTo(original!.Value.WrappedValue)); Assert.That(item.Value.StatusCode, Is.EqualTo(original.Value.StatusCode)); Assert.That(item.Value.SourceTimestamp, Is.EqualTo(original.Value.SourceTimestamp)); } } } /// /// Event handler to defer publish response sequence number acknowledge. /// private static void DeferSubscriptionAcknowledge( ISession session, PublishSequenceNumbersToAcknowledgeEventArgs e) { // for testing do not ack any sequence numbers e.DeferredAcknowledgementsToSend.Clear(); e.AcknowledgementsToSend.Clear(); } private readonly TransferType m_transferType; } }