mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-28 14:09:56 +08:00
# Description V2 subscription engine feature parity round to make the classic engine eligible for deprecation. Splits the test surface so the classic and V2 engines can evolve independently, ports the gaps the classic-engine integration tests had been exercising, and lands the public V2 API additions needed to close them. - Splits `Tests/Opc.Ua.Subscriptions.Tests` into a classic (`Opc.Ua.Subscriptions.Classic.Tests`) and a V2 (`Opc.Ua.Subscriptions.Tests`) project to shrink CI time and let the two engines diverge cleanly. - Adds the V2 public-surface gaps required by the test ports (`Subscription.ServerId` on the class, `SetSubscriptionDurableAsync`, `SendInitialValuesOnTransfer`, unified `OnSubscriptionStateChangedAsync` handler callback, per-item `ConditionRefreshAsync`, fluent `LoadSubscriptionsAsync(Stream, …)` and `Subscription.Snapshot()` / `RestoreAsync()` for save/load + transfer). - Lands handler-centric design (no `PublishingStopped` property handlers maintain derived state); always-sequential per-subscription delivery documented + tested. - **Adds short and long haul stability tests for the V2 `ManagedSession` + subscription engine. Closes #3744** (see "Issue #3744 Haul tests" below). - **Migrates SubscriptionStateSnapshot / MonitoredItemStateSnapshot to `[DataType]` source-generator codecs**. - `SubscriptionDurableV2Tests.cs` 5 V2 ports of the classic durable test patterns. - `V2FollowUpCoverageTests.cs` handler state-change callback, fluent stream-based `LoadSubscriptionsAsync`, `SendInitialValuesOnTransfer`, snapshot edge cases (empty / `DataChangeFilter` round-trip / concurrent mutation). - `SubscriptionFailoverV2Tests.cs` forced channel-break with and without `WithTransferSubscriptionsOnRecreate`. - `MonitoredItemConditionRefreshLiveV2Tests.cs` — live `ConditionRefresh` against the reference server's event source; asserts `RefreshStart` / `RefreshEnd` events flow through the handler. ## Fixes - #3744 Haul tests (V2 ManagedSession + subscription engine) `Tests/Opc.Ua.Sessions.Tests/ManagedSessionStabilityTest.cs` adds the short/long haul coverage requested by [#3744](https://github.com/OPCFoundation/UA-.NETStandard/issues/3744) plus the bonus fault-injection variant: | Test | Duration | Category | Purpose | |---|---|---|---| | `ShortHaulManagedSessionV2Async` | 2 minutes | `ManagedSessionHaul` (`[Explicit]`) | Quick local sanity check of the V2 monotonicity contract. | | `LongHaulManagedSessionV2Async` | 90 minutes (configurable via `TEST_DURATION_MINUTES`) | `ManagedSessionHaul` (`[Explicit]`) | Long-running V2 stability + monotonicity. | | `LongHaulManagedSessionWithFaultInjectionV2Async` | 90 minutes (configurable) + fault every `FAULT_INJECTION_INTERVAL_SECONDS` (default 60s) | `ManagedSessionHaul` (`[Explicit]`) | **Bonus** — force-closes the subscriber transport channel periodically; verifies V2 manager transfers/recreates the subscription and continues monotonic delivery across reconnects. | **Pattern.** A writer `ManagedSession` increments a `uint32` counter and writes it into `Scalar_Static_Mass_UInt32_UInt32_00` every 250ms. A subscriber `ManagedSession` runs a V2 subscription with a single monitored item on the same node. `MonotonicCounterHandler` asserts each received sample is *strictly greater than* the previous one. V2 `ISubscriptionNotificationHandler` per-subscription ordering guarantee. Skipped values (sampling rate < write rate) are tolerated; duplicates and reorderings are violations.
276 lines
12 KiB
C#
276 lines
12 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/
|
|
* ======================================================================*/
|
|
|
|
#nullable enable
|
|
|
|
#pragma warning disable CA2016
|
|
|
|
using System;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Client;
|
|
using Opc.Ua.Client.Subscriptions;
|
|
|
|
using Opc.Ua.Client.TestFramework;
|
|
|
|
namespace Opc.Ua.Subscriptions.Tests
|
|
{
|
|
/// <summary>
|
|
/// V2 failover-style transfer tests. Force a transport channel
|
|
/// break and verify the subscription survives — either via
|
|
/// <see cref="Opc.Ua.Client.Subscriptions.ISubscriptionManager.TransferSubscriptionsOnRecreate"/>
|
|
/// (server kept the subscription) or via the V2 manager's
|
|
/// internal recreate fallback (server discarded). Both outcomes
|
|
/// must result in the subscription continuing to deliver data.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Category("Client")]
|
|
[Category("V2")]
|
|
[Category("Failover")]
|
|
[SetCulture("en-us")]
|
|
[SetUICulture("en-us")]
|
|
public class SubscriptionFailoverTests : 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 FailoverChannelBreakWithTransferOnRecreateV2Async(
|
|
CancellationToken ct)
|
|
{
|
|
ManagedSession session = await ConnectFailoverAsync(
|
|
nameof(FailoverChannelBreakWithTransferOnRecreateV2Async),
|
|
transferOnRecreate: true, ct).ConfigureAwait(false);
|
|
try
|
|
{
|
|
var handler = new RecordingSubscriptionHandler();
|
|
ISubscription sub = session.AddSubscription(handler,
|
|
new Opc.Ua.Client.Subscriptions.SubscriptionOptions
|
|
{
|
|
PublishingInterval = TimeSpan.FromMilliseconds(500),
|
|
KeepAliveCount = 10,
|
|
LifetimeCount = 100,
|
|
PublishingEnabled = true
|
|
});
|
|
bool created = await WaitForAsync(() => sub.Created,
|
|
TimeSpan.FromSeconds(15), ct).ConfigureAwait(false);
|
|
Assert.That(created, Is.True);
|
|
Assert.That(sub.TryAddMonitoredItem(
|
|
"Time",
|
|
VariableIds.Server_ServerStatus_CurrentTime,
|
|
o => o with { SamplingInterval = TimeSpan.FromMilliseconds(250) },
|
|
out _), Is.True);
|
|
|
|
bool firstData = await handler.WaitForFirstDataAsync(
|
|
TimeSpan.FromSeconds(15), ct).ConfigureAwait(false);
|
|
Assert.That(firstData, Is.True);
|
|
|
|
// Force channel break. The V2 manager + ManagedSession
|
|
// reconnect logic must restore the channel and then
|
|
// run TransferSubscriptions (with recreate fallback if
|
|
// the server has discarded the subscription).
|
|
int preCount = handler.DataChangeCount;
|
|
ITransportChannel? channel = session.InnerSession?.TransportChannel;
|
|
if (channel == null)
|
|
{
|
|
Assert.Inconclusive(
|
|
"InnerSession.TransportChannel is null; cannot force channel break.");
|
|
return;
|
|
}
|
|
TestContext.Out.WriteLine(
|
|
"Closing transport channel to force V2 transfer/recreate…");
|
|
channel.Dispose();
|
|
|
|
bool reconnected = await WaitForAsync(
|
|
() => session.Connected,
|
|
TimeSpan.FromSeconds(60), ct).ConfigureAwait(false);
|
|
Assert.That(reconnected, Is.True,
|
|
"Session must auto-reconnect after channel loss.");
|
|
|
|
bool subStillCreated = await WaitForAsync(
|
|
() => sub.Created, TimeSpan.FromSeconds(30), ct)
|
|
.ConfigureAwait(false);
|
|
Assert.That(subStillCreated, Is.True,
|
|
"Subscription must remain Created after the V2 " +
|
|
"TransferSubscriptions / recreate fallback path.");
|
|
|
|
// Either transfer succeeded (same ServerId) or the V2
|
|
// manager re-created the subscription (possibly with a
|
|
// new ServerId). Either way: new data must flow.
|
|
bool postData = await WaitForAsync(
|
|
() => handler.DataChangeCount > preCount,
|
|
TimeSpan.FromSeconds(30), ct).ConfigureAwait(false);
|
|
Assert.That(postData, Is.True,
|
|
"Subscription must continue to deliver data after the " +
|
|
"failover (TransferSubscriptions succeeded or recreate fallback ran).");
|
|
|
|
await sub.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
try { await session.CloseAsync().ConfigureAwait(false); }
|
|
catch { /* best effort */ }
|
|
try { await session.DisposeAsync().ConfigureAwait(false); }
|
|
catch { /* best effort */ }
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
[Order(200)]
|
|
[CancelAfter(120_000)]
|
|
public async Task FailoverChannelBreakWithoutTransferOnRecreateV2Async(
|
|
CancellationToken ct)
|
|
{
|
|
// With TransferSubscriptionsOnRecreate=false the V2 manager
|
|
// does NOT call TransferSubscriptions after a reconnect.
|
|
// The server-side subscription, however, survives the
|
|
// transport-level reconnect because the inner Session keeps
|
|
// its SessionId. So this configuration must also continue
|
|
// to deliver data without the explicit transfer call.
|
|
ManagedSession session = await ConnectFailoverAsync(
|
|
nameof(FailoverChannelBreakWithoutTransferOnRecreateV2Async),
|
|
transferOnRecreate: false, ct).ConfigureAwait(false);
|
|
try
|
|
{
|
|
var handler = new RecordingSubscriptionHandler();
|
|
ISubscription sub = session.AddSubscription(handler,
|
|
new Opc.Ua.Client.Subscriptions.SubscriptionOptions
|
|
{
|
|
PublishingInterval = TimeSpan.FromMilliseconds(500),
|
|
KeepAliveCount = 10,
|
|
LifetimeCount = 100,
|
|
PublishingEnabled = true
|
|
});
|
|
Assert.That(await WaitForAsync(() => sub.Created,
|
|
TimeSpan.FromSeconds(15), ct).ConfigureAwait(false), Is.True);
|
|
Assert.That(sub.TryAddMonitoredItem(
|
|
"Time",
|
|
VariableIds.Server_ServerStatus_CurrentTime,
|
|
o => o with { SamplingInterval = TimeSpan.FromMilliseconds(250) },
|
|
out _), Is.True);
|
|
Assert.That(await handler.WaitForFirstDataAsync(
|
|
TimeSpan.FromSeconds(15), ct).ConfigureAwait(false), Is.True);
|
|
|
|
uint preServerId = ((Opc.Ua.Client.Subscriptions.Subscription)sub).ServerId;
|
|
int preCount = handler.DataChangeCount;
|
|
ITransportChannel? channel = session.InnerSession?.TransportChannel;
|
|
if (channel == null)
|
|
{
|
|
Assert.Inconclusive(
|
|
"InnerSession.TransportChannel is null; cannot force channel break.");
|
|
return;
|
|
}
|
|
TestContext.Out.WriteLine(
|
|
"Closing transport channel — no TransferSubscriptions on recreate…");
|
|
channel.Dispose();
|
|
|
|
Assert.That(await WaitForAsync(() => session.Connected,
|
|
TimeSpan.FromSeconds(60), ct).ConfigureAwait(false), Is.True);
|
|
Assert.That(await WaitForAsync(() => sub.Created,
|
|
TimeSpan.FromSeconds(30), ct).ConfigureAwait(false), Is.True);
|
|
Assert.That(await WaitForAsync(
|
|
() => handler.DataChangeCount > preCount,
|
|
TimeSpan.FromSeconds(30), ct).ConfigureAwait(false), Is.True,
|
|
"Subscription must continue to deliver after channel reconnect");
|
|
Assert.That(((Opc.Ua.Client.Subscriptions.Subscription)sub).ServerId, Is.EqualTo(preServerId),
|
|
"Without TransferSubscriptions on recreate, the server-side " +
|
|
"ServerId should be preserved across a transport-level reconnect.");
|
|
|
|
await sub.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
try { await session.CloseAsync().ConfigureAwait(false); }
|
|
catch { /* best effort */ }
|
|
try { await session.DisposeAsync().ConfigureAwait(false); }
|
|
catch { /* best effort */ }
|
|
}
|
|
}
|
|
|
|
private async Task<ManagedSession> ConnectFailoverAsync(
|
|
string sessionName, bool transferOnRecreate, CancellationToken ct)
|
|
{
|
|
ConfiguredEndpoint endpoint = await ClientFixture
|
|
.GetEndpointAsync(ServerUrl, SecurityPolicies.None)
|
|
.ConfigureAwait(false);
|
|
var builder = new ManagedSessionBuilder(ClientFixture.Config, Telemetry)
|
|
.UseEndpoint(endpoint)
|
|
.WithSessionName(sessionName)
|
|
.WithSessionTimeout(TimeSpan.FromSeconds(120));
|
|
if (transferOnRecreate)
|
|
{
|
|
builder = builder.WithTransferSubscriptionsOnRecreate();
|
|
}
|
|
return await builder.ConnectAsync(ct).ConfigureAwait(false);
|
|
}
|
|
|
|
private static async Task<bool> WaitForAsync(
|
|
Func<bool> 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();
|
|
}
|
|
}
|
|
}
|