Files
UA-.NETStandard/tests/Opc.Ua.Subscriptions.Classic.Tests/SubscriptionTest.cs
romanett 6c9f2d6f36 Keep SubscriptionTest sessions alive across CI runner stalls (#4581)
## Proposed changes

Fixes a flaky `SubscriptionTest.PublishRequestCountAsync` in
`Opc.Ua.Subscriptions.Classic.Tests`. It failed on #4574 (run
[36466677911](https://github.com/OPCFoundation/UA-.NETStandard/actions/runs/36466677911),
`test-linux-net10.0 (25/30)`) with `BadSessionIdInvalid`. #4574 did not
touch this code.

**Evidence from the TRX StdOut of the failing test:**
- The per-test session was created with `Revised session timeout value:
10000`. This fixture uses `SingleSession = false`, so each test gets its
own session with the `ClientFixture` default of 10 s.
- Right after the 50 subscriptions were created, the log goes silent for
about 19.6 s (19:00:15.277 → 19:00:34.922).
- After that silence, all outstanding publish requests fail together
with `BadRequestTimeout`. The next request, the cleanup's
`DeleteSubscriptions` (`SubscriptionTest.cs:510`), returns
`BadSessionIdInvalid`, and keep-alives then report the same status.

So the test host stalled for longer than the 10 s session timeout, and
the server expired the session. The test did not close it. This is the
same mechanism #4539 fixed for the fixture-shared session.

**Changes:**
- `SubscriptionTest.OneTimeSetUpAsync` now sets
`ClientFixture.SessionTimeout = SharedSessionTimeout` (120 s, from
#4539). The fixture's per-test sessions carry heavy publish load (50
subscriptions × 50 items, sampling 0, publishing revised to 100 ms). No
test in this fixture depends on the 10 s timeout.
- `PublishRequestCountAsync` now removes its subscriptions in a
`finally` block, best effort, with errors logged. A failure part-way
through can no longer leave 50 publishing subscriptions behind for the
later ordered tests.

## Related Issues

- Follow-up to #4539

## Types of changes

- [x] Bugfix (non-breaking change which fixes an issue)

## Checklist

- [x] I have read the CONTRIBUTING doc.
- [x] Local run of `Opc.Ua.Subscriptions.Classic.Tests` on net10.0:
57/57 passed.

## Further comments

The runner stall could not be reproduced locally. The fix relies on the
same root cause as #4539, where the stall was shown with an injected
keep-alive delay.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com>
2026-09-29 11:30:33 +02:00

854 lines
35 KiB
C#

/* ========================================================================
* 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.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using Opc.Ua.Client;
using Opc.Ua.Client.TestFramework;
namespace Opc.Ua.Subscriptions.Classic.Tests
{
/// <summary>
/// Test Client Services.
/// </summary>
[TestFixture]
[Category("Client")]
[SetCulture("en-us")]
[SetUICulture("en-us")]
public class SubscriptionTest : ClientTestFramework
{
private readonly string m_subscriptionTestXml = Path.Combine(
Path.GetTempPath(),
"SubscriptionTest.xml");
/// <summary>
/// Set up a Server and a Client instance.
/// </summary>
[OneTimeSetUp]
public override async Task OneTimeSetUpAsync()
{
// the tests can be run against server specified in .runsettings
SupportsExternalServerUrl = true;
// create a new session for every test
SingleSession = false;
MaxChannelCount = 1000;
await OneTimeSetUpCoreAsync(securityNone: true).ConfigureAwait(false);
// The per test sessions carry heavy publish load (up to 50
// subscriptions x 50 items at 100 ms). With the 10 s default a
// stall of a loaded CI runner lets the server expire the session
// and the test fails with BadSessionIdInvalid, so use the long
// timeout of the fixture-shared session for them as well.
ClientFixture.SessionTimeout = SharedSessionTimeout;
}
/// <summary>
/// Tear down the Server and the Client.
/// </summary>
[OneTimeTearDown]
public override Task OneTimeTearDownAsync()
{
return base.OneTimeTearDownAsync();
}
/// <summary>
/// Test setup.
/// </summary>
[SetUp]
public override Task SetUpAsync()
{
return base.SetUpAsync();
}
/// <summary>
/// Test teardown.
/// </summary>
[TearDown]
public override Task TearDownAsync()
{
return base.TearDownAsync();
}
[Test]
[Order(100)]
public async Task AddSubscriptionAsync()
{
using var defaultItemSource = new TestableSubscription(Telemetry);
// check keepAlive
int keepAlive = 0;
Session.KeepAlive += (_, _) => keepAlive++;
int sessionConfigChanged = 0;
Session.SessionConfigurationChanged += (sender, e) => sessionConfigChanged++;
// add current time
var list = new List<MonitoredItem>
{
new TestableMonitoredItem(defaultItemSource.DefaultItem)
{
DisplayName = "ServerStatusCurrentTime",
StartNodeId = VariableIds.Server_ServerStatus_CurrentTime
}
};
list.ForEach(i =>
i.Notification += (item, _) =>
{
foreach (DataValue value in item.DequeueValues())
{
TestContext.Out.WriteLine(
"{0}: {1}, {2}, {3}",
item.DisplayName,
value.WrappedValue,
value.SourceTimestamp,
value.StatusCode);
}
});
using var subscription = new TestableSubscription(Session.DefaultSubscription);
TestContext.Out.WriteLine("MaxMessageCount: {0}", subscription.MaxMessageCount);
TestContext.Out.WriteLine(
"MaxNotificationsPerPublish: {0}",
subscription.MaxNotificationsPerPublish);
TestContext.Out.WriteLine("MinLifetimeInterval: {0}", subscription.MinLifetimeInterval);
subscription.StateChanged += (_, e) =>
TestContext.Out.WriteLine(
"SubscriptionStateChangedEventArgs: Id: {0} Status: {1}",
subscription.Id,
e.Status);
subscription.AddItem(list[0]);
Assert.That(subscription.MonitoredItemCount, Is.EqualTo(1));
Assert.That(subscription.ChangesPending, Is.True);
Assert.ThrowsAsync<ServiceResultException>(async () =>
await subscription.CreateAsync().ConfigureAwait(false));
bool result = await Session.RemoveSubscriptionAsync(subscription).ConfigureAwait(false);
Assert.That(result, Is.False);
result = await Session.RemoveSubscriptionsAsync([subscription]).ConfigureAwait(false);
Assert.That(result, Is.False);
result = Session.AddSubscription(subscription);
Assert.That(result, Is.True);
result = Session.AddSubscription(subscription);
Assert.That(result, Is.False);
result = await Session.RemoveSubscriptionsAsync([subscription]).ConfigureAwait(false);
Assert.That(result, Is.True);
result = await Session.RemoveSubscriptionAsync(subscription).ConfigureAwait(false);
Assert.That(result, Is.False);
result = Session.AddSubscription(subscription);
Assert.That(result, Is.True);
await subscription.CreateAsync().ConfigureAwait(false);
// add state
var list2 = new List<MonitoredItem>
{
new TestableMonitoredItem(subscription.DefaultItem)
{
DisplayName = "ServerStatusState",
StartNodeId = VariableIds.Server_ServerStatus_State
}
};
IList<NodeId> simulatedNodes = GetTestSetSimulation(Session.NamespaceUris);
list2.AddRange(CreateMonitoredItemTestSet(subscription, simulatedNodes));
list2.ForEach(i =>
i.Notification += (item, _) =>
{
foreach (DataValue value in item.DequeueValues())
{
TestContext.Out.WriteLine(
"{0}: {1}, {2}, {3}",
item.DisplayName,
value.WrappedValue,
value.SourceTimestamp,
value.StatusCode);
}
});
subscription.AddItems(list2);
await subscription.ApplyChangesAsync().ConfigureAwait(false);
await subscription.SetPublishingModeAsync(false).ConfigureAwait(false);
Assert.That(subscription.PublishingEnabled, Is.False);
await subscription.SetPublishingModeAsync(true).ConfigureAwait(false);
Assert.That(subscription.PublishingEnabled, Is.True);
Assert.That(subscription.PublishingStopped, Is.False);
subscription.Priority = 200;
await subscription.ModifyAsync().ConfigureAwait(false);
// save with custom Subscription state subclass information
Session.Save(m_subscriptionTestXml);
await Task.Delay(5000).ConfigureAwait(false);
OutputSubscriptionInfo(TestContext.Out, subscription);
await subscription.ConditionRefreshAsync().ConfigureAwait(false);
ServiceResultException sre = Assert
.ThrowsAsync<ServiceResultException>(async () =>
await subscription.RepublishAsync(subscription.SequenceNumber + 100)
.ConfigureAwait(false));
Assert.That(
sre.StatusCode,
Is.EqualTo(StatusCodes.BadMessageNotAvailable),
$"Expected BadMessageNotAvailable, but received {sre.Message}");
// verify that reconnect created subclassed version of subscription and monitored item
foreach (Subscription s in Session.Subscriptions)
{
Assert.That(s.GetType(), Is.EqualTo(typeof(TestableSubscription)));
foreach (MonitoredItem m in s.MonitoredItems)
{
Assert.That(m.GetType(), Is.EqualTo(typeof(TestableMonitoredItem)));
}
}
subscription.RemoveItems(list);
await subscription.ApplyChangesAsync().ConfigureAwait(false);
subscription.RemoveItem(list2[0]);
result = await Session.RemoveSubscriptionAsync(subscription).ConfigureAwait(false);
Assert.That(result, Is.True);
}
[Test]
[Order(200)]
public async Task LoadSubscriptionAsync()
{
if (!File.Exists(m_subscriptionTestXml))
{
Assert
.Ignore($"Save file {m_subscriptionTestXml} does not exist yet");
}
// load
IEnumerable<Subscription> subscriptions = Session.Load(
m_subscriptionTestXml,
false);
Assert.That(subscriptions, Is.Not.Null);
Assert.IsNotEmpty(subscriptions);
int valueChanges = 0;
foreach (Subscription subscription in subscriptions)
{
var list = subscription.MonitoredItems.ToList();
list.ForEach(i =>
i.Notification += (item, _) =>
{
foreach (DataValue value in item.DequeueValues())
{
valueChanges++;
TestContext.Out.WriteLine(
"{0}: {1}, {2}, {3}",
item.DisplayName,
value.WrappedValue,
value.SourceTimestamp,
value.StatusCode);
}
});
Session.AddSubscription(subscription);
await subscription.CreateAsync().ConfigureAwait(false);
}
await Task.Delay(5000).ConfigureAwait(false);
TestContext.Out.WriteLine("{0} value changes.", valueChanges);
Assert.That(valueChanges, Is.GreaterThanOrEqualTo(10));
foreach (Subscription subscription in Session.Subscriptions)
{
OutputSubscriptionInfo(TestContext.Out, subscription);
}
bool result = await Session.RemoveSubscriptionsAsync(subscriptions)
.ConfigureAwait(false);
Assert.That(result, Is.True);
}
[Theory]
[Order(300)]
[CancelAfter(30_000)]
/// <summary>
/// This test doesn't deterministically prove sequential publishing,
/// but rather relies on a subscription not being able to handle the message load.
/// This test should be re-implemented with a Session that deterministically
/// provides the wrong order of messages to Subscription.
///</summary>
public async Task SequentialPublishingSubscriptionAsync(bool enabled)
{
var subscriptionList = new List<Subscription>();
var subscriptionIds = new List<uint>();
using var sequenceBroken = new AutoResetEvent(false);
long numOfNotifications = 0L;
const int testWaitTime = 10000;
const int monitoredItemsPerSubscription = 500;
const int subscriptions = 10;
Session.MinPublishRequestCount = 3;
// multiple Subscriptions to enforce multiple queued publish requests
for (int i = 0; i < subscriptions; i++)
{
var s = new TestableSubscription(Session.DefaultSubscription)
{
SequentialPublishing = enabled,
KeepAliveCount = 10,
PublishingInterval = 100,
DisableMonitoredItemCache = true,
PublishingEnabled = false,
MaxMessageCount = 1
};
subscriptionList.Add(s);
}
Subscription subscription = subscriptionList[0];
// Create monitored items on the server
// and track the last reported sequence number
var testSet = new List<NodeId>();
while (testSet.Count < monitoredItemsPerSubscription)
{
testSet.AddRange(GetTestSetFullSimulation(Session.NamespaceUris));
}
List<MonitoredItem> monitoredItemsList = testSet.ConvertAll(nodeId => new MonitoredItem(
subscription.DefaultItem)
{
StartNodeId = nodeId,
SamplingInterval = 0
});
subscription.AddItems(monitoredItemsList);
foreach (Subscription s in subscriptionList)
{
bool boolResult = Session.AddSubscription(s);
Assert.That(boolResult, Is.True);
await s.CreateAsync().ConfigureAwait(false);
int publishInterval = (int)s.CurrentPublishingInterval;
TestContext.Out.WriteLine($"CurrentPublishingInterval: {publishInterval}");
subscriptionIds.Add(s.Id);
}
// populate sequence number dictionary
var dictionary = new ConcurrentDictionary<uint, uint>();
foreach (Subscription item in subscriptionList)
{
dictionary.TryAdd(item.Id, 0);
}
// track the last reported sequence number
subscription.FastDataChangeCallback = (s, notification, __) =>
{
Interlocked.Increment(ref numOfNotifications);
if (dictionary[s.Id] > notification.SequenceNumber)
{
TestContext.Out.WriteLine(
"Out of order encountered Id: {0}, {1} > {2}",
s.Id,
dictionary[s.Id],
notification.SequenceNumber);
sequenceBroken.Set();
return;
}
dictionary[s.Id] = notification.SequenceNumber;
};
var stopwatch = Stopwatch.StartNew();
// start
await Session.SetPublishingModeAsync(
null,
true,
subscriptionIds,
default).ConfigureAwait(false);
// Wait for out-of-order to occur
bool failed = sequenceBroken.WaitOne(testWaitTime);
if (failed)
{
TestContext.Out.WriteLine("Error detected, terminating after 1 second");
Thread.Sleep(1000);
}
// stop
await Session.SetPublishingModeAsync(
null,
false,
subscriptionIds,
default).ConfigureAwait(false);
//Log information
double elapsed = stopwatch.Elapsed.TotalMilliseconds / 1000.0;
TestContext.Out.WriteLine($"Ran for: {elapsed:N} seconds");
long totalNotifications = Interlocked.Read(ref numOfNotifications);
double notificationRate = totalNotifications / elapsed;
int outstandingMessageWorkers = subscription.OutstandingMessageWorkers;
TestContext.Out.WriteLine(
$"Id: {subscription.Id} Outstanding workers: {outstandingMessageWorkers}");
// clean up before validating conditions
foreach (Subscription s in subscriptionList)
{
bool result = await Session.RemoveSubscriptionAsync(s).ConfigureAwait(false);
Assert.That(result, Is.True);
}
TestContext.Out.WriteLine($"Number of notifications: {totalNotifications:N0}");
//How fast it processed notifications.
TestContext.Out.WriteLine($"Notifications rate: {notificationRate:N} per second");
//No notifications means nothing worked
Assert.NotZero(totalNotifications);
// The issue more unlikely seem to appear on .NET 6 in the given timeframe
if (!enabled && !failed)
{
Assert
.Inconclusive("The test couldn't validate the issue on this platform");
}
// catch if expected/unexpected Out-of-sequence occurred
Assert.That(!failed, Is.EqualTo(enabled));
}
[Test]
[Order(400)]
[CancelAfter(30_000)]
public async Task PublishRequestCountAsync()
{
var subscriptionList = new List<Subscription>();
long numOfNotifications = 0L;
const int testWaitTime = 10000;
const int monitoredItemsPerSubscription = 50;
const int subscriptions = 50;
const int maxServerPublishRequest = 20;
// Server revises the requested publishing interval of 0 to 100 ms
const int samplingInterval = 100;
try
{
for (int i = 0; i < subscriptions; i++)
{
var subscription = new TestableSubscription(Session.DefaultSubscription)
{
PublishingInterval = 0,
DisableMonitoredItemCache = true,
PublishingEnabled = true,
FastDataChangeCallback = (_, notification, __) =>
Interlocked.Add(ref numOfNotifications, notification.MonitoredItems.Count)
};
subscriptionList.Add(subscription);
var list = new List<MonitoredItem>();
IList<NodeId> nodeSet = GetTestSetFullSimulation(Session.NamespaceUris);
for (int ii = 0; ii < monitoredItemsPerSubscription; ii++)
{
NodeId nextNode = nodeSet[ii % nodeSet.Count];
list.Add(
new TestableMonitoredItem(subscription.DefaultItem)
{
StartNodeId = nextNode,
// Sample at the revised publishing interval to keep
// the server load of 2500 items bounded on CI hosts
SamplingInterval = samplingInterval
});
}
var dict = list.ToDictionary(item => item.ClientHandle, _ => DateTime.MinValue);
subscription.AddItems(list);
Assert.ThrowsAsync<ServiceResultException>(
() => subscription.CreateAsync());
bool result = Session.AddSubscription(subscription);
Assert.That(result, Is.True);
await subscription.CreateAsync().ConfigureAwait(false);
int publishInterval = (int)subscription.CurrentPublishingInterval;
TestContext.Out.WriteLine(
$"Id: {subscription.Id} CurrentPublishingInterval: {publishInterval}");
}
var stopwatch = Stopwatch.StartNew();
await Task.Delay(1000).ConfigureAwait(false);
// verify that number of active publishrequests is never exceeded
while (stopwatch.ElapsedMilliseconds < testWaitTime)
{
// use the sample server default for max publish request count
Assert.That(
Math.Max(maxServerPublishRequest, subscriptions),
Is.GreaterThanOrEqualTo(Session.GoodPublishRequestCount),
"No. of Good Publish Requests shall be at max count of subscriptions");
await Task.Delay(100).ConfigureAwait(false);
}
foreach (Subscription subscription in subscriptionList)
{
bool result = await Session.RemoveSubscriptionAsync(subscription)
.ConfigureAwait(false);
Assert.That(result, Is.True);
}
}
finally
{
// on failure remove what is left, so the 50 subscriptions and
// their publish load cannot leak into the following tests
foreach (Subscription subscription in subscriptionList)
{
if (subscription.Session == null)
{
continue;
}
try
{
await Session.RemoveSubscriptionAsync(subscription)
.ConfigureAwait(false);
}
catch (Exception e)
{
TestContext.Out.WriteLine(
$"Id: {subscription.Id} cleanup failed: {e.Message}");
}
}
}
}
[Test]
[Order(1000)]
public async Task FastKeepAliveCallbackAsync()
{
// add current time
using var subscription = new TestableSubscription(Session.DefaultSubscription)
{
KeepAliveCount = 1,
PublishingInterval = 250
};
TestContext.Out.WriteLine("MaxMessageCount: {0}", subscription.MaxMessageCount);
TestContext.Out.WriteLine(
"MaxNotificationsPerPublish: {0}",
subscription.MaxNotificationsPerPublish);
TestContext.Out.WriteLine("MinLifetimeInterval: {0}", subscription.MinLifetimeInterval);
// add static nodes
var list = new List<MonitoredItem>
{
new TestableMonitoredItem(subscription.DefaultItem)
{
DisplayName = "ServerStatusState",
StartNodeId = VariableIds.Server_ServerStatus_State
}
};
IList<NodeId> staticNodes = GetTestSetStatic(Session.NamespaceUris);
list.AddRange(CreateMonitoredItemTestSet(subscription, staticNodes));
list.ForEach(i =>
i.Notification += (item, _) =>
{
foreach (DataValue value in item.DequeueValues())
{
TestContext.Out.WriteLine(
"{0}: {1}, {2}, {3}",
item.DisplayName,
value.WrappedValue,
value.SourceTimestamp,
value.StatusCode);
}
});
subscription.AddItems(list);
int numOfKeepAliveNotifications = 0;
subscription.FastKeepAliveCallback = (_, notification) =>
{
int n = Interlocked.Increment(ref numOfKeepAliveNotifications);
TestContext.Out.WriteLine(
"KeepAliveCallback {0} next sequenceNumber {1} PublishTime {2}",
n,
notification.SequenceNumber,
notification.PublishTime);
};
int numOfDataChangeNotifications = 0;
subscription.FastDataChangeCallback = (_, notification, __) =>
{
int n = Interlocked.Increment(ref numOfDataChangeNotifications);
TestContext.Out.WriteLine(
"DataChangeCallback {0} sequenceNumber {1} PublishTime {2} Count {3}",
n,
notification.SequenceNumber,
notification.PublishTime,
notification.MonitoredItems.Count);
};
bool result = Session.AddSubscription(subscription);
Assert.That(result, Is.True);
await subscription.CreateAsync().ConfigureAwait(false);
await subscription.ApplyChangesAsync().ConfigureAwait(false);
await subscription.SetPublishingModeAsync(false).ConfigureAwait(false);
Assert.That(subscription.PublishingEnabled, Is.False);
await subscription.SetPublishingModeAsync(true).ConfigureAwait(false);
Assert.That(subscription.PublishingEnabled, Is.True);
Assert.That(subscription.PublishingStopped, Is.False);
subscription.Priority = 55;
await subscription.ModifyAsync().ConfigureAwait(false);
TestContext.Out.WriteLine("Waiting for keep alive");
const int delay = 2000;
await Task.Delay(delay).ConfigureAwait(false);
OutputSubscriptionInfo(TestContext.Out, subscription);
// expect at least a quarter of the theoretical number of keep alive
// notifications. The threshold was originally /2 (half), but on
// CPU-pressured CI runners the publish loop can lag enough that
// only ~3 of an expected ~8 keep-alives arrive in 2s. Use /4 to
// make the test robust to CI scheduling jitter while still
// verifying that keep-alives are firing regularly.
int expectedMinKeepAlives = delay / subscription.PublishingInterval / 4;
if (numOfKeepAliveNotifications <= expectedMinKeepAlives)
{
Assert.Ignore(
$"Timing-sensitive: only {numOfKeepAliveNotifications} keep-alive " +
$"notifications observed in {delay} ms (expected > {expectedMinKeepAlives}). " +
"CI runner is too slow to deliver keep-alives within the test window.");
}
Assert.That(
numOfKeepAliveNotifications,
Is.GreaterThan(expectedMinKeepAlives));
Assert.That(numOfDataChangeNotifications, Is.EqualTo(1));
TestContext.Out.WriteLine("Call ResendData.");
bool resendData = await subscription.ResendDataAsync().ConfigureAwait(false);
Assert.That(resendData, Is.True);
await Task.Delay(delay).ConfigureAwait(false);
OutputSubscriptionInfo(TestContext.Out, subscription);
Assert.That(numOfDataChangeNotifications, Is.EqualTo(2));
TestContext.Out.WriteLine("Call ConditionRefresh.");
bool conditionRefresh =
await subscription.ConditionRefreshAsync().ConfigureAwait(false);
Assert.That(conditionRefresh, Is.True);
ServiceResultException sre =
Assert.ThrowsAsync<ServiceResultException>(() =>
subscription.RepublishAsync(subscription.SequenceNumber + 100));
Assert.That(
sre.StatusCode,
Is.EqualTo(StatusCodes.BadMessageNotAvailable));
subscription.RemoveItems(list);
await subscription.ApplyChangesAsync().ConfigureAwait(false);
result = await Session.RemoveSubscriptionAsync(
subscription).ConfigureAwait(false);
Assert.That(result, Is.True);
}
[Test]
[Order(900)]
public async Task SetTriggeringTrackingAsync()
{
// Create a subscription
using var subscription = new Subscription(Session.DefaultSubscription)
{
PublishingEnabled = true,
PublishingInterval = 1000,
KeepAliveCount = 10,
LifetimeCount = 100,
MaxNotificationsPerPublish = 1000,
Priority = 100
};
Session.AddSubscription(subscription);
await subscription.CreateAsync(CancellationToken.None).ConfigureAwait(false);
Assert.That(subscription.Created, Is.True);
// Create monitored items
var triggeringItem = new MonitoredItem(subscription.DefaultItem)
{
StartNodeId = VariableIds.Server_ServerStatus_CurrentTime,
AttributeId = Attributes.Value,
MonitoringMode = MonitoringMode.Reporting,
SamplingInterval = 0,
QueueSize = 0,
DiscardOldest = true
};
var triggeredItem1 = new MonitoredItem(subscription.DefaultItem)
{
StartNodeId = VariableIds.Server_ServerStatus_State,
AttributeId = Attributes.Value,
MonitoringMode = MonitoringMode.Sampling,
SamplingInterval = 0,
QueueSize = 0,
DiscardOldest = true
};
var triggeredItem2 = new MonitoredItem(subscription.DefaultItem)
{
StartNodeId = VariableIds.Server_ServerStatus_BuildInfo,
AttributeId = Attributes.Value,
MonitoringMode = MonitoringMode.Sampling,
SamplingInterval = 0,
QueueSize = 0,
DiscardOldest = true
};
subscription.AddItem(triggeringItem);
subscription.AddItem(triggeredItem1);
subscription.AddItem(triggeredItem2);
// Create the items
await subscription.ApplyChangesAsync(CancellationToken.None).ConfigureAwait(false);
Assert.That(triggeringItem.Created, Is.True);
Assert.That(triggeredItem1.Created, Is.True);
Assert.That(triggeredItem2.Created, Is.True);
// Set up triggering relationship using the new method
var linksToAdd = new List<MonitoredItem> { triggeredItem1, triggeredItem2 };
SetTriggeringResponse response = await subscription.SetTriggeringAsync(
triggeringItem,
linksToAdd,
default,
CancellationToken.None).ConfigureAwait(false);
Assert.That(response, Is.Not.Null);
// Verify the triggering relationships are tracked
Assert.That(triggeringItem.TriggeredItems.IsNull, Is.False);
Assert.That(triggeringItem.TriggeredItems.Count, Is.EqualTo(2));
Assert.That(triggeringItem.TriggeredItems.ToList(), Has.Member(triggeredItem1.ClientHandle));
Assert.That(triggeringItem.TriggeredItems.ToList(), Has.Member(triggeredItem2.ClientHandle));
Assert.That(triggeredItem1.TriggeringItemId, Is.EqualTo(triggeringItem.Status.Id));
Assert.That(triggeredItem2.TriggeringItemId, Is.EqualTo(triggeringItem.Status.Id));
// Snapshot the subscription state
subscription.Snapshot(out SubscriptionState state);
// Verify that the triggering relationships are persisted
MonitoredItemState triggeringItemState = state.MonitoredItems
.Memory.ToArray().First(m => m.ClientId == triggeringItem.ClientHandle);
Assert.That(triggeringItemState, Is.Not.Null);
Assert.That(triggeringItemState.TriggeredItems.IsNull, Is.False);
Assert.That(triggeringItemState.TriggeredItems.Count, Is.EqualTo(2));
// Clean up
await subscription.DeleteAsync(true, CancellationToken.None).ConfigureAwait(false);
}
/// <summary>
/// Test that concurrent calls to CreateItemsAsync do not create duplicate monitored items.
/// This test verifies the fix for the race condition where multiple threads calling
/// CreateItemsAsync could include the same items in their create requests.
/// </summary>
[Test]
[Order(1100)]
public async Task ConcurrentCreateItemsNoDuplicatesAsync()
{
using var subscription = new TestableSubscription(Session.DefaultSubscription);
Session.AddSubscription(subscription);
await subscription.CreateAsync().ConfigureAwait(false);
// Create multiple monitored items
var items = new List<MonitoredItem>();
for (int i = 0; i < 10; i++)
{
items.Add(new TestableMonitoredItem(subscription.DefaultItem)
{
DisplayName = $"Item{i}",
StartNodeId = VariableIds.Server_ServerStatus_CurrentTime,
AttributeId = Attributes.Value
});
}
subscription.AddItems(items);
Assert.That(subscription.MonitoredItemCount, Is.EqualTo(10));
// Simulate concurrent CreateItemsAsync calls
// Use 3 concurrent tasks to ensure at least 2 will race with each other
const int concurrentTasks = 3;
var tasks = new List<Task<ArrayOf<MonitoredItem>>>();
for (int i = 0; i < concurrentTasks; i++)
{
tasks.Add(Task.Run(() =>
subscription.CreateItemsAsync(CancellationToken.None)));
}
ArrayOf<MonitoredItem>[] results = await Task.WhenAll(tasks).ConfigureAwait(false);
// Verify that all items were created exactly once
int totalCreated = 0;
foreach (MonitoredItem item in items)
{
if (item.Status.Created)
{
totalCreated++;
Assert.That(item.Status.Id, Is.GreaterThan(0u),
$"Item {item.DisplayName} should have a server-assigned ID");
}
}
Assert.That(totalCreated, Is.EqualTo(10),
"All 10 items should be created exactly once");
// Verify that each result list contains only the items that were actually created
// by that specific call (should be empty for concurrent calls after the first)
int nonEmptyResults = 0;
foreach (ArrayOf<MonitoredItem> result in results)
{
if (result.Count > 0)
{
nonEmptyResults++;
}
}
Assert.That(nonEmptyResults, Is.LessThanOrEqualTo(1),
"Only one CreateItemsAsync call should have created items");
// Clean up
await subscription.DeleteAsync(true, CancellationToken.None).ConfigureAwait(false);
}
}
}