mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-16 21:02:51 +08:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user