Restore monitoring for survivors of partial node deletion (#4776)

# Description

Complete failed recursive deletion according to each monitored node's
actual state, rather than inferring the entire subtree's outcome from
the root.

Monitored items for removed nodes remain detached and source-deleted.
Items for surviving descendants are restored with their existing
identity and ownership. Restoration attempts continue after individual
failures, and compensation failures are surfaced together with the
original deletion failure.

The change covers internal and service-facing async deletion and the
synchronous compatibility path. Regression tests monitor both the
removed root and surviving child across both monitored-item
implementations.

## Related Issues

_Reference all GitHub issues this PR addresses. If there is no issue
yet, open one and link it here._

_If this is a relatively large or complex change, a design must have
been discussed in the related tracking issue and signed off (which
becomes the Architectural Decision Record (ADR))._

- Fixes #4768
- Related: #4769, #4770
- Design and failure reproduction are recorded in #4768; this draft
awaits review/sign-off.

## Checklist

_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._

- [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.
- [x] 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.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

## Validation

The server test project and its dependencies built successfully on
net10.0 and net48 with no new build/analyzer warnings. The entire
monitored-item lifecycle fixture, including six new partial-subtree
cases and four new compensation-failure cases, passed:

```powershell
dotnet test tests\Opc.Ua.Server.Tests\Opc.Ua.Server.Tests.csproj --no-build --no-restore -f net10.0 -p:CustomTestTarget=net10.0 --filter "FullyQualifiedName~MonitoredItemManagerLifecycleTests"
```

Result: **63 passed, 0 failed**.

```powershell
dotnet test tests\Opc.Ua.Server.Tests\Opc.Ua.Server.Tests.csproj --no-build --no-restore -f net48 -p:CustomTestTarget=net48 --filter "FullyQualifiedName~MonitoredItemManagerLifecycleTests"
```

Result: **63 passed, 0 failed**.

The full-solution .NET 10 VSTest invocation is blocked by the unchanged
AOT test projects' Microsoft.Testing.Platform configuration. Full
net48/net10 solution validation and CI remain outstanding.

The blocker was reproduced in the original-fix worktree with:

```powershell
dotnet test tests\Opc.Ua.Aot.Tests\Opc.Ua.Aot.Tests.csproj --no-build --no-restore -p:CustomTestTarget=net10.0 -p:NuGetAudit=false
```

Result: **failed** because Microsoft.Testing.Platform 2.4.1 rejects
VSTest under SDK 10.0.401. Restore/build commands used
`NuGetAudit=false` because the audit endpoint was unavailable;
repository audit settings are unchanged.

This is an independent master-based draft. It does not include the
separate NodeVersion eligibility fix or the model-change completion
redesign.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Cristi Pogacean
2026-10-14 08:43:56 +02:00
committed by GitHub
parent 89c1275d9d
commit f1e51c19f1
4 changed files with 393 additions and 75 deletions
+7
View File
@@ -108,6 +108,13 @@ in-memory `NodeStateCollection`), you can either:
changes, so it is safe to call at the end of an arbitrary service
implementation.
Deletion is not an atomic subtree transaction. If removal fails after deleting
some nodes, monitored items for removed nodes report `Bad_NodeIdUnknown`, while
items for descendants that remain registered are restored to their node manager.
The original failure is still reported; restoration failures are also surfaced.
Each asynchronous restoration gets its own five-second cleanup budget, so one
timed-out item does not cancel attempts to restore the remaining items.
### Manual aggregation across a transaction
`ModelChangeAggregator` is thread-safe and supports arbitrary batching:
@@ -1895,12 +1895,8 @@ namespace Opc.Ua.Server
contextToUse,
node,
cancellationToken).ConfigureAwait(false);
bool addressSpaceRemovalStarted = false;
bool addressSpaceRemovalCompleted = false;
try
{
addressSpaceRemovalStarted = true;
try
{
await RemovePredefinedNodeAsync(
@@ -1910,8 +1906,6 @@ namespace Opc.Ua.Server
{
await RemoveDeletedNotifiersAsync(removedNotifiers).ConfigureAwait(false);
}
addressSpaceRemovalCompleted = true;
// Refresh the parent's cached component view so a Browse issued
// after this runtime delete no longer reflects the removed child.
await RefreshParentComponentCacheAsync(
@@ -1925,23 +1919,9 @@ namespace Opc.Ua.Server
.ConfigureAwait(false);
}
}
catch
catch (Exception failure)
{
bool nodeStillExists = PredefinedNodes.ContainsKey(node.NodeId);
if (!addressSpaceRemovalStarted ||
(!addressSpaceRemovalCompleted && nodeStillExists))
{
await RestoreDetachedMonitoredItemsAsync(
detachedItems,
CancellationToken.None).ConfigureAwait(false);
}
else
{
foreach (IMonitoredItem monitoredItem in detachedItems)
{
((IDetachableMonitoredItem)monitoredItem).MarkNodeDeleted();
}
}
await RestoreSurvivingMonitoredItemsAsync(detachedItems, failure).ConfigureAwait(false);
throw;
}
@@ -2353,11 +2333,8 @@ namespace Opc.Ua.Server
cancellationToken).ConfigureAwait(false);
var referencesToRemove = new List<LocalReference>();
bool addressSpaceRemovalStarted = false;
bool addressSpaceRemovalCompleted = false;
try
{
addressSpaceRemovalStarted = true;
try
{
await RemovePredefinedNodeAsync(
@@ -2367,8 +2344,6 @@ namespace Opc.Ua.Server
{
await RemoveDeletedNotifiersAsync(removedNotifiers).ConfigureAwait(false);
}
addressSpaceRemovalCompleted = true;
// Refresh the parent's cached component view so a Browse issued
// after this runtime delete no longer reflects the removed child.
await RefreshParentComponentCacheAsync(
@@ -2382,23 +2357,9 @@ namespace Opc.Ua.Server
.ConfigureAwait(false);
}
}
catch
catch (Exception failure)
{
bool nodeStillExists = PredefinedNodes.ContainsKey(node.NodeId);
if (!addressSpaceRemovalStarted ||
(!addressSpaceRemovalCompleted && nodeStillExists))
{
await RestoreDetachedMonitoredItemsAsync(
detachedItems,
CancellationToken.None).ConfigureAwait(false);
}
else
{
foreach (IMonitoredItem monitoredItem in detachedItems)
{
((IDetachableMonitoredItem)monitoredItem).MarkNodeDeleted();
}
}
await RestoreSurvivingMonitoredItemsAsync(detachedItems, failure).ConfigureAwait(false);
throw;
}
@@ -2574,22 +2535,61 @@ namespace Opc.Ua.Server
return detachedItems;
}
private async ValueTask RestoreDetachedMonitoredItemsAsync(
private async ValueTask RestoreSurvivingMonitoredItemsAsync(
IReadOnlyList<IMonitoredItem> monitoredItems,
Exception deletionFailure)
{
var survivingItems = new List<IMonitoredItem>(monitoredItems.Count);
foreach (IMonitoredItem monitoredItem in monitoredItems)
{
if (PredefinedNodes.ContainsKey(monitoredItem.NodeId))
{
survivingItems.Add(monitoredItem);
}
else
{
((IDetachableMonitoredItem)monitoredItem).MarkNodeDeleted();
}
}
try
{
await RestoreDetachedMonitoredItemsAsync(survivingItems, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception restorationFailure) when (restorationFailure is not OutOfMemoryException)
{
throw new AggregateException(
"Node deletion and monitored-item restoration failed.", deletionFailure, restorationFailure);
}
}
private async ValueTask RestoreDetachedMonitoredItemsAsync(
List<IMonitoredItem> monitoredItems,
CancellationToken cancellationToken)
{
TimeProvider clock = (Server as ITimeProviderProvider)?.TimeProvider ?? TimeProvider.System;
using CancellationTokenSource timeout = clock.CreateCancellationTokenSource(TimeSpan.FromSeconds(5));
using var cleanup = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
List<Exception>? failures = null;
for (int ii = monitoredItems.Count - 1; ii >= 0; ii--)
{
ServiceResult result = await AttachMonitoredItemForLifecycleAsync(
monitoredItems[ii],
cleanup.Token).ConfigureAwait(false);
if (ServiceResult.IsBad(result))
try
{
throw new ServiceResultException(result);
using CancellationTokenSource timeout = clock.CreateCancellationTokenSource(TimeSpan.FromSeconds(5));
using var cleanup = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
ServiceResult result = await AttachMonitoredItemForLifecycleAsync(
monitoredItems[ii],
cleanup.Token).ConfigureAwait(false);
if (ServiceResult.IsBad(result))
{
throw new ServiceResultException(result);
}
}
catch (Exception failure) when (failure is not OutOfMemoryException)
{
(failures ??= []).Add(failure);
}
}
if (failures != null)
{
throw new AggregateException("Monitored-item restoration failed.", failures);
}
}
@@ -972,9 +972,6 @@ namespace Opc.Ua.Server
IReadOnlyList<IMonitoredItem> monitoredItems =
GetMonitoredItemsForNodeSubtree(contextToUse, node);
var detachedItems = new List<IMonitoredItem>(monitoredItems.Count);
bool addressSpaceRemovalStarted = false;
bool addressSpaceRemovalCompleted = false;
try
{
foreach (IMonitoredItem monitoredItem in monitoredItems)
@@ -988,9 +985,7 @@ namespace Opc.Ua.Server
detachedItems.Add(monitoredItem);
}
addressSpaceRemovalStarted = true;
RemovePredefinedNode(contextToUse, node!, referencesToRemove);
addressSpaceRemovalCompleted = true;
RemoveRootNotifier(node!);
if (referencesToRemove.Count > 0)
@@ -1000,21 +995,9 @@ namespace Opc.Ua.Server
#pragma warning restore CS0618
}
}
catch
catch (Exception failure)
{
bool nodeStillExists = PredefinedNodes.ContainsKey(node.NodeId);
if (!addressSpaceRemovalStarted ||
(!addressSpaceRemovalCompleted && nodeStillExists))
{
RestoreDetachedMonitoredItems(detachedItems);
}
else
{
foreach (IMonitoredItem monitoredItem in detachedItems)
{
((IDetachableMonitoredItem)monitoredItem).MarkNodeDeleted();
}
}
RestoreSurvivingMonitoredItems(detachedItems, failure);
throw;
}
@@ -1059,16 +1042,55 @@ namespace Opc.Ua.Server
return lifecycle.GetMonitoredItemsSnapshot(nodeIds);
}
private void RestoreSurvivingMonitoredItems(
List<IMonitoredItem> monitoredItems,
Exception deletionFailure)
{
var survivingItems = new List<IMonitoredItem>(monitoredItems.Count);
foreach (IMonitoredItem monitoredItem in monitoredItems)
{
if (PredefinedNodes.ContainsKey(monitoredItem.NodeId))
{
survivingItems.Add(monitoredItem);
}
else
{
((IDetachableMonitoredItem)monitoredItem).MarkNodeDeleted();
}
}
try
{
RestoreDetachedMonitoredItems(survivingItems);
}
catch (Exception restorationFailure) when (restorationFailure is not OutOfMemoryException)
{
throw new AggregateException(
"Node deletion and monitored-item restoration failed.", deletionFailure, restorationFailure);
}
}
private void RestoreDetachedMonitoredItems(
List<IMonitoredItem> monitoredItems)
{
List<Exception>? failures = null;
for (int ii = monitoredItems.Count - 1; ii >= 0; ii--)
{
ServiceResult result = AttachMonitoredItemForLifecycle(monitoredItems[ii]);
if (ServiceResult.IsBad(result))
try
{
throw new ServiceResultException(result);
ServiceResult result = AttachMonitoredItemForLifecycle(monitoredItems[ii]);
if (ServiceResult.IsBad(result))
{
throw new ServiceResultException(result);
}
}
catch (Exception failure) when (failure is not OutOfMemoryException)
{
(failures ??= []).Add(failure);
}
}
if (failures != null)
{
throw new AggregateException("Monitored-item restoration failed.", failures);
}
}
@@ -561,6 +561,294 @@ namespace Opc.Ua.Server.Tests
}
}
/// <summary>
/// A partially deleted subtree preserves monitoring for surviving descendants.
/// </summary>
[TestCase(ManagerPath.AsyncMonitoredNode, false)]
[TestCase(ManagerPath.AsyncSamplingGroup, false)]
[TestCase(ManagerPath.SyncAdapterMonitoredNode, false)]
[TestCase(ManagerPath.SyncAdapterSamplingGroup, false)]
[TestCase(ManagerPath.AsyncMonitoredNode, true)]
[TestCase(ManagerPath.AsyncSamplingGroup, true)]
public async Task PartialNodeDeletionRestoresSurvivingMonitoredItemsAsync(ManagerPath path, bool service)
{
Mock<IServerInternal> server = DeterministicServerMock.Create(
out MonitoredItemQueueFactory queueFactory);
using (queueFactory)
using (ManagerOwner owner = CreateManager(server.Object, path))
{
var root = new BaseDataVariableState(null)
{
NodeId = new NodeId("PartialRemoval", owner.NamespaceIndex),
BrowseName = new QualifiedName("PartialRemoval", owner.NamespaceIndex),
DataType = DataTypeIds.Int32,
Value = 1
};
root.CreateAsPredefinedNode(owner.SystemContext);
var child = new BaseDataVariableState(root)
{
NodeId = new NodeId("SurvivingChild", owner.NamespaceIndex),
BrowseName = new QualifiedName("SurvivingChild", owner.NamespaceIndex),
ReferenceTypeId = ReferenceTypeIds.HasComponent,
DataType = DataTypeIds.Int32,
Value = 2
};
child.CreateAsPredefinedNode(owner.SystemContext);
root.AddChild(child);
await owner.AddNodeAsync(root).ConfigureAwait(false);
using MonitoredItem rootItem = CreateMonitoredItem(
server.Object, Mock.Of<IAsyncNodeManager>(), new NodeHandle(), root.NodeId, 2);
using MonitoredItem childItem = CreateMonitoredItem(
server.Object, Mock.Of<IAsyncNodeManager>(), new NodeHandle(), child.NodeId, 3);
var rootLifecycle = (IDetachableMonitoredItem)rootItem;
var childLifecycle = (IDetachableMonitoredItem)childItem;
rootLifecycle.Detach(server.Object);
childLifecycle.Detach(server.Object);
Assert.That(ServiceResult.IsGood(
await owner.Lifecycle.AttachMonitoredItemAsync(rootItem).ConfigureAwait(false)), Is.True);
Assert.That(ServiceResult.IsGood(
await owner.Lifecycle.AttachMonitoredItemAsync(childItem).ConfigureAwait(false)), Is.True);
var failure = new InvalidOperationException("Injected partial removal failure.");
owner.SetNodeRemovedCallback(removed =>
{
if (ReferenceEquals(removed, root))
{
throw failure;
}
});
await Assert.ThatAsync(async () =>
{
if (service)
{
using var context = new OperationContext(rootItem);
await owner.AsyncManager.DeleteNodeAsync(
context, new DeleteNodesItem { NodeId = root.NodeId }).ConfigureAwait(false);
}
else
{
await owner.DeleteNodeAsync(root.NodeId).ConfigureAwait(false);
}
}, Throws.TypeOf<InvalidOperationException>().And.SameAs(failure)).ConfigureAwait(false);
IReadOnlyList<IMonitoredItem> remaining = await owner.Lifecycle
.GetMonitoredItemsSnapshotAsync().ConfigureAwait(false);
using (Assert.EnterMultipleScope())
{
Assert.That(owner.ContainsNode(root.NodeId), Is.False);
Assert.That(owner.ContainsNode(child.NodeId), Is.True);
Assert.That(rootLifecycle.IsDeleted, Is.True);
Assert.That(rootLifecycle.IsDetached, Is.True);
Assert.That(childLifecycle.IsDeleted, Is.False);
Assert.That(childLifecycle.IsDetached, Is.False);
Assert.That(owner.Owns(childItem.NodeManager), Is.True);
Assert.That(remaining, Has.Count.EqualTo(1));
Assert.That(remaining[0], Is.SameAs(childItem));
}
}
}
/// <summary>
/// Restoration failures do not hide the deletion failure or abandon other surviving items.
/// </summary>
[TestCase(ManagerPath.AsyncMonitoredNode)]
[TestCase(ManagerPath.AsyncSamplingGroup)]
[TestCase(ManagerPath.SyncAdapterMonitoredNode)]
[TestCase(ManagerPath.SyncAdapterSamplingGroup)]
public async Task PartialDeletionPreservesFailuresAndRestoresOtherItemsAsync(ManagerPath path)
{
Mock<IServerInternal> server = DeterministicServerMock.Create(
out MonitoredItemQueueFactory queueFactory);
using (queueFactory)
using (ManagerOwner owner = CreateManager(server.Object, path))
{
var root = new BaseObjectState(null)
{
NodeId = new NodeId("FailedRoot", owner.NamespaceIndex),
BrowseName = new QualifiedName("FailedRoot", owner.NamespaceIndex)
};
root.CreateAsPredefinedNode(owner.SystemContext);
var value = new BaseDataVariableState(root)
{
NodeId = new NodeId("SurvivingValue", owner.NamespaceIndex),
BrowseName = new QualifiedName("SurvivingValue", owner.NamespaceIndex),
ReferenceTypeId = ReferenceTypeIds.HasComponent,
DataType = DataTypeIds.Int32,
Value = 1
};
value.CreateAsPredefinedNode(owner.SystemContext);
var events = new BaseObjectState(root)
{
NodeId = new NodeId("SurvivingEvents", owner.NamespaceIndex),
BrowseName = new QualifiedName("SurvivingEvents", owner.NamespaceIndex),
ReferenceTypeId = ReferenceTypeIds.HasComponent,
EventNotifier = EventNotifiers.SubscribeToEvents
};
events.CreateAsPredefinedNode(owner.SystemContext);
root.AddChild(value);
root.AddChild(events);
await owner.AddNodeAsync(root).ConfigureAwait(false);
using MonitoredItem valueItem = CreateMonitoredItem(
server.Object, Mock.Of<IAsyncNodeManager>(), new NodeHandle(), value.NodeId, 2);
using MonitoredItem eventItem = CreateEventMonitoredItem(
server.Object, Mock.Of<IAsyncNodeManager>(), new NodeHandle(), events.NodeId, 3);
var valueLifecycle = (IDetachableMonitoredItem)valueItem;
var eventLifecycle = (IDetachableMonitoredItem)eventItem;
valueLifecycle.Detach(server.Object);
eventLifecycle.Detach(server.Object);
Assert.That(ServiceResult.IsGood(
await owner.Lifecycle.AttachMonitoredItemAsync(valueItem).ConfigureAwait(false)), Is.True);
Assert.That(ServiceResult.IsGood(
await owner.Lifecycle.AttachMonitoredItemAsync(eventItem).ConfigureAwait(false)), Is.True);
var deletionFailure = new InvalidOperationException("Injected deletion failure.");
var restorationFailure = new InvalidOperationException("Injected restoration failure.");
bool restoring = false;
owner.SetEventSubscriptionCallback(unsubscribe =>
{
if (restoring && !unsubscribe)
{
throw restorationFailure;
}
});
owner.SetNodeRemovedCallback(removed =>
{
if (ReferenceEquals(removed, root))
{
restoring = true;
throw deletionFailure;
}
});
AggregateException? observed = null;
try
{
await owner.DeleteNodeAsync(root.NodeId).ConfigureAwait(false);
}
catch (AggregateException failure)
{
observed = failure;
}
owner.SetEventSubscriptionCallback(static _ => { });
Assert.That(observed, Is.Not.Null);
using (Assert.EnterMultipleScope())
{
Assert.That(observed!.Flatten().InnerExceptions, Does.Contain(deletionFailure));
Assert.That(observed.Flatten().InnerExceptions, Does.Contain(restorationFailure));
Assert.That(valueLifecycle.IsDetached, Is.False);
Assert.That(valueLifecycle.IsDeleted, Is.False);
Assert.That(owner.Owns(valueItem.NodeManager), Is.True);
Assert.That(eventLifecycle.IsDeleted, Is.False);
}
}
}
/// <summary>
/// A timed-out restoration does not consume the cleanup budget of the next surviving item.
/// </summary>
[TestCase(ManagerPath.AsyncMonitoredNode)]
[TestCase(ManagerPath.AsyncSamplingGroup)]
public async Task TimedOutRestorationDoesNotCancelFollowingItemsAsync(ManagerPath path)
{
var clock = new FakeTimeProvider();
Mock<IServerInternal> server = DeterministicServerMock.Create(
out MonitoredItemQueueFactory queueFactory, clock);
using (queueFactory)
using (ManagerOwner owner = CreateManager(server.Object, path))
{
var root = new BaseObjectState(null)
{
NodeId = new NodeId("TimedOutRestoration", owner.NamespaceIndex),
BrowseName = new QualifiedName("TimedOutRestoration", owner.NamespaceIndex)
};
root.CreateAsPredefinedNode(owner.SystemContext);
var first = new BaseObjectState(root)
{
NodeId = new NodeId("FirstSurvivor", owner.NamespaceIndex),
BrowseName = new QualifiedName("FirstSurvivor", owner.NamespaceIndex),
ReferenceTypeId = ReferenceTypeIds.HasComponent,
EventNotifier = EventNotifiers.SubscribeToEvents
};
var second = new BaseObjectState(root)
{
NodeId = new NodeId("SecondSurvivor", owner.NamespaceIndex),
BrowseName = new QualifiedName("SecondSurvivor", owner.NamespaceIndex),
ReferenceTypeId = ReferenceTypeIds.HasComponent,
EventNotifier = EventNotifiers.SubscribeToEvents
};
first.CreateAsPredefinedNode(owner.SystemContext);
second.CreateAsPredefinedNode(owner.SystemContext);
root.AddChild(first);
root.AddChild(second);
await owner.AddNodeAsync(root).ConfigureAwait(false);
using MonitoredItem firstItem = CreateEventMonitoredItem(
server.Object, Mock.Of<IAsyncNodeManager>(), new NodeHandle(), first.NodeId, 2);
using MonitoredItem secondItem = CreateEventMonitoredItem(
server.Object, Mock.Of<IAsyncNodeManager>(), new NodeHandle(), second.NodeId, 3);
var firstLifecycle = (IDetachableMonitoredItem)firstItem;
var secondLifecycle = (IDetachableMonitoredItem)secondItem;
firstLifecycle.Detach(server.Object);
secondLifecycle.Detach(server.Object);
Assert.That(ServiceResult.IsGood(
await owner.Lifecycle.AttachMonitoredItemAsync(firstItem).ConfigureAwait(false)), Is.True);
Assert.That(ServiceResult.IsGood(
await owner.Lifecycle.AttachMonitoredItemAsync(secondItem).ConfigureAwait(false)), Is.True);
var entered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
int restorationCalls = 0;
owner.AsyncManager.EventSubscriptionCallback = async (unsubscribe, ct) =>
{
if (!unsubscribe && Interlocked.Increment(ref restorationCalls) == 1)
{
entered.TrySetResult(true);
await release.Task.WaitAsync(ct).ConfigureAwait(false);
}
};
var deletionFailure = new InvalidOperationException("Injected deletion failure.");
owner.SetNodeRemovedCallback(removed =>
{
if (ReferenceEquals(removed, root))
{
throw deletionFailure;
}
});
Task<bool> deletion = owner.DeleteNodeAsync(root.NodeId).AsTask();
try
{
await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
clock.Advance(TimeSpan.FromSeconds(5) - TimeSpan.FromTicks(1));
Assert.That(deletion.IsCompleted, Is.False);
clock.Advance(TimeSpan.FromTicks(1));
await Assert.ThatAsync(
() => deletion.WaitAsync(TimeSpan.FromSeconds(10)),
Throws.TypeOf<AggregateException>().With.InnerException.SameAs(deletionFailure))
.ConfigureAwait(false);
IReadOnlyList<IMonitoredItem> remaining = await owner.Lifecycle
.GetMonitoredItemsSnapshotAsync().ConfigureAwait(false);
using (Assert.EnterMultipleScope())
{
Assert.That(restorationCalls, Is.EqualTo(2));
Assert.That(remaining, Has.Count.EqualTo(1));
Assert.That(firstLifecycle.IsDetached, Is.Not.EqualTo(secondLifecycle.IsDetached));
Assert.That(firstLifecycle.IsDeleted, Is.False);
Assert.That(secondLifecycle.IsDeleted, Is.False);
}
}
finally
{
owner.AsyncManager.EventSubscriptionCallback = static (_, _) => default;
release.TrySetResult(true);
try
{
await deletion.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
}
catch (AggregateException failure) when (
failure.Flatten().InnerExceptions.Contains(deletionFailure))
{
}
}
}
}
[Test]
public async Task DurableStartupFallbackCanBeDeletedAndLaterRecoveredAsync()
{
@@ -1956,7 +2244,8 @@ namespace Opc.Ua.Server.Tests
IServerInternal server,
IAsyncNodeManager nodeManager,
object managerHandle,
NodeId nodeId)
NodeId nodeId,
uint id = 2)
{
var identity = new Mock<IUserIdentity>();
var session = new Mock<ISession>();
@@ -1973,7 +2262,7 @@ namespace Opc.Ua.Server.Tests
nodeManager,
managerHandle,
subscriptionId: 1,
id: 2,
id,
itemToMonitor: new ReadValueId
{
NodeId = nodeId,