Files
Marc Schier 9bfc780e14 Update NuGet dependencies and migrate tests to NUnit 5 (#4840)
# Description

Update 55 NuGet dependencies to the latest stable releases available on
2026-10-19, preserving documented compatibility pins and
framework-specific version bands. Existing OpenUSD prereleases remain
unchanged because no newer releases are available.

Migrate existing tests to NUnit 5: await asynchronous assertions,
propagate async through helpers, handle nullable predicates, and replace
obsolete `Order` attributes with sequencing dependencies. Sequencing
dependencies use `AllowFailure = true`; filtering a dependent test also
runs prerequisites. Adjust fixture-level parallelism where NUnit
dependency scheduling requires it.

Keep Roslyn API/runtime pins intact; select newer Immutable/Metadata
runtime packages only for test and profiling hosts requiring them.
Update the approved SDK pin to 10.0.402 across local, CI, and container
builds. Document NUnit 5 conventions.
2026-10-19 15:23:29 +00:00

843 lines
42 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.Data.Common;
using System.IO;
using System.Net.Http;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
using Opc.Ua.Server.AliasNames;
using Opc.Ua.Server.Tests.NodeManager;
using Opc.Ua.Tests;
namespace Opc.Ua.Server.Tests.AliasNames
{
/// <summary>
/// Verifies alias refresh failure isolation, reference identity, and bounded background ownership.
/// </summary>
[TestFixture]
[Category("AliasNames")]
public sealed class AliasRefreshRegressionTests
{
[Test]
public async Task InitialMaterializationRetriesOneTimedOutSnapshotAsync()
{
await using var harness = new RefreshHarness();
int attempts = 0;
harness.Query = (id, pattern, reference, types, ct) =>
{
Assert.That(harness.Category, Is.Null);
if (++attempts == 1)
{
return new ValueTask<IReadOnlyList<AliasNameVerboseDataType>>(
Task.FromException<IReadOnlyList<AliasNameVerboseDataType>>(
new ServiceResultException(StatusCodes.BadTimeout)));
}
return harness.Data.FindAliasVerboseAsync(id, pattern, reference, types, ct);
};
await harness.InitializeAsync().ConfigureAwait(false);
Assert.That(attempts, Is.EqualTo(2));
AliasNameState alias = harness.FindAlias("Alpha");
Assert.That(alias, Is.Not.Null);
Assert.That(harness.Category.ReferenceExists(ReferenceTypeIds.Organizes, false, alias.NodeId), Is.True);
Assert.That(harness.Target.ReferenceExists(ReferenceTypeIds.AliasFor, true, alias.NodeId), Is.True);
}
[Test]
public async Task InitialMaterializationPropagatesRepeatedTimeoutAsync()
{
await using var harness = new RefreshHarness();
var failure = new ServiceResultException(StatusCodes.BadTimeout);
int attempts = 0;
harness.Query = (_, _, _, _, _) =>
{
attempts++;
return new ValueTask<IReadOnlyList<AliasNameVerboseDataType>>(
Task.FromException<IReadOnlyList<AliasNameVerboseDataType>>(failure));
};
ServiceResultException actual = await Assert.ThrowsAsync<ServiceResultException>(
async () => await harness.InitializeAsync().ConfigureAwait(false));
Assert.That(actual, Is.SameAs(failure));
Assert.That(attempts, Is.EqualTo(2));
Assert.That(harness.Category, Is.Null);
Assert.That(harness.FindAlias("Alpha"), Is.Null);
}
[Test]
public async Task InitialMaterializationDoesNotRetryNonTimeoutFailureAsync()
{
await using var harness = new RefreshHarness();
var failure = new IOException("Initial alias store unavailable.");
int attempts = 0;
harness.Query = (_, _, _, _, _) =>
{
attempts++;
return new ValueTask<IReadOnlyList<AliasNameVerboseDataType>>(
Task.FromException<IReadOnlyList<AliasNameVerboseDataType>>(failure));
};
IOException actual = await Assert.ThrowsAsync<IOException>(
async () => await harness.InitializeAsync().ConfigureAwait(false));
Assert.That(actual, Is.SameAs(failure));
Assert.That(attempts, Is.EqualTo(1));
Assert.That(harness.Category, Is.Null);
}
[Test]
public async Task InitialMaterializationDoesNotRetryCanceledQueryAsync()
{
await using var harness = new RefreshHarness();
using var cancellation = new CancellationTokenSource();
var failure = new ServiceResultException(StatusCodes.BadTimeout);
int attempts = 0;
harness.Query = (_, _, _, _, ct) =>
{
Assert.That(ct, Is.EqualTo(cancellation.Token));
attempts++;
cancellation.Cancel();
return new ValueTask<IReadOnlyList<AliasNameVerboseDataType>>(
Task.FromException<IReadOnlyList<AliasNameVerboseDataType>>(failure));
};
ServiceResultException actual = await Assert.ThrowsAsync<ServiceResultException>(
async () => await harness.InitializeAsync(cancellation.Token).ConfigureAwait(false));
Assert.That(actual, Is.SameAs(failure));
Assert.That(attempts, Is.EqualTo(1));
Assert.That(harness.Category, Is.Null);
}
[TestCase(false)]
[TestCase(true)]
public async Task StoreQueryFailurePreservesMaterializedAliasNodesAsync(bool timeout)
{
await using var harness = new RefreshHarness();
await harness.InitializeAsync().ConfigureAwait(false);
AliasNameState original = harness.FindAlias("Alpha");
Exception failure = timeout
? new ServiceResultException(StatusCodes.BadTimeout)
: new IOException("Remote alias store unavailable.");
harness.Query = (_, _, _, _, _) =>
new ValueTask<IReadOnlyList<AliasNameVerboseDataType>>(
Task.FromException<IReadOnlyList<AliasNameVerboseDataType>>(failure));
Exception actual = await Assert.ThrowsAsync(failure.GetType(), async () =>
await harness.Materializer.RefreshCategoryAsync(
harness.Store.Object, harness.CategoryId, materializeAliasNodes: true)
.ConfigureAwait(false));
Assert.That(actual, Is.SameAs(failure));
Assert.That(harness.FindAlias("Alpha"), Is.SameAs(original));
Assert.That(harness.Category.ReferenceExists(ReferenceTypeIds.Organizes, false, original.NodeId), Is.True);
Assert.That(original.ReferenceExists(ReferenceTypeIds.AliasFor, false, harness.Target.NodeId), Is.True);
Assert.That(harness.Target.ReferenceExists(ReferenceTypeIds.AliasFor, true, original.NodeId), Is.True);
}
[Test]
public async Task NullStoreSnapshotPreservesAliasesAndAllowsLiveRetryAsync()
{
await using var harness = new RefreshHarness();
await harness.InitializeAsync().ConfigureAwait(false);
AliasNameState original = harness.FindAlias("Alpha");
var queried = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var published = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
int calls = 0;
harness.Query = (id, pattern, reference, types, ct) =>
{
if (Interlocked.Increment(ref calls) == 1)
{
queried.TrySetResult(true);
return new ValueTask<IReadOnlyList<AliasNameVerboseDataType>>(
(IReadOnlyList<AliasNameVerboseDataType>)null!);
}
return harness.Data.FindAliasVerboseAsync(id, pattern, reference, types, ct);
};
await using var coordinator = new AliasNameRefreshCoordinator(
"NullAliasSnapshot", NUnitTelemetryContext.Create(),
(_, ct) => harness.Materializer.RefreshCategoryAsync(
harness.Store.Object, harness.CategoryId, materializeAliasNodes: true, cancellationToken: ct));
harness.Category.OnStateChanged += (_, _, _) =>
{
if (harness.FindAlias("Gamma") != null)
{
published.TrySetResult(true);
}
};
await harness.Data.AddAliasesAsync(harness.CategoryId,
[new AliasAddRequest("Beta", harness.Target.NodeId, null, ReferenceTypeIds.AliasFor)])
.ConfigureAwait(false);
coordinator.RequestRefresh();
await queried.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
Assert.That(harness.FindAlias("Alpha"), Is.SameAs(original));
await harness.Data.AddAliasesAsync(harness.CategoryId,
[new AliasAddRequest("Gamma", harness.Target.NodeId, null, ReferenceTypeIds.AliasFor)])
.ConfigureAwait(false);
coordinator.RequestRefresh();
await published.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
Assert.That(calls, Is.EqualTo(2));
Assert.That(harness.FindAlias("Alpha"), Is.SameAs(original));
Assert.That(harness.FindAlias("Beta"), Is.Not.Null);
Assert.That(harness.Target.ReferenceExists(
ReferenceTypeIds.AliasFor, true, original.NodeId), Is.True);
Assert.That(harness.Target.ReferenceExists(
ReferenceTypeIds.AliasFor, true, harness.FindAlias("Gamma").NodeId), Is.True);
}
[Test]
public async Task RefreshDiffRetainsUnchangedAliasesAndUpdatesActualTargetInversesAsync()
{
await using var harness = new RefreshHarness();
harness.Data.Seed(harness.CategoryId, "Removed", harness.Target.NodeId, null, ReferenceTypeIds.AliasFor);
await harness.InitializeAsync().ConfigureAwait(false);
AliasNameState original = harness.FindAlias("Alpha");
AliasNameState removed = harness.FindAlias("Removed");
await harness.Data.AddAliasesAsync(harness.CategoryId,
[new AliasAddRequest("Beta", harness.Target.NodeId, null, ReferenceTypeIds.AliasFor)])
.ConfigureAwait(false);
await harness.Data.DeleteAliasesAsync(harness.CategoryId,
[new AliasDeleteRequest("Removed", harness.Target.NodeId)]).ConfigureAwait(false);
await harness.RefreshAsync().ConfigureAwait(false);
AliasNameState added = harness.FindAlias("Beta");
Assert.That(harness.FindAlias("Alpha"), Is.SameAs(original));
Assert.That(harness.FindAlias("Removed"), Is.Null);
Assert.That(harness.Target.ReferenceExists(ReferenceTypeIds.AliasFor, true, original.NodeId), Is.True);
Assert.That(harness.Target.ReferenceExists(ReferenceTypeIds.AliasFor, true, removed.NodeId), Is.False);
Assert.That(added.ReferenceExists(ReferenceTypeIds.AliasFor, false, harness.Target.NodeId), Is.True);
Assert.That(harness.Target.ReferenceExists(ReferenceTypeIds.AliasFor, true, added.NodeId), Is.True);
Assert.That(harness.Category.ReferenceExists(ReferenceTypeIds.Organizes, false, removed.NodeId), Is.False);
await harness.RefreshAsync().ConfigureAwait(false);
Assert.That(harness.FindAlias("Beta"), Is.SameAs(added));
Assert.That(harness.FindAlias("Alpha"), Is.SameAs(original));
}
[Test]
public async Task RefreshUpdatesTargetsWithoutReplacingAliasInstanceAsync()
{
await using var harness = new RefreshHarness();
await harness.InitializeAsync().ConfigureAwait(false);
AliasNameState original = harness.FindAlias("Alpha");
var replacement = new BaseDataVariableState(null) { NodeId = new NodeId("Replacement", 1) };
await ((IAliasNameMaterializerHost)harness.Manager).RegisterNodeAsync(
replacement, CancellationToken.None).ConfigureAwait(false);
await harness.Data.AddAliasesAsync(harness.CategoryId,
[new AliasAddRequest("Alpha", replacement.NodeId, null, ReferenceTypeIds.AliasFor)])
.ConfigureAwait(false);
await harness.Data.DeleteAliasesAsync(harness.CategoryId,
[new AliasDeleteRequest("Alpha", harness.Target.NodeId)]).ConfigureAwait(false);
await harness.RefreshAsync().ConfigureAwait(false);
Assert.That(harness.FindAlias("Alpha"), Is.SameAs(original));
Assert.That(original.ReferenceExists(ReferenceTypeIds.AliasFor, false, harness.Target.NodeId), Is.False);
Assert.That(harness.Target.ReferenceExists(ReferenceTypeIds.AliasFor, true, original.NodeId), Is.False);
Assert.That(original.ReferenceExists(ReferenceTypeIds.AliasFor, false, replacement.NodeId), Is.True);
Assert.That(replacement.ReferenceExists(ReferenceTypeIds.AliasFor, true, original.NodeId), Is.True);
}
[Test]
public async Task RefreshOptOutDoesNotQueryOrChangeTheSnapshotAsync()
{
await using var harness = new RefreshHarness();
await harness.InitializeAsync().ConfigureAwait(false);
AliasNameState original = harness.FindAlias("Alpha");
await harness.Data.AddAliasesAsync(harness.CategoryId,
[new AliasAddRequest("Beta", harness.Target.NodeId, null, ReferenceTypeIds.AliasFor)])
.ConfigureAwait(false);
harness.Store.Invocations.Clear();
await harness.Materializer.RefreshCategoryAsync(
harness.Store.Object, harness.CategoryId, materializeAliasNodes: false).ConfigureAwait(false);
harness.Store.Verify(store => store.FindAliasVerboseAsync(
It.IsAny<NodeId>(), It.IsAny<string>(), It.IsAny<NodeId>(), It.IsAny<ITypeTable>(),
It.IsAny<CancellationToken>()), Times.Never);
Assert.That(harness.FindAlias("Alpha"), Is.SameAs(original));
Assert.That(harness.FindAlias("Beta"), Is.Null);
}
[Test]
public async Task StaleRefreshGenerationDoesNotMutateTheSnapshotAsync()
{
await using var harness = new RefreshHarness();
await harness.InitializeAsync().ConfigureAwait(false);
AliasNameState original = harness.FindAlias("Alpha");
await harness.Data.DeleteAliasesAsync(harness.CategoryId,
[new AliasDeleteRequest("Alpha", harness.Target.NodeId)]).ConfigureAwait(false);
await harness.Materializer.RefreshCategoryAsync(harness.Store.Object, harness.CategoryId,
materializeAliasNodes: true, isCurrentGeneration: () => false).ConfigureAwait(false);
Assert.That(harness.FindAlias("Alpha"), Is.SameAs(original));
Assert.That(harness.Target.ReferenceExists(ReferenceTypeIds.AliasFor, true, original.NodeId), Is.True);
}
[TestCase(41u, 42u)]
[TestCase(uint.MaxValue, 0u)]
public async Task DelayedStoreEventsCannotRegressLastChangeAsync(uint previous, uint current)
{
await using var harness = new RefreshHarness();
await harness.InitializeAsync().ConfigureAwait(false);
harness.Store.Setup(store => store.GetLastChange(harness.CategoryId)).Returns(previous);
harness.Store.Raise(store => store.Changed += null,
new AliasStoreChangedEventArgs(harness.CategoryId, previous));
harness.Store.Setup(store => store.GetLastChange(harness.CategoryId)).Returns(current);
harness.Store.Raise(store => store.Changed += null,
new AliasStoreChangedEventArgs(harness.CategoryId, current));
harness.Store.Raise(store => store.Changed += null,
new AliasStoreChangedEventArgs(harness.CategoryId, previous));
Assert.That(harness.Category.LastChange!.Value, Is.EqualTo(current));
}
[Test]
public async Task DefaultLiveRefreshAddsAndDeletesAliasNodesAsync()
{
await using var harness = new RefreshHarness(refreshOnChange: null);
await harness.InitializeAsync().ConfigureAwait(false);
AliasNameState original = harness.FindAlias("Alpha");
var published = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
harness.Category.OnStateChanged += (_, _, _) =>
{
if (harness.FindAlias("Beta") != null)
{
published.TrySetResult(true);
}
};
await harness.Data.AddAliasesAsync(harness.CategoryId,
[new AliasAddRequest("Beta", harness.Target.NodeId, null, ReferenceTypeIds.AliasFor)])
.ConfigureAwait(false);
await published.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
Assert.That(harness.FindAlias("Alpha"), Is.SameAs(original));
AliasNameState added = harness.FindAlias("Beta");
Assert.That(harness.Category.ReferenceExists(
ReferenceTypeIds.Organizes, false, added.NodeId), Is.True);
Assert.That(added.ReferenceExists(
ReferenceTypeIds.AliasFor, false, harness.Target.NodeId), Is.True);
Assert.That(harness.Target.ReferenceExists(
ReferenceTypeIds.AliasFor, true, added.NodeId), Is.True);
var removed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
harness.Category.OnStateChanged += (_, _, _) =>
{
if (harness.FindAlias("Beta") == null)
{
removed.TrySetResult(true);
}
};
await harness.Data.DeleteAliasesAsync(harness.CategoryId,
[new AliasDeleteRequest("Beta", harness.Target.NodeId)]).ConfigureAwait(false);
await removed.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
Assert.That(harness.FindAlias("Alpha"), Is.SameAs(original));
Assert.That(harness.FindAlias("Beta"), Is.Null);
Assert.That(harness.Category.ReferenceExists(
ReferenceTypeIds.Organizes, false, added.NodeId), Is.False);
Assert.That(harness.Target.ReferenceExists(
ReferenceTypeIds.AliasFor, true, added.NodeId), Is.False);
}
[Test]
public async Task ChangeStormUsesOneWorkerAndOnePendingSignalAsync()
{
var entered = new TaskCompletionSource<long>(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var second = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var stopped = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
int calls = 0;
await using var coordinator = new AliasNameRefreshCoordinator(
"AliasStormTest", NUnitTelemetryContext.Create(), async (generation, ct) =>
{
if (Interlocked.Increment(ref calls) == 1)
{
entered.TrySetResult(generation);
await release.Task.WaitAsync(ct).ConfigureAwait(false);
}
else
{
second.TrySetResult(true);
try
{
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
}
finally
{
stopped.TrySetResult(true);
}
}
});
coordinator.RequestRefresh();
long firstGeneration = await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
for (int ii = 0; ii < 10000; ii++)
{
coordinator.RequestRefresh();
}
Assert.That(coordinator.PendingTaskCount, Is.EqualTo(1));
Assert.That(coordinator.PendingRefreshCount, Is.EqualTo(1));
Assert.That(calls, Is.EqualTo(1));
Assert.That(coordinator.IsCurrent(firstGeneration), Is.False);
release.TrySetResult(true);
await second.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
await coordinator.DisposeAsync().ConfigureAwait(false);
coordinator.RequestRefresh();
Assert.That(stopped.Task.Status, Is.EqualTo(TaskStatus.RanToCompletion));
Assert.That(coordinator.PendingTaskCount, Is.Zero);
Assert.That(coordinator.PendingRefreshCount, Is.Zero);
Assert.That(calls, Is.EqualTo(2));
}
[TestCaseSource(nameof(s_backendFailures))]
public async Task RefreshFaultIsLoggedAndNextChangeRetriesAsync(Exception failure)
{
await using var harness = new RefreshHarness();
await harness.InitializeAsync().ConfigureAwait(false);
AliasNameState original = harness.FindAlias("Alpha");
ITelemetryContext telemetry = CreateRefreshTelemetry(out Mock<ILogger> logger);
Task<bool> logged = ObserveError(logger, "AliasRefreshFailed");
Task<bool> terminated = ObserveError(logger, "BackgroundTaskFailed");
var retried = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
int calls = 0;
harness.Query = (id, pattern, reference, types, ct) =>
{
if (Interlocked.Increment(ref calls) == 1)
{
throw failure;
}
return harness.Data.FindAliasVerboseAsync(id, pattern, reference, types, ct);
};
await using var coordinator = new AliasNameRefreshCoordinator(
"AliasFailureTest", telemetry, async (_, ct) =>
{
await harness.Materializer.RefreshCategoryAsync(
harness.Store.Object, harness.CategoryId, materializeAliasNodes: true, cancellationToken: ct)
.ConfigureAwait(false);
retried.TrySetResult(true);
});
await harness.Data.AddAliasesAsync(harness.CategoryId,
[new AliasAddRequest("Beta", harness.Target.NodeId, null, ReferenceTypeIds.AliasFor)])
.ConfigureAwait(false);
coordinator.RequestRefresh();
Task<bool> outcome = await Task.WhenAny(logged, terminated).ConfigureAwait(false);
Assert.That(outcome, Is.SameAs(logged), "A backend failure must not terminate the only refresh worker.");
Assert.That(harness.FindAlias("Alpha"), Is.SameAs(original));
Assert.That(harness.FindAlias("Beta"), Is.Null);
Assert.That(harness.Target.ReferenceExists(ReferenceTypeIds.AliasFor, true, original.NodeId), Is.True);
await harness.Data.AddAliasesAsync(harness.CategoryId,
[new AliasAddRequest("Gamma", harness.Target.NodeId, null, ReferenceTypeIds.AliasFor)])
.ConfigureAwait(false);
coordinator.RequestRefresh();
await retried.Task.ConfigureAwait(false);
logger.Verify(log => log.Log(LogLevel.Error,
It.Is<EventId>(id => id.Name == "AliasRefreshFailed"), It.IsAny<It.IsAnyType>(), failure,
It.IsAny<Func<It.IsAnyType, Exception?, string>>()), Times.Once);
Assert.That(terminated.IsCompleted, Is.False);
Assert.That(calls, Is.EqualTo(2));
Assert.That(coordinator.PendingTaskCount, Is.EqualTo(1));
Assert.That(harness.FindAlias("Alpha"), Is.SameAs(original));
Assert.That(harness.FindAlias("Beta"), Is.Not.Null);
Assert.That(harness.FindAlias("Gamma"), Is.Not.Null);
Assert.That(harness.Target.ReferenceExists(
ReferenceTypeIds.AliasFor, true, harness.FindAlias("Beta").NodeId), Is.True);
Assert.That(harness.Target.ReferenceExists(
ReferenceTypeIds.AliasFor, true, harness.FindAlias("Gamma").NodeId), Is.True);
}
[TestCase(false)]
[TestCase(true)]
public async Task ShutdownDistinguishesCancellationFromBackendFailureAsync(bool backendFailure)
{
ITelemetryContext telemetry = CreateRefreshTelemetry(out Mock<ILogger> logger);
var entered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var failure = new HttpRequestException("Alias store failed during shutdown.");
int calls = 0;
await using var coordinator = new AliasNameRefreshCoordinator(
"AliasShutdownTest", telemetry, async (_, ct) =>
{
Interlocked.Increment(ref calls);
entered.TrySetResult(true);
await release.Task.ConfigureAwait(false);
if (backendFailure)
{
throw failure;
}
ct.ThrowIfCancellationRequested();
});
try
{
coordinator.RequestRefresh();
await entered.Task.ConfigureAwait(false);
coordinator.RequestRefresh();
coordinator.Dispose();
}
finally
{
release.TrySetResult(true);
}
await coordinator.DisposeAsync().ConfigureAwait(false);
coordinator.RequestRefresh();
Assert.That(calls, Is.EqualTo(1));
Assert.That(coordinator.PendingTaskCount, Is.Zero);
Assert.That(coordinator.PendingRefreshCount, Is.Zero);
logger.Verify(log => log.Log(LogLevel.Error,
It.IsAny<EventId>(), It.IsAny<It.IsAnyType>(), It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()), backendFailure ? Times.Once : Times.Never);
logger.Verify(log => log.Log(LogLevel.Error,
It.Is<EventId>(id => id.Name == "AliasRefreshFailed"), It.IsAny<It.IsAnyType>(), failure,
It.IsAny<Func<It.IsAnyType, Exception?, string>>()), backendFailure ? Times.Once : Times.Never);
logger.Verify(log => log.Log(LogLevel.Error,
It.Is<EventId>(id => id.Name == "BackgroundTaskFailed"), It.IsAny<It.IsAnyType>(),
It.IsAny<Exception>(), It.IsAny<Func<It.IsAnyType, Exception?, string>>()), Times.Never);
}
[TestCaseSource(nameof(s_fatalFailures))]
public async Task FatalRefreshFailureIsNotRetriedAsync(Exception failure)
{
ITelemetryContext telemetry = CreateRefreshTelemetry(out Mock<ILogger> logger);
Task<bool> logged = ObserveError(logger, "AliasRefreshFailed");
Task<bool> terminated = ObserveError(logger, "BackgroundTaskFailed");
int calls = 0;
await using var coordinator = new AliasNameRefreshCoordinator(
"AliasFatalTest", telemetry, (_, _) =>
{
Interlocked.Increment(ref calls);
throw failure;
});
coordinator.RequestRefresh();
Task<bool> outcome = await Task.WhenAny(logged, terminated).ConfigureAwait(false);
Assert.That(outcome, Is.SameAs(terminated));
await coordinator.DisposeAsync().ConfigureAwait(false);
Assert.That(calls, Is.EqualTo(1));
Assert.That(coordinator.PendingTaskCount, Is.Zero);
logger.Verify(log => log.Log(LogLevel.Error,
It.Is<EventId>(id => id.Name == "BackgroundTaskFailed"), It.IsAny<It.IsAnyType>(), failure,
It.IsAny<Func<It.IsAnyType, Exception?, string>>()), Times.Once);
logger.Verify(log => log.Log(LogLevel.Error,
It.Is<EventId>(id => id.Name == "AliasRefreshFailed"), It.IsAny<It.IsAnyType>(),
It.IsAny<Exception>(), It.IsAny<Func<It.IsAnyType, Exception?, string>>()), Times.Never);
}
[Test]
public async Task HostDisposalCancelsAndDrainsAnInFlightStoreQueryAsync()
{
await using var harness = new RefreshHarness(refreshOnChange: true);
await harness.InitializeAsync().ConfigureAwait(false);
var entered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var stopped = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
harness.Query = async (_, _, _, _, ct) =>
{
entered.TrySetResult(true);
try
{
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
return [];
}
finally
{
stopped.TrySetResult(true);
}
};
await harness.Data.AddAliasesAsync(harness.CategoryId,
[new AliasAddRequest("Beta", harness.Target.NodeId, null, ReferenceTypeIds.AliasFor)])
.ConfigureAwait(false);
await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
await harness.Manager.DisposeAsync().ConfigureAwait(false);
Assert.That(stopped.Task.Status, Is.EqualTo(TaskStatus.RanToCompletion));
harness.Store.Invocations.Clear();
await harness.Data.AddAliasesAsync(harness.CategoryId,
[new AliasAddRequest("Gamma", harness.Target.NodeId, null, ReferenceTypeIds.AliasFor)])
.ConfigureAwait(false);
harness.Store.Verify(store => store.FindAliasVerboseAsync(
It.IsAny<NodeId>(), It.IsAny<string>(), It.IsAny<NodeId>(), It.IsAny<ITypeTable>(),
It.IsAny<CancellationToken>()), Times.Never);
}
[Test]
public async Task AncestorNotificationsCoalesceWithoutPublishingStaleGenerationsAsync()
{
await using var harness = new RefreshHarness(refreshOnChange: true, nested: true);
await harness.InitializeAsync().ConfigureAwait(false);
AliasNameState original = harness.FindAlias("Alpha");
AliasNameCategoryState child = harness.Manager.FindPredefinedNode<AliasNameCategoryState>(
harness.ChildCategoryId);
var entered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var published = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var queried = new ConcurrentQueue<NodeId>();
var observedConcurrency = new ConcurrentQueue<int>();
var publishedCounts = new ConcurrentQueue<int>();
int active = 0;
harness.Query = async (id, pattern, reference, types, ct) =>
{
queried.Enqueue(id);
observedConcurrency.Enqueue(Interlocked.Increment(ref active));
try
{
IReadOnlyList<AliasNameVerboseDataType> snapshot = await harness.Data.FindAliasVerboseAsync(
id, pattern, reference, types, ct).ConfigureAwait(false);
entered.TrySetResult(true);
await release.Task.WaitAsync(ct).ConfigureAwait(false);
return snapshot;
}
finally
{
Interlocked.Decrement(ref active);
}
};
child.OnStateChanged += (_, _, _) =>
{
if (harness.FindAlias("Nested", harness.ChildCategoryId) != null)
{
var references = new List<IReference>();
child.GetReferences(harness.Manager.SystemContext, references);
publishedCounts.Enqueue(references.FindAll(
reference => !reference.IsInverse && reference.ReferenceTypeId == ReferenceTypeIds.Organizes)
.Count);
if (harness.FindAlias("Child31", harness.ChildCategoryId) != null)
{
published.TrySetResult(true);
}
}
};
await harness.Data.AddAliasesAsync(harness.ChildCategoryId,
[new AliasAddRequest("Nested", harness.Target.NodeId, null, ReferenceTypeIds.AliasFor)])
.ConfigureAwait(false);
await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
for (int ii = 0; ii < 32; ii++)
{
await harness.Data.AddAliasesAsync(harness.ChildCategoryId,
[new AliasAddRequest("Child" + ii, harness.Target.NodeId, null, ReferenceTypeIds.AliasFor)])
.ConfigureAwait(false);
}
Assert.That(queried, Has.Count.EqualTo(1));
release.TrySetResult(true);
await published.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
Assert.That(queried, Has.Count.EqualTo(2));
Assert.That(queried, Is.All.EqualTo(harness.CategoryId));
Assert.That(observedConcurrency, Is.All.EqualTo(1));
Assert.That(publishedCounts, Is.Not.Empty.And.All.EqualTo(33));
Assert.That(harness.FindAlias("Alpha"), Is.SameAs(original));
Assert.That(child.LastChange!.Value, Is.EqualTo(33u));
Assert.That(harness.Category.LastChange!.Value, Is.EqualTo(33u));
}
private static ITelemetryContext CreateRefreshTelemetry(out Mock<ILogger> logger)
{
logger = new Mock<ILogger>();
logger.Setup(log => log.IsEnabled(It.IsAny<LogLevel>())).Returns(true);
var factory = new Mock<ILoggerFactory>();
factory.Setup(value => value.CreateLogger(It.IsAny<string>())).Returns(logger.Object);
var telemetry = new Mock<ITelemetryContext>();
telemetry.SetupGet(value => value.LoggerFactory).Returns(factory.Object);
return telemetry.Object;
}
private static Task<bool> ObserveError(Mock<ILogger> logger, string eventName)
{
var observed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
logger.Setup(log => log.Log(LogLevel.Error,
It.Is<EventId>(id => id.Name == eventName), It.IsAny<It.IsAnyType>(),
It.IsAny<Exception>(), It.IsAny<Func<It.IsAnyType, Exception?, string>>()))
.Callback(() => observed.TrySetResult(true));
return observed.Task;
}
private sealed class RefreshHarness : IAsyncDisposable
{
public delegate ValueTask<IReadOnlyList<AliasNameVerboseDataType>> AliasQuery(
NodeId categoryId,
string pattern,
NodeId referenceType,
ITypeTable typeTable,
CancellationToken cancellationToken);
public RefreshHarness(bool? refreshOnChange = false, bool nested = false)
{
Mock<IServerInternal> server = DeterministicServerMock.Create(out m_queues);
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
server.SetupGet(instance => instance.Telemetry).Returns(telemetry);
var descriptor = new AliasNameCategoryDescriptor(CategoryId,
new QualifiedName("Category", 1), AliasNameCapabilities.All,
nested
? [new AliasNameCategoryDescriptor(
ChildCategoryId, new QualifiedName("Child", 1), AliasNameCapabilities.All)]
: null);
Data = new InMemoryAliasNameStore([descriptor]);
Data.Seed(CategoryId, "Alpha", Target.NodeId, null, ReferenceTypeIds.AliasFor);
Store.SetupGet(store => store.RootCategories).Returns(Data.RootCategories);
Store.Setup(store => store.OwnsCategory(It.IsAny<NodeId>()))
.Returns((NodeId id) => Data.OwnsCategory(id));
Store.Setup(store => store.GetLastChange(It.IsAny<NodeId>()))
.Returns((NodeId id) => Data.GetLastChange(id));
Store.Setup(store => store.FindAliasVerboseAsync(
It.IsAny<NodeId>(), It.IsAny<string>(), It.IsAny<NodeId>(), It.IsAny<ITypeTable>(),
It.IsAny<CancellationToken>()))
.Returns((NodeId id, string pattern, NodeId reference, ITypeTable types, CancellationToken ct) =>
{
AliasQuery query = Query;
return query != null
? query(id, pattern, reference, types, ct)
: Data.FindAliasVerboseAsync(id, pattern, reference, types, ct);
});
Data.Changed += (_, args) => Store.Raise(store => store.Changed += null, args);
var options = new AliasNameNodeManagerOptions
{
NamespaceUri = DeterministicServerMock.TestNamespaceUri,
RegisterWithServerRegistry = false
};
if (refreshOnChange.HasValue)
{
options.RefreshAliasNodesOnChange = refreshOnChange.Value;
}
Manager = new AliasNameNodeManager(
server.Object, new ApplicationConfiguration(), Store.Object, options);
m_registry.Register(Store.Object);
Materializer = new AliasNameNodeMaterializer(
Manager, m_registry, _ => false,
telemetry.CreateLogger<AliasNameNodeMaterializer>());
}
public NodeId CategoryId { get; } = new("Category", 1);
public NodeId ChildCategoryId { get; } = new("Child", 1);
public BaseDataVariableState Target { get; } = new(null)
{
NodeId = new NodeId("Target", 1),
BrowseName = new QualifiedName("Target", 1),
DataType = DataTypeIds.Int32,
Value = 42
};
public InMemoryAliasNameStore Data { get; }
public Mock<IAliasNameStore> Store { get; } = new(MockBehavior.Strict);
public AliasQuery Query
{
get => Volatile.Read(ref m_query);
set => Volatile.Write(ref m_query, value);
}
public AliasNameNodeManager Manager { get; }
public AliasNameNodeMaterializer Materializer { get; }
public AliasNameCategoryState Category =>
Manager.FindPredefinedNode<AliasNameCategoryState>(CategoryId);
public async ValueTask InitializeAsync(CancellationToken cancellationToken = default)
{
await ((IAliasNameMaterializerHost)Manager).RegisterNodeAsync(Target, cancellationToken)
.ConfigureAwait(false);
var references = new Dictionary<NodeId, IList<IReference>>();
await Manager.CreateAddressSpaceAsync(references, cancellationToken).ConfigureAwait(false);
await Manager.AddReferencesAsync(references, cancellationToken).ConfigureAwait(false);
}
public AliasNameState FindAlias(string name, NodeId categoryId = default)
{
return Manager.FindPredefinedNode<AliasNameState>(
new NodeId(Utils.Format("{0}.{1}", categoryId.IsNull ? CategoryId : categoryId, name), 1));
}
public ValueTask RefreshAsync()
{
return Materializer.RefreshCategoryAsync(Store.Object, CategoryId, materializeAliasNodes: true);
}
public async ValueTask DisposeAsync()
{
await Manager.DisposeAsync().ConfigureAwait(false);
m_registry.Dispose();
Data.Dispose();
m_queues.Dispose();
}
private readonly AliasNameStoreRegistry m_registry = new();
private readonly MonitoredItemQueueFactory m_queues;
private AliasQuery m_query = null!;
}
private static readonly TestCaseData[] s_backendFailures =
[
new TestCaseData(new IOException("Alias store offline."))
.SetArgDisplayNames(nameof(IOException)),
new TestCaseData(new HttpRequestException("Alias HTTP store offline."))
.SetArgDisplayNames(nameof(HttpRequestException)),
new TestCaseData(new Mock<DbException>().Object)
.SetArgDisplayNames(nameof(DbException)),
new TestCaseData(new SocketException((int)SocketError.ConnectionReset))
.SetArgDisplayNames(nameof(SocketException)),
new TestCaseData(new Mock<NullReferenceException>().Object)
.SetArgDisplayNames(nameof(NullReferenceException)),
new TestCaseData(new OperationCanceledException("Alias query canceled.", new CancellationToken(true)))
.SetArgDisplayNames(nameof(OperationCanceledException))
];
private static readonly TestCaseData[] s_fatalFailures =
[
new TestCaseData(new Mock<OutOfMemoryException>().Object)
.SetArgDisplayNames(nameof(OutOfMemoryException)),
new TestCaseData(new Mock<AccessViolationException>().Object)
.SetArgDisplayNames(nameof(AccessViolationException))
];
}
}