Files
Wolfgang ColsmanandMarc Schier 97cc145811 Client redundancy: ServiceLevel-driven failover, member ServiceLevel probing and read statuses (#4884)
# Description

## Problem

OPC 10000-4, clause 6.6.2 "Server redundancy", lets a Client of a
non-transparent redundant Server set (Cold, Warm, Hot, HotAndMirrored)
connect to the Server with the highest `ServiceLevel` and fail over when
the Server in use is lost or its `ServiceLevel` leaves the Healthy
subrange (6.6.2.4.2 "ServiceLevel": 200-255 Healthy, 2-199 Degraded, 1
NoData, 0 Maintenance; 6.6.2.4.5 "Client failover behaviour"). The
managed session on `master` covers part of this:

- `ManagedSession` fails over only in the `Failover` state of
`ConnectionStateMachine`, which is entered after the reconnect policy
gives up. `DefaultServerRedundancyHandler.ShouldFailover` /
`ServerRedundancyHandlerExtensions.ShouldFailover` implement the
subrange rules, but nothing in the client calls them: a connected
session stays on a Server that reports Degraded or Maintenance until its
connection breaks. There is no public member that starts a failover
(`HandleFailoverAsync` is private).
- Members known only from `ServerUriArray` get `ServiceLevel` NoData
with `ServiceLevelKnown = false` (internal). Their `ServiceLevel` is
never read, so neither the initial connect nor `SelectFailoverTarget`
can prefer the member with the highest `ServiceLevel`.
- `ReadRedundancyInfoAsync` maps a bad status of `RedundancySupport`,
`ServerUriArray`, `RedundantServerArray` or `CurrentServerId` to an
empty or default value, and peer endpoint resolution failures are only
logged. An application cannot report which redundancy Node the Server
refused or why a member has no endpoint.

An application that must fail over on `ServiceLevel` therefore polls the
`ServiceLevel` itself, opens its own sessions to read each member, and,
lacking a failover trigger, closes the managed session and opens a new
one - losing the transfer or recreate of its subscriptions that the
managed failover performs.

## API (all additions; defaults keep the current behaviour)

```csharp
public partial class ManagedSession
{
    public Task FailoverAsync(ConfiguredEndpoint? target = null, CancellationToken ct = default);
    public ServerRedundancyInfo? RedundancyInfo { get; }           // implements IManagedSession.RedundancyInfo
}

public interface IManagedSession : ISession
{
    ServerRedundancyInfo? RedundancyInfo { get; }                  // new interface member
}

public sealed record ServerRedundancyOptions
{
    public bool ProbeMemberServiceLevel { get; init; }             // default false
    public TimeSpan? ServiceLevelMonitoringInterval { get; init; } // default null (off); must be positive when set
}

public sealed class RedundantServer
{
    public bool ServiceLevelKnown { get; init; } = true;           // was internal
    public ServiceResult? ResolutionError { get; init; }           // new
}

public sealed class ServerRedundancyInfo
{
    public IReadOnlyDictionary<NodeId, StatusCode> ReadStatuses { get; init; } // new, empty by default
}
```

## Behaviour

1. **Read statuses and resolution errors.**
`DefaultServerRedundancyHandler` records the status of every Node it
reads from the Server in use (`RedundancySupport`, `ServiceLevel`,
`RedundantServerArray`, `ServerUriArray`, `EstimatedReturnTime`, and
`CurrentServerId` for Transparent redundancy) in
`ServerRedundancyInfo.ReadStatuses`. A peer whose endpoint resolution
fails carries the exact status in `RedundantServer.ResolutionError` (the
discovery `ServiceResult`, `BadTimeout` when the discovery bound
expires, `BadNotFound` when no endpoint matches the security and
transport in use). `IManagedSession.RedundancyInfo` exposes the last
snapshot.
2. **Member probing** (`ProbeMemberServiceLevel`). After a member's
endpoint is resolved, `ManagedSession` reads `Server.ServiceLevel` and
`Server.ServerStatus.State` of every other member over a short session
with the identity of the session in use, bounded by
`PeerDiscoveryTimeout`, and replaces the reported values. A member that
cannot be probed keeps the values reported for it and carries the
failure in `ResolutionError` (for example `BadNotReadable`). With
probing, the initial connect switches to the member with the highest
`ServiceLevel` when it is higher than that of the Server it connected to
(6.6.2.4.5).
3. **`FailoverAsync`.** Fails over a connected session to `target`, or
to the target the redundancy handler selects from a refreshed snapshot,
through the existing in-place recreate (subscriptions are transferred or
recreated; with token reuse enabled a HotAndMirrored target reactivates
the session). The connection state machine gets an internal
`RequestFailover` that enters `Failover` with the requested operation;
`ConnectionStateChanged` reports `Failover` then `Connected`. Throws
`BadNotSupported` without a redundancy handler, `BadInvalidState` when
not connected, `BadNothingToDo` when the handler selects no target, and
the failover error otherwise. A failed requested failover transitions to
`Reconnecting` (not `Disconnected` plus the recovery timer), so the
reconnect policy restores the session at once. Cancelling `ct` stops
waiting; a failover in progress completes.
4. **ServiceLevel monitoring** (`ServiceLevelMonitoringInterval`). While
connected to a non-transparent set, `ManagedSession` reads the
`ServiceLevel` of the Server in use at the interval. While it is Healthy
nothing else happens (a Healthy Server is not left, 6.6.2.4.3).
Otherwise the snapshot is refreshed (with probing when enabled),
`ShouldFailover` decides, and a warranted failover runs `FailoverAsync`
to the handler's target. The loop ends with the session.

The switch logic of the automatic failover (`HandleFailoverAsync`) is
factored into a helper shared with the initial selection and
`FailoverAsync`; its behaviour is unchanged.

## Tests

- `ManagedSessionServerRedundancyTests` (new, in-process Warm set of two
`ReferenceServer` fixtures with distinct ApplicationUris,
`Server.ServerRedundancy` promoted to `NonTransparentRedundancyType`
with each other in `ServerUriArray`, `ServiceLevel` set through
`IServerServiceLevelControl`, endpoints resolved by a test
`IRedundantServerEndpointResolver`):
- without probing the session stays on the configured Server; the peer
has `ServiceLevelKnown = false` (defaults unchanged);
- with probing the initial connect selects the member with the higher
`ServiceLevel` (250 over 210);
- a member that refuses the `ServiceLevel` read keeps an unknown
`ServiceLevel` and reports `BadNotReadable` in `ResolutionError`;
- a refused `RedundantServerArray` reports `BadNotReadable` in
`ReadStatuses`;
- lowering the `ServiceLevel` of the Server in use to 150 while the peer
reports 230 fails over within the monitoring interval,
`ConnectionStateChanged` reports only `Failover` and `Connected`, the
same subscription object keeps delivering data changes from the new
Server (sequence numbers restart there, as the subscription is recreated
on another Server);
- `FailoverAsync` to an explicit target and to the handler-selected
target;
- `FailoverAsync` without a redundancy handler throws `BadNotSupported`.
- `ServerRedundancyHandlerTests`: resolution error of a refusing and of
an unresolved peer, read status of each Node, validation of the
monitoring interval.
- `ConnectionStateMachineTests`: a requested failover runs its operation
in `Failover` and bypasses the failover delegate; a failed requested
failover reconnects without `Disconnected`; `RequestFailover` is refused
when not connected.

## Compatibility

- Source/binary additions only, except that `IManagedSession` gains
`RedundancyInfo`; `ManagedSession` is the only implementation in the
repository, external implementers of `IManagedSession` must add the
property.
- `RedundantServer.ServiceLevelKnown` changes from internal to public
(same default `true`).
- With the default options nothing changes at runtime: no probing, no
monitoring, the automatic failover path is unchanged. The only
default-visible differences are the new `ReadStatuses`/`ResolutionError`
data.
- Probing opens one short additional session per member;
reverse-connect-only members cannot be probed and report the probe
failure in `ResolutionError`.

## Related Issues

- No tracking issue; the problem and the design are described above.

## Checklist

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [ ] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. (Run
locally: the affected test classes on net48 and net10.0 and the whole
Opc.Ua.Client.Tests project on net10.0; not the whole UA.slnx.)
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

---------

Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
2026-10-23 05:58:57 +02:00

1417 lines
64 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.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Time.Testing;
using Moq;
using NUnit.Framework;
using Opc.Ua.Tests;
namespace Opc.Ua.Client.Tests.ManagedSession
{
/// <summary>
/// Unit tests for <see cref="DefaultServerRedundancyHandler"/>.
/// </summary>
[TestFixture]
[Category("Client")]
[Category("ServerRedundancy")]
public sealed class ServerRedundancyHandlerTests
{
private static readonly DateTime s_now = new(2026, 6, 27, 4, 0, 0, DateTimeKind.Utc);
private Mock<IRedundantServerEndpointResolver> m_resolver = null!;
private DefaultServerRedundancyHandler m_handler = null!;
/// <summary>
/// Creates a strict endpoint resolver mock and a redundancy handler with a fixed clock for each scenario.
/// </summary>
[SetUp]
public void SetUp()
{
m_resolver = new Mock<IRedundantServerEndpointResolver>(MockBehavior.Strict);
m_handler = new DefaultServerRedundancyHandler(m_resolver.Object, new FixedTimeProvider(s_now));
}
/// <summary>
/// Verifies that server redundancy support values map to the corresponding redundancy modes.
/// </summary>
[TestCase(0, RedundancySupport.None)]
[TestCase(1, RedundancySupport.Cold)]
[TestCase(2, RedundancySupport.Warm)]
[TestCase(3, RedundancySupport.Hot)]
[TestCase(4, RedundancySupport.Transparent)]
[TestCase(5, RedundancySupport.HotAndMirrored)]
public async Task FetchRedundancyInfoMapsRedundancySupportAsync(
int value,
RedundancySupport expected)
{
Mock<ISession> mockSession = CreateMockSession(
redundancySupport: value,
serviceLevel: ServiceLevels.HealthyMinimum);
ServerRedundancyInfo info = await m_handler.FetchRedundancyInfoAsync(
mockSession.Object).ConfigureAwait(false);
Assert.That(info.Mode, Is.EqualTo(expected));
}
[TestCase(RedundancySupport.Cold)]
[TestCase(RedundancySupport.Warm)]
public async Task BasicRedundancyReadRetainsUnresolvedPeersWithoutDiscoveryAsync(RedundancySupport mode)
{
Mock<ISession> session = CreateMockSession(
(int)mode, ServiceLevels.Maximum, serverUris: ["urn:offline"]);
ServerRedundancyInfo snapshot = await ((IServerRedundancyEndpointCache)m_handler)
.ReadRedundancyInfoAsync(session.Object, CancellationToken.None).ConfigureAwait(false);
Assert.That(snapshot.Mode, Is.EqualTo(mode));
Assert.That(snapshot.ServiceLevel, Is.EqualTo(ServiceLevels.Maximum));
Assert.That(snapshot.RedundantServers.Count, Is.EqualTo(1));
Assert.That(snapshot.RedundantServers[0].ServerUri, Is.EqualTo("urn:offline"));
Assert.That(snapshot.RedundantServers[0].Endpoint, Is.Null);
m_resolver.VerifyNoOtherCalls();
VerifyBatchedRedundancyRead(session);
}
[TestCase(false)]
[TestCase(true)]
public async Task PeerTimeoutPreservesOtherEndpointsAndCoalescesPendingDiscoveryAsync(bool ignoreCancellation)
{
var time = new FakeTimeProvider();
var handler = new DefaultServerRedundancyHandler(m_resolver.Object, time);
ConfiguredEndpoint available = CreateEndpoint("urn:available", "opc.tcp://available:4840");
var blocked = new TaskCompletionSource<ConfiguredEndpoint?>(
TaskCreationOptions.RunContinuationsAsynchronously);
var secondLookup = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
int lookups = 0;
m_resolver.Setup(resolver => resolver.ResolveAsync(
"urn:offline", It.IsAny<ConfiguredEndpoint>(), It.IsAny<CancellationToken>()))
.Returns((string _, ConfiguredEndpoint _, CancellationToken ct) =>
{
if (Interlocked.Increment(ref lookups) == 2)
{
secondLookup.TrySetResult(true);
}
return new ValueTask<ConfiguredEndpoint?>(ignoreCancellation
? blocked.Task
: blocked.Task.WaitAsync(ct));
});
m_resolver.Setup(resolver => resolver.ResolveAsync(
"urn:available", It.IsAny<ConfiguredEndpoint>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(available);
Mock<ISession> session = CreateMockSession(
(int)RedundancySupport.Cold, ServiceLevels.Maximum, serverUris: ["urn:offline", "urn:available"]);
var cache = (IServerRedundancyEndpointCache)handler;
ServerRedundancyInfo basic = await cache.ReadRedundancyInfoAsync(session.Object, CancellationToken.None)
.ConfigureAwait(false);
try
{
Task<ServerRedundancyInfo> resolving = cache.ResolveCachedEndpointsAsync(
basic, session.Object.ConfiguredEndpoint, CancellationToken.None).AsTask();
m_resolver.Verify(resolver => resolver.ResolveAsync(
"urn:available", It.IsAny<ConfiguredEndpoint>(), It.IsAny<CancellationToken>()), Times.Once);
Assert.That(resolving.IsCompleted, Is.False);
time.Advance(TimeSpan.FromSeconds(2));
ServerRedundancyInfo resolved = await resolving.WaitAsync(TimeSpan.FromSeconds(10))
.ConfigureAwait(false);
Assert.That(resolved.Mode, Is.EqualTo(RedundancySupport.Cold));
Assert.That(resolved.RedundantServers[0].Endpoint, Is.Null);
Assert.That(resolved.RedundantServers[1].Endpoint, Is.SameAs(available));
Assert.That(handler.SelectFailoverTarget(resolved, session.Object.ConfiguredEndpoint),
Is.SameAs(available));
Task<ServerRedundancyInfo> retry = cache.ResolveCachedEndpointsAsync(
resolved, session.Object.ConfiguredEndpoint, CancellationToken.None).AsTask();
if (!ignoreCancellation)
{
// The timed-out lookup drains on the thread pool, so the retry may first join it and only
// start its own lookup once it has drained. Advancing the clock before then would expire
// the retry's bound too, so wait for the fresh lookup.
await secondLookup.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
}
time.Advance(TimeSpan.FromSeconds(2));
await retry.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
m_resolver.Verify(resolver => resolver.ResolveAsync(
"urn:offline", It.IsAny<ConfiguredEndpoint>(), It.IsAny<CancellationToken>()),
Times.Exactly(ignoreCancellation ? 1 : 2));
m_resolver.Verify(resolver => resolver.ResolveAsync(
"urn:available", It.IsAny<ConfiguredEndpoint>(), It.IsAny<CancellationToken>()), Times.Once);
}
finally
{
blocked.TrySetResult(null);
}
}
[Test]
public async Task ConfiguredPeerDiscoveryTimeoutReplacesTheDefaultBoundAsync()
{
var time = new FakeTimeProvider();
var handler = new DefaultServerRedundancyHandler(
m_resolver.Object,
time,
new ServerRedundancyOptions { PeerDiscoveryTimeout = TimeSpan.FromSeconds(30) });
var blocked = new TaskCompletionSource<ConfiguredEndpoint?>(
TaskCreationOptions.RunContinuationsAsynchronously);
m_resolver.Setup(resolver => resolver.ResolveAsync(
"urn:offline", It.IsAny<ConfiguredEndpoint>(), It.IsAny<CancellationToken>()))
.Returns((string _, ConfiguredEndpoint _, CancellationToken ct) =>
new ValueTask<ConfiguredEndpoint?>(blocked.Task.WaitAsync(ct)));
Mock<ISession> session = CreateMockSession(
(int)RedundancySupport.Cold, ServiceLevels.Maximum, serverUris: ["urn:offline"]);
var cache = (IServerRedundancyEndpointCache)handler;
ServerRedundancyInfo basic = await cache.ReadRedundancyInfoAsync(session.Object, CancellationToken.None)
.ConfigureAwait(false);
try
{
Task<ServerRedundancyInfo> resolving = cache.ResolveCachedEndpointsAsync(
basic, session.Object.ConfiguredEndpoint, CancellationToken.None).AsTask();
time.Advance(TimeSpan.FromSeconds(2));
await Task.Yield();
Assert.That(resolving.IsCompleted, Is.False);
time.Advance(TimeSpan.FromSeconds(28));
ServerRedundancyInfo resolved = await resolving.WaitAsync(TimeSpan.FromSeconds(10))
.ConfigureAwait(false);
Assert.That(resolved.RedundantServers[0].Endpoint, Is.Null);
}
finally
{
blocked.TrySetResult(null);
}
}
[Test]
public void ServerRedundancyOptionsRejectNonPositiveBounds()
{
Assert.Multiple(() =>
{
Assert.That(
() => new ServerRedundancyOptions { RefreshTimeout = TimeSpan.Zero }.Validate(),
Throws.InstanceOf<ArgumentOutOfRangeException>());
Assert.That(
() => new ServerRedundancyOptions
{
PeerDiscoveryTimeout = TimeSpan.FromSeconds(-5)
}.Validate(),
Throws.InstanceOf<ArgumentOutOfRangeException>());
Assert.That(
() => new ServerRedundancyOptions
{
RefreshTimeout = Timeout.InfiniteTimeSpan,
PeerDiscoveryTimeout = Timeout.InfiniteTimeSpan
}.Validate(),
Throws.Nothing);
Assert.That(new ServerRedundancyOptions().RefreshTimeout,
Is.EqualTo(ServerRedundancyOptions.DefaultTimeout));
});
}
[TestCase(false)]
[TestCase(true)]
public async Task PeerDiscoveryTimeoutCancelsResolverBeforeOwnedTimerFiresAsync(bool ignoreCancellation)
{
var clock = new FakeTimeProvider();
var timeProvider = new Mock<TimeProvider> { CallBase = true };
timeProvider.Setup(provider => provider.GetUtcNow()).Returns(clock.GetUtcNow);
timeProvider.Setup(provider => provider.GetTimestamp()).Returns(clock.GetTimestamp);
timeProvider.SetupGet(provider => provider.TimestampFrequency).Returns(clock.TimestampFrequency);
int timerCount = 0;
timeProvider.Setup(provider => provider.CreateTimer(
It.IsAny<TimerCallback>(), It.IsAny<object?>(),
It.IsAny<TimeSpan>(), It.IsAny<TimeSpan>()))
.Returns((TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) =>
{
if (Interlocked.Increment(ref timerCount) == 1)
{
// Deliver the outer wait's deadline before the owned cancellation timer.
dueTime += TimeSpan.FromSeconds(1);
}
return clock.CreateTimer(callback, state, dueTime, period);
});
var timeout = TimeSpan.FromSeconds(1);
var handler = new DefaultServerRedundancyHandler(
m_resolver.Object, timeProvider.Object,
new ServerRedundancyOptions { PeerDiscoveryTimeout = timeout });
var blocked = new TaskCompletionSource<ConfiguredEndpoint?>(
TaskCreationOptions.RunContinuationsAsynchronously);
CancellationToken discoveryToken = default;
int resolverCalls = 0;
ConfiguredEndpoint freshEndpoint = CreateEndpoint("urn:offline", "opc.tcp://fresh:4840");
m_resolver.Setup(resolver => resolver.ResolveAsync(
"urn:offline", It.IsAny<ConfiguredEndpoint>(), It.IsAny<CancellationToken>()))
.Returns((string _, ConfiguredEndpoint _, CancellationToken ct) =>
{
if (Interlocked.Increment(ref resolverCalls) == 1)
{
discoveryToken = ct;
return new ValueTask<ConfiguredEndpoint?>(
ignoreCancellation ? blocked.Task : blocked.Task.WaitAsync(ct));
}
return new ValueTask<ConfiguredEndpoint?>(freshEndpoint);
});
Mock<ISession> session = CreateMockSession(
(int)RedundancySupport.Cold, ServiceLevels.Maximum, serverUris: ["urn:offline"]);
var cache = (IServerRedundancyEndpointCache)handler;
ServerRedundancyInfo basic = await cache.ReadRedundancyInfoAsync(
session.Object, CancellationToken.None).ConfigureAwait(false);
try
{
Task<ServerRedundancyInfo> resolving = cache.ResolveCachedEndpointsAsync(
basic, session.Object.ConfiguredEndpoint, CancellationToken.None).AsTask();
Assert.That(timerCount, Is.EqualTo(2));
Assert.That(discoveryToken.CanBeCanceled, Is.True);
clock.Advance(timeout);
ServerRedundancyInfo resolved = await resolving.WaitAsync(TimeSpan.FromSeconds(5))
.ConfigureAwait(false);
Assert.That(resolved.RedundantServers[0].Endpoint, Is.Null);
Assert.That(discoveryToken.IsCancellationRequested, Is.True);
m_resolver.Verify(resolver => resolver.ResolveAsync(
"urn:offline", It.IsAny<ConfiguredEndpoint>(), It.IsAny<CancellationToken>()), Times.Once);
blocked.TrySetResult(CreateEndpoint("urn:offline", "opc.tcp://late:4840"));
ServerRedundancyInfo retried = await cache.ResolveCachedEndpointsAsync(
basic, session.Object.ConfiguredEndpoint, CancellationToken.None).AsTask()
.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
Assert.That(retried.RedundantServers[0].Endpoint, Is.SameAs(freshEndpoint));
m_resolver.Verify(resolver => resolver.ResolveAsync(
"urn:offline", It.IsAny<ConfiguredEndpoint>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
}
finally
{
blocked.TrySetResult(null);
}
}
[Test]
public async Task PeerDiscoveryPropagatesCallerCancellationAsync()
{
using var cancellation = new CancellationTokenSource();
var resolverEntered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var blocked = new TaskCompletionSource<ConfiguredEndpoint?>(
TaskCreationOptions.RunContinuationsAsynchronously);
CancellationToken discoveryToken = default;
m_resolver.Setup(resolver => resolver.ResolveAsync(
"urn:offline", It.IsAny<ConfiguredEndpoint>(), It.IsAny<CancellationToken>()))
.Returns((string _, ConfiguredEndpoint _, CancellationToken ct) =>
{
discoveryToken = ct;
resolverEntered.TrySetResult(true);
return new ValueTask<ConfiguredEndpoint?>(blocked.Task.WaitAsync(ct));
});
Mock<ISession> session = CreateMockSession(
(int)RedundancySupport.Cold, ServiceLevels.Maximum, serverUris: ["urn:offline"]);
var cache = (IServerRedundancyEndpointCache)m_handler;
ServerRedundancyInfo basic = await cache.ReadRedundancyInfoAsync(session.Object, cancellation.Token)
.ConfigureAwait(false);
Task<ServerRedundancyInfo> resolving = cache.ResolveCachedEndpointsAsync(
basic, session.Object.ConfiguredEndpoint, cancellation.Token).AsTask();
await resolverEntered.Task.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
cancellation.Cancel();
await Assert.ThatAsync(
() => resolving,
Throws.InstanceOf<OperationCanceledException>()).ConfigureAwait(false);
Assert.That(discoveryToken.IsCancellationRequested, Is.True);
Assert.That(basic.RedundantServers[0].Endpoint, Is.Null);
}
[Test]
public async Task CancelledMetadataReadDoesNotStartPeerDiscoveryAsync()
{
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
Mock<ISession> session = CreateMockSession(
(int)RedundancySupport.Cold, ServiceLevels.Maximum, serverUris: ["urn:offline"]);
await Assert.ThatAsync(
async () => await m_handler.FetchRedundancyInfoAsync(session.Object, cancellation.Token)
.ConfigureAwait(false),
Throws.InstanceOf<OperationCanceledException>()).ConfigureAwait(false);
session.VerifyNoOtherCalls();
m_resolver.VerifyNoOtherCalls();
}
[Test]
public async Task CachedResolutionDoesNotCrossEndpointSecurityProfilesAsync()
{
Mock<ISession> session = CreateMockSession(
(int)RedundancySupport.Cold, ServiceLevels.Maximum, serverUris: ["urn:peer"]);
ConfiguredEndpoint unsecured = CreateEndpoint("urn:peer", "opc.tcp://peer:4840");
ConfiguredEndpoint secured = CreateEndpoint("urn:peer", "opc.tcp://peer:4840");
secured.Description.SecurityMode = MessageSecurityMode.Sign;
secured.Description.SecurityPolicyUri = SecurityPolicies.Basic256Sha256;
m_resolver.Setup(resolver => resolver.ResolveAsync(
"urn:peer", It.Is<ConfiguredEndpoint>(endpoint =>
endpoint.Description.SecurityMode == MessageSecurityMode.None),
It.IsAny<CancellationToken>()))
.ReturnsAsync(unsecured);
m_resolver.Setup(resolver => resolver.ResolveAsync(
"urn:peer", It.Is<ConfiguredEndpoint>(endpoint =>
endpoint.Description.SecurityMode == MessageSecurityMode.Sign),
It.IsAny<CancellationToken>()))
.ReturnsAsync(secured);
ServerRedundancyInfo first = await m_handler.FetchRedundancyInfoAsync(session.Object)
.ConfigureAwait(false);
Assert.That(first.RedundantServers[0].Endpoint, Is.SameAs(unsecured));
session.Object.ConfiguredEndpoint.Description.SecurityMode = MessageSecurityMode.Sign;
session.Object.ConfiguredEndpoint.Description.SecurityPolicyUri = SecurityPolicies.Basic256Sha256;
ServerRedundancyInfo second = await m_handler.FetchRedundancyInfoAsync(session.Object)
.ConfigureAwait(false);
Assert.That(second.RedundantServers[0].Endpoint, Is.SameAs(secured));
m_resolver.Verify(resolver => resolver.ResolveAsync(
"urn:peer", It.IsAny<ConfiguredEndpoint>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
}
[Test]
public async Task RetriedPeerDiscoveryRejectsLateCancelledResultAsync()
{
var time = new FakeTimeProvider();
var handler = new DefaultServerRedundancyHandler(m_resolver.Object, time);
ConfiguredEndpoint stale = CreateEndpoint("urn:peer", "opc.tcp://peer:4840");
ConfiguredEndpoint current = CreateEndpoint("urn:peer", "opc.tcp://peer:4841");
var blocked = new TaskCompletionSource<ConfiguredEndpoint?>(
TaskCreationOptions.RunContinuationsAsynchronously);
m_resolver.SetupSequence(resolver => resolver.ResolveAsync(
"urn:peer", It.IsAny<ConfiguredEndpoint>(), It.IsAny<CancellationToken>()))
.Returns(new ValueTask<ConfiguredEndpoint?>(blocked.Task))
.ReturnsAsync(current);
Mock<ISession> session = CreateMockSession(
(int)RedundancySupport.Cold, ServiceLevels.Maximum, serverUris: ["urn:peer"]);
var cache = (IServerRedundancyEndpointCache)handler;
ServerRedundancyInfo basic = await cache.ReadRedundancyInfoAsync(session.Object, CancellationToken.None)
.ConfigureAwait(false);
Task<ServerRedundancyInfo> first = cache.ResolveCachedEndpointsAsync(
basic, session.Object.ConfiguredEndpoint, CancellationToken.None).AsTask();
time.Advance(TimeSpan.FromSeconds(2));
ServerRedundancyInfo unresolved = await first.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
Assert.That(unresolved.RedundantServers[0].Endpoint, Is.Null);
Task<ServerRedundancyInfo> retry = cache.ResolveCachedEndpointsAsync(
unresolved, session.Object.ConfiguredEndpoint, CancellationToken.None).AsTask();
m_resolver.Verify(resolver => resolver.ResolveAsync(
"urn:peer", It.IsAny<ConfiguredEndpoint>(), It.IsAny<CancellationToken>()), Times.Once);
blocked.TrySetResult(stale);
ServerRedundancyInfo resolved = await retry.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
Assert.That(resolved.RedundantServers[0].Endpoint, Is.SameAs(current));
ServerRedundancyInfo cached = await cache.ReadRedundancyInfoAsync(session.Object, CancellationToken.None)
.ConfigureAwait(false);
Assert.That(cached.RedundantServers[0].Endpoint, Is.SameAs(current));
m_resolver.Verify(resolver => resolver.ResolveAsync(
"urn:peer", It.IsAny<ConfiguredEndpoint>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
}
/// <summary>
/// Verifies that a healthy server avoids proactive failover while retaining a recovery target.
/// </summary>
[Test]
public void HealthyServerDoesNotProactivelyFailOverButRetainsRecoveryTarget()
{
var info = new ServerRedundancyInfo
{
Mode = RedundancySupport.Hot,
ServiceLevel = ServiceLevels.HealthyMinimum,
ServiceLevelAccessible = true,
ServiceLevelSubrange = ServiceLevelSubrange.Healthy,
RedundantServers =
[
CreateServerInfo("urn:backup", ServiceLevels.Maximum, ServerState.Running)
]
};
ServerFailoverDecision decision = m_handler.ShouldFailover(
info, CreateCurrentEndpoint("urn:current"));
ConfiguredEndpoint? target = m_handler.SelectFailoverTarget(
info, CreateCurrentEndpoint("urn:current"));
Assert.That(decision.IsFailoverWarranted, Is.False);
Assert.That(target, Is.Not.Null);
Assert.That(
target!.Description.Server.ApplicationUri,
Is.EqualTo("urn:backup"));
}
/// <summary>
/// Verifies that an interface-typed call (the extension method) applies the
/// same health checks as the concrete handler.
/// </summary>
[Test]
public void InterfaceShouldFailoverDoesNotFailOverFromHealthyServer()
{
var info = new ServerRedundancyInfo
{
Mode = RedundancySupport.Hot,
ServiceLevel = ServiceLevels.HealthyMinimum,
ServiceLevelAccessible = true,
ServiceLevelSubrange = ServiceLevelSubrange.Healthy,
RedundantServers =
[
CreateServerInfo("urn:backup", ServiceLevels.Maximum, ServerState.Running)
]
};
IServerRedundancyHandler handler = m_handler;
ServerFailoverDecision decision = handler.ShouldFailover(
info, CreateCurrentEndpoint("urn:current"));
Assert.That(decision.IsFailoverWarranted, Is.False);
}
/// <summary>
/// Verifies that the extension fallback for a custom handler does not fail
/// over from a Healthy server even when a target is available.
/// </summary>
[Test]
public void CustomHandlerShouldFailoverDoesNotFailOverFromHealthyServer()
{
var info = new ServerRedundancyInfo
{
Mode = RedundancySupport.Hot,
ServiceLevel = ServiceLevels.Maximum,
ServiceLevelAccessible = true,
ServiceLevelSubrange = ServiceLevelSubrange.Healthy
};
var custom = new Mock<IServerRedundancyHandler>();
custom.Setup(h => h.SelectFailoverTarget(It.IsAny<ServerRedundancyInfo>(), It.IsAny<ConfiguredEndpoint>()))
.Returns(CreateCurrentEndpoint("urn:backup"));
ServerFailoverDecision healthy = custom.Object.ShouldFailover(
info, CreateCurrentEndpoint("urn:current"));
ServerFailoverDecision degraded = custom.Object.ShouldFailover(
new ServerRedundancyInfo
{
Mode = RedundancySupport.Hot,
ServiceLevel = ServiceLevels.DegradedMaximum,
ServiceLevelAccessible = true,
ServiceLevelSubrange = ServiceLevelSubrange.Degraded
},
CreateCurrentEndpoint("urn:current"));
Assert.That(healthy.IsFailoverWarranted, Is.False);
Assert.That(degraded.IsFailoverWarranted, Is.True);
}
/// <summary>
/// Verifies that a degraded server fails over to a healthy peer.
/// </summary>
[Test]
public void ShouldFailoverSwitchesFromDegradedToHealthyPeer()
{
var info = new ServerRedundancyInfo
{
Mode = RedundancySupport.Hot,
ServiceLevel = ServiceLevels.DegradedMaximum,
ServiceLevelAccessible = true,
ServiceLevelSubrange = ServiceLevelSubrange.Degraded,
RedundantServers =
[
CreateServerInfo("urn:degraded", 150, ServerState.Running),
CreateServerInfo("urn:healthy", 230, ServerState.Running)
]
};
ServerFailoverDecision decision = m_handler.ShouldFailover(
info, CreateCurrentEndpoint("urn:current"));
ConfiguredEndpoint? target = m_handler.SelectFailoverTarget(
info, CreateCurrentEndpoint("urn:current"));
Assert.That(decision.IsFailoverWarranted, Is.True);
Assert.That(target, Is.Not.Null);
Assert.That(target!.Description.Server.ApplicationUri, Is.EqualTo("urn:healthy"));
}
/// <summary>
/// Verifies that a degraded server does not fail over when no healthy peer is available.
/// </summary>
[Test]
public void ShouldFailoverDoesNotSwitchFromDegradedWithoutHealthyPeer()
{
var info = new ServerRedundancyInfo
{
Mode = RedundancySupport.Hot,
ServiceLevel = 100,
ServiceLevelAccessible = true,
ServiceLevelSubrange = ServiceLevelSubrange.Degraded,
RedundantServers =
[
CreateServerInfo("urn:degraded", 150, ServerState.Running)
]
};
ServerFailoverDecision decision = m_handler.ShouldFailover(
info, CreateCurrentEndpoint("urn:current"));
Assert.That(decision.IsFailoverWarranted, Is.False);
}
/// <summary>
/// Verifies that a server in maintenance fails over to an operational peer.
/// </summary>
[Test]
public void ShouldFailoverSwitchesFromMaintenanceToOperationalPeer()
{
var info = new ServerRedundancyInfo
{
Mode = RedundancySupport.Hot,
ServiceLevel = ServiceLevels.Maintenance,
ServiceLevelAccessible = true,
ServiceLevelSubrange = ServiceLevelSubrange.Maintenance,
EstimatedReturnTime = s_now.AddMinutes(10),
RedundantServers =
[
CreateServerInfo("urn:healthy", 230, ServerState.Running)
]
};
ServerFailoverDecision decision = m_handler.ShouldFailover(
info, CreateCurrentEndpoint("urn:current"));
ConfiguredEndpoint? target = m_handler.SelectFailoverTarget(
info, CreateCurrentEndpoint("urn:current"));
Assert.That(decision.IsFailoverWarranted, Is.True);
Assert.That(target, Is.Not.Null);
Assert.That(target!.Description.Server.ApplicationUri, Is.EqualTo("urn:healthy"));
}
/// <summary>
/// Verifies that failover timing honors a maintenance server's estimated return time.
/// </summary>
[Test]
public void ShouldFailoverHonorsMaintenanceEstimatedReturnTime()
{
DateTime estimatedReturnTime = s_now.AddMinutes(10);
var info = new ServerRedundancyInfo
{
Mode = RedundancySupport.Hot,
ServiceLevel = ServiceLevels.Maintenance,
ServiceLevelAccessible = true,
ServiceLevelSubrange = ServiceLevelSubrange.Maintenance,
EstimatedReturnTime = estimatedReturnTime,
RedundantServers =
[
CreateServerInfo("urn:shutdown", 230, ServerState.Shutdown)
]
};
ServerFailoverDecision decision = m_handler.ShouldFailover(
info, CreateCurrentEndpoint("urn:current"));
Assert.That(decision.IsFailoverWarranted, Is.False);
Assert.That(decision.RetryAfter, Is.EqualTo(estimatedReturnTime));
}
/// <summary>
/// Verifies that failover uses backoff after the maintenance return estimate has elapsed.
/// </summary>
[Test]
public void ShouldFailoverUsesBackoffWhenMaintenanceReturnTimeLapsed()
{
var info = new ServerRedundancyInfo
{
Mode = RedundancySupport.Hot,
ServiceLevel = ServiceLevels.Maintenance,
ServiceLevelAccessible = true,
ServiceLevelSubrange = ServiceLevelSubrange.Maintenance,
EstimatedReturnTime = s_now.AddMinutes(-1),
RedundantServers =
[
CreateServerInfo("urn:shutdown", 230, ServerState.Shutdown)
]
};
ServerFailoverDecision decision = m_handler.ShouldFailover(
info, CreateCurrentEndpoint("urn:current"));
Assert.That(decision.IsFailoverWarranted, Is.False);
Assert.That(
decision.RetryAfter,
Is.EqualTo(s_now.Add(DefaultServerRedundancyHandler.DefaultMaintenanceBackoff)));
}
/// <summary>
/// Verifies that failover uses backoff when no maintenance return estimate is available.
/// </summary>
[Test]
public void ShouldFailoverUsesBackoffWhenMaintenanceReturnTimeIsAbsent()
{
var info = new ServerRedundancyInfo
{
Mode = RedundancySupport.Hot,
ServiceLevel = ServiceLevels.Maintenance,
ServiceLevelAccessible = true,
ServiceLevelSubrange = ServiceLevelSubrange.Maintenance,
EstimatedReturnTime = DateTime.MinValue,
RedundantServers =
[
CreateServerInfo("urn:shutdown", 230, ServerState.Shutdown)
]
};
ServerFailoverDecision decision = m_handler.ShouldFailover(
info, CreateCurrentEndpoint("urn:current"));
Assert.That(decision.IsFailoverWarranted, Is.False);
Assert.That(
decision.RetryAfter,
Is.EqualTo(s_now.Add(DefaultServerRedundancyHandler.DefaultMaintenanceBackoff)));
}
/// <summary>
/// Verifies that no failover is requested when all peers are down.
/// </summary>
[Test]
public void ShouldFailoverReturnsNoFailoverWhenAllPeersAreDown()
{
var info = new ServerRedundancyInfo
{
Mode = RedundancySupport.Hot,
ServiceLevel = ServiceLevels.NoData,
ServiceLevelAccessible = true,
ServiceLevelSubrange = ServiceLevelSubrange.NoData,
RedundantServers =
[
CreateServerInfo("urn:shutdown", ServiceLevels.Maximum, ServerState.Shutdown),
CreateServerInfo("urn:suspended", ServiceLevels.Maximum, ServerState.Suspended)
]
};
ServerFailoverDecision decision = m_handler.ShouldFailover(
info, CreateCurrentEndpoint("urn:current"));
ConfiguredEndpoint? target = m_handler.SelectFailoverTarget(
info, CreateCurrentEndpoint("urn:current"));
Assert.That(decision.IsFailoverWarranted, Is.False);
Assert.That(target, Is.Null);
}
/// <summary>
/// Verifies that transparent redundancy does not select a client-side failover target.
/// </summary>
[Test]
public void SelectFailoverTargetReturnsNullForTransparentMode()
{
var info = new ServerRedundancyInfo
{
Mode = RedundancySupport.Transparent,
ServiceLevel = ServiceLevels.NoData,
ServiceLevelAccessible = true,
ServiceLevelSubrange = ServiceLevelSubrange.NoData,
RedundantServers =
[
CreateServerInfo("urn:backup", 230, ServerState.Running)
]
};
ConfiguredEndpoint? result = m_handler.SelectFailoverTarget(
info, CreateCurrentEndpoint("urn:current"));
Assert.That(result, Is.Null);
}
/// <summary>
/// Verifies that disabled redundancy does not select a failover target.
/// </summary>
[Test]
public void SelectFailoverTargetReturnsNullForNoneMode()
{
var info = new ServerRedundancyInfo
{
Mode = RedundancySupport.None,
ServiceLevel = ServiceLevels.NoData,
ServiceLevelAccessible = false,
ServiceLevelSubrange = ServiceLevelSubrange.NoData,
RedundantServers =
[
CreateServerInfo("urn:backup", 230, ServerState.Running)
]
};
ConfiguredEndpoint? result = m_handler.SelectFailoverTarget(
info, CreateCurrentEndpoint("urn:current"));
Assert.That(result, Is.Null);
}
/// <summary>
/// Verifies that failover target selection excludes the current endpoint.
/// </summary>
[Test]
public void SelectFailoverTargetSkipsCurrentEndpoint()
{
var info = new ServerRedundancyInfo
{
Mode = RedundancySupport.Hot,
ServiceLevel = ServiceLevels.NoData,
ServiceLevelAccessible = true,
ServiceLevelSubrange = ServiceLevelSubrange.NoData,
RedundantServers =
[
CreateServerInfo("urn:current", ServiceLevels.Maximum, ServerState.Running),
CreateServerInfo("urn:backup", 230, ServerState.Running)
]
};
ConfiguredEndpoint? result = m_handler.SelectFailoverTarget(
info, CreateCurrentEndpoint("urn:current"));
Assert.That(result, Is.Not.Null);
Assert.That(result!.Description.Server.ApplicationUri, Is.EqualTo("urn:backup"));
}
/// <summary>
/// Verifies that failover target selection excludes servers that are not running.
/// </summary>
[Test]
public void SelectFailoverTargetSkipsNonRunningServers()
{
var info = new ServerRedundancyInfo
{
Mode = RedundancySupport.Hot,
ServiceLevel = ServiceLevels.NoData,
ServiceLevelAccessible = true,
ServiceLevelSubrange = ServiceLevelSubrange.NoData,
RedundantServers =
[
CreateServerInfo("urn:suspended", ServiceLevels.Maximum, ServerState.Suspended),
CreateServerInfo("urn:shutdown", 240, ServerState.Shutdown),
CreateServerInfo("urn:running", 230, ServerState.Running)
]
};
ConfiguredEndpoint? result = m_handler.SelectFailoverTarget(
info, CreateCurrentEndpoint("urn:current"));
Assert.That(result, Is.Not.Null);
Assert.That(result!.Description.Server.ApplicationUri, Is.EqualTo("urn:running"));
}
/// <summary>
/// Verifies that target selection returns null when no viable server remains.
/// </summary>
[Test]
public void SelectFailoverTargetReturnsNullWhenNoViableServers()
{
var info = new ServerRedundancyInfo
{
Mode = RedundancySupport.Hot,
ServiceLevel = ServiceLevels.NoData,
ServiceLevelAccessible = true,
ServiceLevelSubrange = ServiceLevelSubrange.NoData,
RedundantServers =
[
CreateServerInfo("urn:current", 230, ServerState.Running),
CreateServerInfo("urn:down", 230, ServerState.Shutdown)
]
};
ConfiguredEndpoint? result = m_handler.SelectFailoverTarget(
info, CreateCurrentEndpoint("urn:current"));
Assert.That(result, Is.Null);
}
/// <summary>
/// Verifies that redundancy discovery reads the redundant server array.
/// </summary>
[Test]
public async Task FetchRedundancyInfoReadsRedundantServerArrayAsync()
{
var serverData = new RedundantServerDataType
{
ServerId = "urn:server1",
ServiceLevel = 230,
ServerState = ServerState.Running
};
ConfiguredEndpoint resolvedEndpoint = CreateEndpoint("urn:server1", "opc.tcp://server1:4840");
m_resolver.Setup(r => r.ResolveAsync(
"urn:server1",
It.IsAny<ConfiguredEndpoint>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(resolvedEndpoint);
Mock<ISession> mockSession = CreateMockSession(
redundancySupport: (int)RedundancySupport.Hot,
serviceLevel: 100,
redundantServers: [serverData]);
ServerRedundancyInfo info = await m_handler.FetchRedundancyInfoAsync(
mockSession.Object).ConfigureAwait(false);
Assert.That(info.Mode, Is.EqualTo(RedundancySupport.Hot));
Assert.That(info.ServiceLevelSubrange, Is.EqualTo(ServiceLevelSubrange.Degraded));
Assert.That(info.RedundantServers, Has.Count.EqualTo(1));
Assert.That(info.RedundantServers[0].ServerUri, Is.EqualTo("urn:server1"));
Assert.That(info.RedundantServers[0].Endpoint, Is.SameAs(resolvedEndpoint));
}
/// <summary>
/// Verifies that redundancy discovery resolves server application URIs to endpoints.
/// </summary>
[Test]
public async Task FetchRedundancyInfoResolvesServerUriArrayToEndpointsAsync()
{
ConfiguredEndpoint resolvedEndpoint = CreateEndpoint("urn:server-uri", "opc.tcp://server-uri:4840");
m_resolver.Setup(r => r.ResolveAsync(
"urn:server-uri",
It.IsAny<ConfiguredEndpoint>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(resolvedEndpoint);
Mock<ISession> mockSession = CreateMockSession(
redundancySupport: (int)RedundancySupport.Hot,
serviceLevel: ServiceLevels.NoData,
serverUris: ["urn:server-uri"]);
ServerRedundancyInfo info = await m_handler.FetchRedundancyInfoAsync(
mockSession.Object).ConfigureAwait(false);
Assert.That(info.RedundantServers, Has.Count.EqualTo(1));
Assert.That(info.RedundantServers[0].ServerUri, Is.EqualTo("urn:server-uri"));
Assert.That(info.RedundantServers[0].Endpoint, Is.SameAs(resolvedEndpoint));
Assert.That(
info.RedundantServers[0].Endpoint!.Description.EndpointUrl,
Is.EqualTo("opc.tcp://server-uri:4840"));
VerifyBatchedRedundancyRead(mockSession);
}
/// <summary>
/// Verifies that peers with unresolved server URIs are excluded from failover selection.
/// </summary>
[Test]
public async Task FetchRedundancyInfoExcludesUnresolvedServerUriFromSelectionAsync()
{
m_resolver.Setup(r => r.ResolveAsync(
"urn:unresolved",
It.IsAny<ConfiguredEndpoint>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((ConfiguredEndpoint?)null);
Mock<ISession> mockSession = CreateMockSession(
redundancySupport: (int)RedundancySupport.Hot,
serviceLevel: ServiceLevels.NoData,
serverUris: ["urn:unresolved"]);
ServerRedundancyInfo info = await m_handler.FetchRedundancyInfoAsync(
mockSession.Object).ConfigureAwait(false);
ConfiguredEndpoint? target = m_handler.SelectFailoverTarget(
info, CreateCurrentEndpoint("urn:current"));
Assert.That(info.RedundantServers, Has.Count.EqualTo(1));
Assert.That(info.RedundantServers[0].Endpoint, Is.Null);
Assert.That(info.RedundantServers[0].ResolutionError, Is.Not.Null);
Assert.That(info.RedundantServers[0].ResolutionError!.StatusCode, Is.EqualTo(StatusCodes.BadNotFound));
Assert.That(target, Is.Null);
}
/// <summary>
/// Verifies that a ServiceLevel monitoring interval must be positive and is off by default.
/// </summary>
[Test]
public void ServiceLevelMonitoringIntervalMustBePositive()
{
var defaults = new ServerRedundancyOptions();
Assert.That(defaults.ServiceLevelMonitoringInterval, Is.Null);
Assert.That(defaults.ProbeMemberServiceLevel, Is.False);
Assert.That(
() => new ServerRedundancyOptions { ServiceLevelMonitoringInterval = TimeSpan.Zero }.Validate(),
Throws.TypeOf<ArgumentOutOfRangeException>());
Assert.That(
() => new ServerRedundancyOptions { ServiceLevelMonitoringInterval = Timeout.InfiniteTimeSpan }.Validate(),
Throws.TypeOf<ArgumentOutOfRangeException>());
Assert.That(
() => new ServerRedundancyOptions { ServiceLevelMonitoringInterval = TimeSpan.FromSeconds(1) }.Validate(),
Throws.Nothing);
}
/// <summary>
/// Verifies that a failed endpoint resolution keeps the exact status of the failure.
/// </summary>
[Test]
public async Task FetchRedundancyInfoReportsResolutionErrorOfFailedPeerAsync()
{
m_resolver.Setup(r => r.ResolveAsync(
"urn:refusing",
It.IsAny<ConfiguredEndpoint>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new ServiceResultException(StatusCodes.BadCertificateUntrusted));
Mock<ISession> mockSession = CreateMockSession(
redundancySupport: (int)RedundancySupport.Warm,
serviceLevel: ServiceLevels.Maximum,
serverUris: ["urn:refusing"]);
ServerRedundancyInfo info = await m_handler.FetchRedundancyInfoAsync(
mockSession.Object).ConfigureAwait(false);
Assert.That(info.RedundantServers, Has.Count.EqualTo(1));
Assert.That(info.RedundantServers[0].Endpoint, Is.Null);
Assert.That(info.RedundantServers[0].ServiceLevelKnown, Is.False);
Assert.That(
info.RedundantServers[0].ResolutionError?.StatusCode,
Is.EqualTo((StatusCode)StatusCodes.BadCertificateUntrusted));
}
/// <summary>
/// Verifies that a discovery failure of the default resolver is reported instead of BadNotFound.
/// </summary>
[Test]
public async Task FetchRedundancyInfoReportsDiscoveryFailureOfDefaultResolverAsync()
{
var resolver = new DefaultRedundantServerEndpointResolver(
NUnitTelemetryContext.Create(),
new FailingDiscovery(StatusCodes.BadCertificateUntrusted));
var handler = new DefaultServerRedundancyHandler(resolver, new FixedTimeProvider(s_now));
Mock<ISession> mockSession = CreateMockSession(
redundancySupport: (int)RedundancySupport.Warm,
serviceLevel: ServiceLevels.Maximum,
serverUris: ["urn:refusing"]);
ServerRedundancyInfo info = await handler.FetchRedundancyInfoAsync(
mockSession.Object).ConfigureAwait(false);
Assert.That(info.RedundantServers, Has.Count.EqualTo(1));
Assert.That(info.RedundantServers[0].Endpoint, Is.Null);
Assert.That(
info.RedundantServers[0].ResolutionError?.StatusCode,
Is.EqualTo((StatusCode)StatusCodes.BadCertificateUntrusted));
}
/// <summary>
/// Verifies that the status of every redundancy node read is reported, including refused nodes.
/// </summary>
[Test]
public async Task FetchRedundancyInfoReportsReadStatusOfEachNodeAsync()
{
Mock<ISession> mockSession = CreateMockSession(
redundancySupport: (int)RedundancySupport.Transparent,
serviceLevel: ServiceLevels.Maximum,
currentServerId: "urn:current");
ServerRedundancyInfo info = await m_handler.FetchRedundancyInfoAsync(
mockSession.Object).ConfigureAwait(false);
Assert.That(info.CurrentServerId, Is.EqualTo("urn:current"));
Assert.That(info.ReadStatuses, Has.Count.EqualTo(6));
Assert.That(
info.ReadStatuses[VariableIds.Server_ServerRedundancy_RedundancySupport],
Is.EqualTo((StatusCode)StatusCodes.Good));
Assert.That(
info.ReadStatuses[VariableIds.Server_ServiceLevel],
Is.EqualTo((StatusCode)StatusCodes.Good));
Assert.That(
info.ReadStatuses[VariableIds.Server_ServerRedundancy_RedundantServerArray],
Is.EqualTo((StatusCode)StatusCodes.BadNodeIdUnknown));
Assert.That(
info.ReadStatuses[VariableIds.Server_ServerRedundancy_ServerUriArray],
Is.EqualTo((StatusCode)StatusCodes.BadNodeIdUnknown));
Assert.That(
info.ReadStatuses[VariableIds.Server_ServerRedundancy_CurrentServerId],
Is.EqualTo((StatusCode)StatusCodes.Good));
}
/// <summary>
/// Verifies that a URI-only peer can be selected when its service level is unknown.
/// </summary>
[Test]
public async Task FetchRedundancyInfoSelectsServerUriOnlyPeerWithUnknownServiceLevelAsync()
{
ConfiguredEndpoint resolvedEndpoint = CreateEndpoint("urn:server-uri", "opc.tcp://server-uri:4840");
m_resolver.Setup(r => r.ResolveAsync(
"urn:server-uri",
It.IsAny<ConfiguredEndpoint>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(resolvedEndpoint);
Mock<ISession> mockSession = CreateMockSession(
redundancySupport: (int)RedundancySupport.Hot,
serviceLevel: ServiceLevels.NoData,
serverUris: ["urn:server-uri"]);
ServerRedundancyInfo info = await m_handler.FetchRedundancyInfoAsync(
mockSession.Object).ConfigureAwait(false);
ConfiguredEndpoint? target = m_handler.SelectFailoverTarget(
info, CreateCurrentEndpoint("urn:current"));
Assert.That(info.RedundantServers[0].ServiceLevel, Is.EqualTo(ServiceLevels.NoData));
Assert.That(target, Is.SameAs(resolvedEndpoint));
}
/// <summary>
/// Verifies that discovery can fall back to a URI-only peer with unknown service level.
/// </summary>
[Test]
public async Task FetchRedundancyInfoFallsBackToUnknownServerUriOnlyPeerAsync()
{
var serverData = new RedundantServerDataType
{
ServerId = "urn:nodata",
ServiceLevel = ServiceLevels.NoData,
ServerState = ServerState.Running
};
ConfiguredEndpoint noDataEndpoint = CreateEndpoint("urn:nodata", "opc.tcp://nodata:4840");
ConfiguredEndpoint unknownEndpoint = CreateEndpoint("urn:server-uri", "opc.tcp://server-uri:4840");
m_resolver.Setup(r => r.ResolveAsync(
"urn:nodata",
It.IsAny<ConfiguredEndpoint>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(noDataEndpoint);
m_resolver.Setup(r => r.ResolveAsync(
"urn:server-uri",
It.IsAny<ConfiguredEndpoint>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(unknownEndpoint);
Mock<ISession> mockSession = CreateMockSession(
redundancySupport: (int)RedundancySupport.Hot,
serviceLevel: ServiceLevels.DegradedMaximum,
redundantServers: [serverData],
serverUris: ["urn:nodata", "urn:server-uri"]);
ServerRedundancyInfo info = await m_handler.FetchRedundancyInfoAsync(
mockSession.Object).ConfigureAwait(false);
ConfiguredEndpoint? target = m_handler.SelectFailoverTarget(
info, CreateCurrentEndpoint("urn:current"));
Assert.That(info.RedundantServers, Has.Count.EqualTo(2));
Assert.That(target, Is.SameAs(unknownEndpoint));
}
/// <summary>
/// Verifies that redundancy discovery caches resolved peer endpoints.
/// </summary>
[Test]
public async Task FetchRedundancyInfoCachesResolvedEndpointsAsync()
{
var serverData = new RedundantServerDataType
{
ServerId = "urn:server1",
ServiceLevel = 230,
ServerState = ServerState.Running
};
ConfiguredEndpoint resolvedEndpoint = CreateEndpoint("urn:server1", "opc.tcp://server1:4840");
m_resolver.Setup(r => r.ResolveAsync(
"urn:server1",
It.IsAny<ConfiguredEndpoint>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(resolvedEndpoint);
Mock<ISession> mockSession = CreateMockSession(
redundancySupport: (int)RedundancySupport.Hot,
serviceLevel: 100,
redundantServers: [serverData]);
await m_handler.FetchRedundancyInfoAsync(mockSession.Object).ConfigureAwait(false);
await m_handler.FetchRedundancyInfoAsync(mockSession.Object).ConfigureAwait(false);
m_resolver.Verify(r => r.ResolveAsync(
"urn:server1",
It.IsAny<ConfiguredEndpoint>(),
It.IsAny<CancellationToken>()), Times.Once);
}
/// <summary>
/// Verifies that transparent redundancy discovery reads the current server identifier.
/// </summary>
[Test]
public async Task FetchRedundancyInfoReadsTransparentCurrentServerIdAsync()
{
Mock<ISession> mockSession = CreateMockSession(
redundancySupport: (int)RedundancySupport.Transparent,
serviceLevel: ServiceLevels.HealthyMinimum,
currentServerId: "server-a");
ServerRedundancyInfo info = await m_handler.FetchRedundancyInfoAsync(
mockSession.Object).ConfigureAwait(false);
Assert.That(info.CurrentServerId, Is.EqualTo("server-a"));
}
/// <summary>
/// Verifies that failed redundancy reads report no redundancy, an inaccessible service level, and no peers.
/// </summary>
[Test]
public async Task FetchRedundancyInfoHandlesReadErrorsAsync()
{
Mock<ISession> mockSession = CreateMockSessionWithBadStatus();
ServerRedundancyInfo info = await m_handler.FetchRedundancyInfoAsync(
mockSession.Object).ConfigureAwait(false);
Assert.That(info.Mode, Is.EqualTo(RedundancySupport.None));
Assert.That(info.ServiceLevel, Is.Zero);
Assert.That(info.ServiceLevelAccessible, Is.False);
Assert.That(info.RedundantServers, Is.Empty);
}
/// <summary>
/// Verifies that malformed optional redundancy nodes preserve the discovered mode without inventing peers.
/// </summary>
[Test]
public async Task FetchRedundancyInfoHandlesMalformedOptionalNodesAsync()
{
Mock<ISession> mockSession = CreateMockSessionWithThrowingOptionalReads();
ServerRedundancyInfo info = await m_handler.FetchRedundancyInfoAsync(
mockSession.Object).ConfigureAwait(false);
Assert.That(info.Mode, Is.EqualTo(RedundancySupport.Hot));
Assert.That(info.RedundantServers, Is.Empty);
}
/// <summary>
/// Verifies that discovery and failover APIs reject null sessions, redundancy information, and endpoints.
/// </summary>
[Test]
public void NullArgumentsThrow()
{
var info = new ServerRedundancyInfo();
ConfiguredEndpoint endpoint = CreateCurrentEndpoint("urn:current");
Assert.That(
async () => await m_handler.FetchRedundancyInfoAsync(null!).ConfigureAwait(false),
Throws.ArgumentNullException);
Assert.That(() => m_handler.ShouldFailover(null!, endpoint), Throws.ArgumentNullException);
Assert.That(() => m_handler.ShouldFailover(info, null!), Throws.ArgumentNullException);
Assert.That(() => m_handler.SelectFailoverTarget(null!, endpoint), Throws.ArgumentNullException);
Assert.That(() => m_handler.SelectFailoverTarget(info, null!), Throws.ArgumentNullException);
}
private static RedundantServer CreateServerInfo(
string uri,
byte serviceLevel,
ServerState state)
{
return new RedundantServer
{
ServerUri = uri,
ServiceLevel = serviceLevel,
ServerState = state,
Endpoint = CreateEndpoint(uri, $"opc.tcp://{uri[4..]}:4840")
};
}
private static ConfiguredEndpoint CreateCurrentEndpoint(string applicationUri)
{
return CreateEndpoint(applicationUri, "opc.tcp://current:4840");
}
private static ConfiguredEndpoint CreateEndpoint(string applicationUri, string endpointUrl)
{
var description = new EndpointDescription
{
EndpointUrl = endpointUrl,
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
Server = new ApplicationDescription
{
ApplicationUri = applicationUri,
DiscoveryUrls = new ArrayOf<string>(new[] { endpointUrl })
}
};
return new ConfiguredEndpoint(null, description, configuration: null);
}
private static Mock<ISession> CreateMockSession(
int redundancySupport,
byte serviceLevel,
RedundantServerDataType[]? redundantServers = null,
string[]? serverUris = null,
string currentServerId = "")
{
var mock = new Mock<ISession>();
mock.SetupGet(s => s.ConfiguredEndpoint)
.Returns(CreateCurrentEndpoint("urn:current"));
mock.Setup(s => s.ReadAsync(
It.IsAny<RequestHeader>(),
It.IsAny<double>(),
It.IsAny<TimestampsToReturn>(),
It.Is<ArrayOf<ReadValueId>>(r => r.Count == 5),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ReadResponse
{
Results =
[
new DataValue(new Variant(redundancySupport), StatusCodes.Good),
new DataValue(new Variant(serviceLevel), StatusCodes.Good),
redundantServers == null
? new DataValue(Variant.Null, StatusCodes.BadNodeIdUnknown)
: CreateRedundantServerArrayValue(redundantServers),
serverUris == null
? new DataValue(Variant.Null, StatusCodes.BadNodeIdUnknown)
: new DataValue(new Variant(new ArrayOf<string>(serverUris)), StatusCodes.Good),
new DataValue(new Variant(DateTime.MinValue), StatusCodes.Good)
],
DiagnosticInfos = []
});
SetupSingleNodeRead(
mock,
VariableIds.Server_ServerRedundancy_CurrentServerId,
string.IsNullOrEmpty(currentServerId)
? new DataValue(Variant.Null, StatusCodes.BadNodeIdUnknown)
: new DataValue(new Variant(currentServerId), StatusCodes.Good));
return mock;
}
private static void SetupSingleNodeRead(
Mock<ISession> mock,
NodeId nodeId,
DataValue result)
{
mock.Setup(s => s.ReadAsync(
It.IsAny<RequestHeader>(),
It.IsAny<double>(),
It.IsAny<TimestampsToReturn>(),
It.Is<ArrayOf<ReadValueId>>(r => r.Count == 1 && r[0].NodeId == nodeId),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ReadResponse
{
Results = [result],
DiagnosticInfos = []
});
}
private static void VerifyBatchedRedundancyRead(Mock<ISession> mock)
{
mock.Verify(s => s.ReadAsync(
It.IsAny<RequestHeader>(),
It.IsAny<double>(),
It.IsAny<TimestampsToReturn>(),
It.Is<ArrayOf<ReadValueId>>(r => r.Count == 5),
It.IsAny<CancellationToken>()), Times.Once);
mock.Verify(s => s.ReadAsync(
It.IsAny<RequestHeader>(),
It.IsAny<double>(),
It.IsAny<TimestampsToReturn>(),
It.Is<ArrayOf<ReadValueId>>(r =>
r.Count == 1 &&
(r[0].NodeId == VariableIds.Server_ServerRedundancy_RedundantServerArray ||
r[0].NodeId == VariableIds.Server_ServerRedundancy_ServerUriArray)),
It.IsAny<CancellationToken>()), Times.Never);
}
private static DataValue CreateRedundantServerArrayValue(
RedundantServerDataType[] redundantServers)
{
ArrayOf<ExtensionObject> extensionObjects =
Array.ConvertAll(redundantServers, s => new ExtensionObject(s));
return new DataValue(new Variant(extensionObjects), StatusCodes.Good);
}
private static Mock<ISession> CreateMockSessionWithBadStatus()
{
var mock = new Mock<ISession>();
mock.Setup(s => s.ReadAsync(
It.IsAny<RequestHeader>(),
It.IsAny<double>(),
It.IsAny<TimestampsToReturn>(),
It.Is<ArrayOf<ReadValueId>>(r => r.Count == 5),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ReadResponse
{
Results =
[
new DataValue(Variant.Null, StatusCodes.BadNodeIdUnknown),
new DataValue(Variant.Null, StatusCodes.BadNodeIdUnknown),
new DataValue(Variant.Null, StatusCodes.BadNodeIdUnknown),
new DataValue(Variant.Null, StatusCodes.BadNodeIdUnknown),
new DataValue(Variant.Null, StatusCodes.BadNodeIdUnknown)
],
DiagnosticInfos = []
});
return mock;
}
private static Mock<ISession> CreateMockSessionWithThrowingOptionalReads()
{
return CreateMockSession(
redundancySupport: (int)RedundancySupport.Hot,
serviceLevel: ServiceLevels.NoData);
}
private sealed class FixedTimeProvider : TimeProvider
{
public FixedTimeProvider(DateTime utcNow)
{
m_utcNow = utcNow;
}
public override DateTimeOffset GetUtcNow()
{
return m_utcNow;
}
private readonly DateTimeOffset m_utcNow;
}
private sealed class FailingDiscovery : IRedundantServerDiscovery
{
public FailingDiscovery(StatusCode statusCode)
{
m_statusCode = statusCode;
}
public ValueTask<ArrayOf<ApplicationDescription>> FindServersAsync(
Uri discoveryUri,
EndpointConfiguration configuration,
string serverUri,
ITelemetryContext telemetry,
CancellationToken ct)
{
throw new ServiceResultException(m_statusCode);
}
public ValueTask<ArrayOf<EndpointDescription>> GetEndpointsAsync(
Uri discoveryUri,
EndpointConfiguration configuration,
ITelemetryContext telemetry,
CancellationToken ct)
{
throw new ServiceResultException(m_statusCode);
}
private readonly StatusCode m_statusCode;
}
}
}