mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-17 05:11:13 +08:00
Complete model-change reporting for committed node deletions (#4778)
# Description Finalize deletion model-change reporting from observed address-space outcomes, including when later cleanup fails or the caller cancels the request. A shared internal snapshot captures registered subtree nodes, pre-removal NodeVersion eligibility, and relevant reference endpoints. Completion reports only removed nodes and references actually removed from surviving local nodes, then rethrows the original deletion failure. If reporting also fails, both errors remain visible. This preserves notifications for versioned descendants below unversioned roots and applies strict deletion eligibility to the service-facing overload. It avoids blindly reporting the requested subtree from a finally block. For endpoints owned by another node manager in the same server, reporting requires target-reference cleanup to be both requested and successful because the remaining foreign references cannot be inspected locally. A successful service deletion with `DeleteTargetReferences = false` does not imply foreign reference removal. Reporting covers both directions of hierarchical and non-hierarchical references; absolute targets are excluded, matching the cleanup path. Master cleanup now propagates a bad per-reference `DeleteReferenceAsync` result as a `ServiceResultException`. A normal return with a bad status is therefore not mistaken for successful foreign cleanup. Committed local changes are still reported before the cleanup failure is rethrown; unconfirmed foreign reference removals are not reported. The branch includes the merge of current master after #4776 and #4777 merged. This PR preserves their per-node monitored-item restoration, independent cleanup budgets, and NodeVersion eligibility behavior. It does not introduce further monitored-item completion changes; the remaining diff is outcome-based model-change completion, its regression tests, and documentation. It contains no release-version changes. ## 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 #4769 - Related: #4768, #4770 (addressed by merged PRs #4776 and #4777). - Design and failure reproduction are recorded in #4769. ## 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 ### Current head: `524e46fbe` The server test project and its dependencies built without build/analyzer warnings or errors. The following command covers the deletion/model-change regressions, the complete monitored-item lifecycle fixture, and the master node-management dispatcher fixture: ```powershell & 'C:\Program Files\dotnet\dotnet.exe' exec 'C:\Program Files\dotnet\sdk\10.0.402\dotnet.dll' test tests\Opc.Ua.Server.Tests\Opc.Ua.Server.Tests.csproj --no-restore -f net48 -p:CustomTestTarget=net48 -p:NuGetAudit=false -m:2 --filter "FullyQualifiedName~AsyncCustomNodeManagerTests&Name~Delet|FullyQualifiedName~AsyncCustomNodeManagerNodeManagementTests&Name~DeleteNode|FullyQualifiedName~MonitoredItemManagerLifecycleTests|FullyQualifiedName~MasterNodeManagerNodeManagementTests" --logger "trx;LogFileName=foreign-cleanup-status-net48.trx" --results-directory 'C:\Users\crpogace\.copilot\session-state\f4f431fc-413e-40d4-88a7-83f6ade0c9a7\files\foreign-cleanup-status-results' ``` Result: **296 passed, 0 failed**; 25 fixture combinations are inapplicable to the legacy adapter and were not executed. Regression coverage includes late cleanup failure/cancellation, pre-mutation cancellation, partial subtree removal, service eligibility, versioned descendants, preservation of simultaneous deletion/reporting failures, foreign-reference direction/type combinations, requested versus omitted cleanup, failed cleanup, and absolute-target exclusion. New regressions exercise the real master cleanup dispatcher with `Good`, `BadNotSupported`, and `BadUnexpectedError` results across async internal/service deletion and legacy runtime deletion. Before the fix, 10 bad-status cases failed; the 5 successful-cleanup controls passed. **Current-head net10.0 tests were not executed locally:** the machine now has SDK 10.0.402 rather than the pinned 10.0.401. Restore requires `Microsoft.NET.ILLink.Tasks` 10.0.13, which is absent from the local cache, and the NuGet endpoint fails its TLS handshake. This is an environment/restore blocker, not a test failure. The explicit SDK invocation above does not change the repository SDK pin. `NuGetAudit=false` was invocation-only because the audit endpoint was unavailable; repository audit settings are unchanged. The earlier pre-merge revision `0c53e143b` passed 96 focused deletion tests on each of net10.0 and net48, with 22 inapplicable fixture combinations per framework. Those historical results do not establish net10.0 validation of the current merged head. Full net48/net10.0 solution validation has not been completed locally. The ordinary .NET 10 VSTest invocation cannot run the unchanged AOT projects' Microsoft.Testing.Platform suites; this is a mixed-runner invocation limitation, not evidence of an AOT compilation defect. Their supported validation path is native publish-and-run, as used in CI. A separate local Historian native AOT run passed 2/2 tests earlier in this work, but does not establish complete AOT or current-head validation. CI was still running when this description was updated; the full-suite and CI checklist items remain unchecked. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1147,6 +1147,492 @@ namespace Opc.Ua.Server.Tests
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service deletion uses the node's pre-removal eligibility in strict and compatibility modes.
|
||||
/// </summary>
|
||||
[TestCase(false, true)]
|
||||
[TestCase(true, true)]
|
||||
[TestCase(false, false)]
|
||||
[TestCase(true, false)]
|
||||
public async Task ServiceDeleteNodePreservesEligibilityAsync(bool versioned, bool requireNodeVersion)
|
||||
{
|
||||
using ITestNodeManager manager = CreateManager();
|
||||
Assume.That(manager is TestableAsyncCustomNodeManager, "Requires node-management services.");
|
||||
manager.RequireNodeVersionForModelChange = requireNodeVersion;
|
||||
var node = new BaseObjectState(null);
|
||||
if (versioned)
|
||||
{
|
||||
manager.EnableModelChangeTrackingFor(node);
|
||||
}
|
||||
NodeId id = await manager.CreateNodeAsync(
|
||||
manager.SystemContext, default, ReferenceTypeIds.Organizes,
|
||||
new QualifiedName("ServiceDeletedNode", manager.NamespaceIndexes[0]), node).ConfigureAwait(false);
|
||||
m_mockServer.Invocations.Clear();
|
||||
|
||||
await DeleteTestNodeAsync(manager, id, true).ConfigureAwait(false);
|
||||
|
||||
Assert.That(manager.Find(id), Is.Null);
|
||||
bool expected = versioned || !requireNodeVersion;
|
||||
m_mockServer.Verify(value => value.ReportEvent(It.Is<IFilterTarget>(
|
||||
target => target is GeneralModelChangeEventState)), expected ? Times.Once() : Times.Never());
|
||||
if (expected)
|
||||
{
|
||||
Assert.That(GetReportedDeletionChanges()[0].Affected, Is.EqualTo(id));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Foreign endpoints of either direction and reference kind are reported only after requested cleanup.
|
||||
/// </summary>
|
||||
[TestCase(false, false, false)]
|
||||
[TestCase(false, false, true)]
|
||||
[TestCase(false, true, false)]
|
||||
[TestCase(false, true, true)]
|
||||
[TestCase(true, false, false)]
|
||||
[TestCase(true, false, true)]
|
||||
[TestCase(true, true, false)]
|
||||
[TestCase(true, true, true)]
|
||||
public async Task ServiceDeleteNodeReportsForeignReferenceOnlyWhenRequestedAsync(
|
||||
bool deleteTargetReferences,
|
||||
bool isInverse,
|
||||
bool hierarchical)
|
||||
{
|
||||
using ITestNodeManager manager = CreateManager();
|
||||
Assume.That(manager is TestableAsyncCustomNodeManager, "Requires node-management services.");
|
||||
var typeTree = (TypeTable)m_mockServer.Object.TypeTree;
|
||||
typeTree.AddSubtype(ReferenceTypeIds.References, NodeId.Null);
|
||||
typeTree.AddSubtype(ReferenceTypeIds.HierarchicalReferences, ReferenceTypeIds.References);
|
||||
typeTree.AddSubtype(ReferenceTypeIds.Organizes, ReferenceTypeIds.HierarchicalReferences);
|
||||
typeTree.AddSubtype(ReferenceTypeIds.NonHierarchicalReferences, ReferenceTypeIds.References);
|
||||
typeTree.AddSubtype(ReferenceTypeIds.HasCause, ReferenceTypeIds.NonHierarchicalReferences);
|
||||
NodeId referenceTypeId = hierarchical ? ReferenceTypeIds.Organizes : ReferenceTypeIds.HasCause;
|
||||
var node = new BaseObjectState(null);
|
||||
manager.EnableModelChangeTrackingFor(node);
|
||||
NodeId id = await manager.CreateNodeAsync(
|
||||
manager.SystemContext, default, ReferenceTypeIds.Organizes,
|
||||
new QualifiedName("ForeignReferenceSource", manager.NamespaceIndexes[0]), node).ConfigureAwait(false);
|
||||
var foreignTarget = new BaseObjectState(null)
|
||||
{
|
||||
NodeId = new NodeId("ForeignTarget", m_namespaceTable.GetIndexOrAppend("urn:test:foreign-node-manager"))
|
||||
};
|
||||
Assert.That(manager.Find(foreignTarget.NodeId), Is.Null);
|
||||
node.AddReference(referenceTypeId, isInverse, foreignTarget.NodeId);
|
||||
foreignTarget.AddReference(referenceTypeId, !isInverse, id);
|
||||
m_mockMasterNodeManager.Setup(value => value.RemoveReferencesAsync(
|
||||
It.IsAny<List<LocalReference>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<List<LocalReference>, CancellationToken>((references, _) =>
|
||||
{
|
||||
foreach (LocalReference reference in references)
|
||||
{
|
||||
if (reference.SourceId == foreignTarget.NodeId)
|
||||
{
|
||||
foreignTarget.RemoveReference(
|
||||
reference.ReferenceTypeId, reference.IsInverse, reference.TargetId);
|
||||
}
|
||||
}
|
||||
return default;
|
||||
});
|
||||
m_mockServer.Invocations.Clear();
|
||||
m_mockMasterNodeManager.Invocations.Clear();
|
||||
using var context = new OperationContext(
|
||||
new RequestHeader(), null!, RequestType.DeleteNodes, RequestLifetime.None, m_mockSession.Object);
|
||||
|
||||
ServiceResult result = await manager.DeleteNodeAsync(
|
||||
context, new DeleteNodesItem
|
||||
{
|
||||
NodeId = id,
|
||||
DeleteTargetReferences = deleteTargetReferences
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
Assert.That(ServiceResult.IsGood(result), Is.True);
|
||||
Assert.That(manager.Find(id), Is.Null);
|
||||
m_mockMasterNodeManager.Verify(value => value.RemoveReferencesAsync(
|
||||
It.IsAny<List<LocalReference>>(), It.IsAny<CancellationToken>()),
|
||||
deleteTargetReferences ? Times.Once() : Times.Never());
|
||||
Assert.That(foreignTarget.ReferenceExists(referenceTypeId, !isInverse, id),
|
||||
Is.EqualTo(!deleteTargetReferences));
|
||||
bool foreignReferenceReported = false;
|
||||
bool deletionReported = false;
|
||||
foreach (ModelChangeStructureDataType change in GetReportedDeletionChanges())
|
||||
{
|
||||
if (change.Affected == foreignTarget.NodeId)
|
||||
{
|
||||
foreignReferenceReported = true;
|
||||
Assert.That(change.Verb,
|
||||
Is.EqualTo((byte)Opc.Ua.Server.NodeManager.ModelChangeVerbs.ReferenceDeleted));
|
||||
}
|
||||
if (change.Affected == id)
|
||||
{
|
||||
deletionReported = true;
|
||||
Assert.That(change.Verb,
|
||||
Is.EqualTo((byte)Opc.Ua.Server.NodeManager.ModelChangeVerbs.NodeDeleted));
|
||||
}
|
||||
}
|
||||
Assert.That(deletionReported, Is.True);
|
||||
Assert.That(foreignReferenceReported, Is.EqualTo(deleteTargetReferences));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Failed foreign cleanup does not announce unconfirmed reference removals.
|
||||
/// </summary>
|
||||
[TestCase(false)]
|
||||
[TestCase(true)]
|
||||
public async Task ServiceDeleteNodeDoesNotReportForeignReferenceAfterCleanupFailureAsync(bool isInverse)
|
||||
{
|
||||
using ITestNodeManager manager = CreateManager();
|
||||
Assume.That(manager is TestableAsyncCustomNodeManager, "Requires node-management services.");
|
||||
var node = new BaseObjectState(null);
|
||||
manager.EnableModelChangeTrackingFor(node);
|
||||
NodeId id = await manager.CreateNodeAsync(
|
||||
manager.SystemContext, default, ReferenceTypeIds.Organizes,
|
||||
new QualifiedName("FailedForeignCleanup", manager.NamespaceIndexes[0]), node).ConfigureAwait(false);
|
||||
var foreignId = new NodeId(
|
||||
"ForeignTarget", m_namespaceTable.GetIndexOrAppend("urn:test:foreign-node-manager"));
|
||||
node.AddReference(ReferenceTypeIds.HasCause, isInverse, foreignId);
|
||||
var failure = new InvalidOperationException("Injected foreign reference cleanup failure.");
|
||||
m_mockMasterNodeManager.Setup(value => value.RemoveReferencesAsync(
|
||||
It.IsAny<List<LocalReference>>(), It.IsAny<CancellationToken>())).Throws(failure);
|
||||
m_mockServer.Invocations.Clear();
|
||||
|
||||
await Assert.ThatAsync(
|
||||
async () => await DeleteTestNodeAsync(manager, id, true).ConfigureAwait(false),
|
||||
Throws.TypeOf<InvalidOperationException>().And.SameAs(failure)).ConfigureAwait(false);
|
||||
|
||||
Assert.That(manager.Find(id), Is.Null);
|
||||
ArrayOf<ModelChangeStructureDataType> changes = GetReportedDeletionChanges();
|
||||
Assert.That(changes.Count, Is.EqualTo(1));
|
||||
Assert.That(changes[0].Affected, Is.EqualTo(id));
|
||||
Assert.That(changes[0].Verb, Is.EqualTo((byte)Opc.Ua.Server.NodeManager.ModelChangeVerbs.NodeDeleted));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Foreign cleanup status failures propagate without announcing retained references.
|
||||
/// </summary>
|
||||
[TestCase(false, 0)]
|
||||
[TestCase(false, 1)]
|
||||
[TestCase(false, 2)]
|
||||
[TestCase(true, 0)]
|
||||
[TestCase(true, 1)]
|
||||
[TestCase(true, 2)]
|
||||
public async Task DeleteNodeChecksForeignReferenceCleanupStatusAsync(bool service, int statusIndex)
|
||||
{
|
||||
StatusCode cleanupStatus = statusIndex switch
|
||||
{
|
||||
0 => StatusCodes.Good,
|
||||
1 => StatusCodes.BadNotSupported,
|
||||
_ => StatusCodes.BadUnexpectedError
|
||||
};
|
||||
using ITestNodeManager manager = CreateManager();
|
||||
Assume.That(!service || manager is TestableAsyncCustomNodeManager, "Requires node-management services.");
|
||||
var node = new BaseObjectState(null);
|
||||
manager.EnableModelChangeTrackingFor(node);
|
||||
NodeId id = await manager.CreateNodeAsync(
|
||||
manager.SystemContext, default, ReferenceTypeIds.Organizes,
|
||||
new QualifiedName("ForeignCleanupStatus", manager.NamespaceIndexes[0]), node).ConfigureAwait(false);
|
||||
const string ForeignNamespaceUri = "urn:test:foreign-cleanup-status";
|
||||
var foreignTarget = new BaseObjectState(null)
|
||||
{
|
||||
NodeId = new NodeId("ForeignTarget", m_namespaceTable.GetIndexOrAppend(ForeignNamespaceUri))
|
||||
};
|
||||
node.AddReference(ReferenceTypeIds.HasCause, false, foreignTarget.NodeId);
|
||||
foreignTarget.AddReference(ReferenceTypeIds.HasCause, true, id);
|
||||
var foreignManager = new Mock<IAsyncNodeManager>();
|
||||
foreignManager.Setup(value => value.NamespaceUris).Returns([ForeignNamespaceUri]);
|
||||
foreignManager.Setup(value => value.GetManagerHandleAsync(
|
||||
foreignTarget.NodeId, It.IsAny<CancellationToken>()))
|
||||
.Returns(new ValueTask<object>(foreignTarget));
|
||||
foreignManager.Setup(value => value.DeleteReferenceAsync(
|
||||
foreignTarget, ReferenceTypeIds.HasCause, true, id, false, It.IsAny<CancellationToken>()))
|
||||
.Returns(() =>
|
||||
{
|
||||
if (StatusCode.IsGood(cleanupStatus))
|
||||
{
|
||||
foreignTarget.RemoveReference(ReferenceTypeIds.HasCause, true, id);
|
||||
}
|
||||
return new ValueTask<ServiceResult>(new ServiceResult(cleanupStatus));
|
||||
});
|
||||
var factory = new Mock<IMainNodeManagerFactory>();
|
||||
factory.Setup(value => value.CreateConfigurationNodeManager())
|
||||
.Returns(new Mock<IConfigurationNodeManager>().Object);
|
||||
factory.Setup(value => value.CreateCoreNodeManager(It.IsAny<ushort>()))
|
||||
.Returns(new Mock<ICoreNodeManager>().Object);
|
||||
m_mockServer.Setup(value => value.MainNodeManagerFactory).Returns(factory.Object);
|
||||
using var master = new MasterNodeManager(
|
||||
m_mockServer.Object, m_configuration, null, [foreignManager.Object]);
|
||||
m_mockServer.Setup(value => value.NodeManager).Returns(master);
|
||||
m_mockServer.Invocations.Clear();
|
||||
|
||||
if (StatusCode.IsGood(cleanupStatus))
|
||||
{
|
||||
await DeleteTestNodeAsync(manager, id, service).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await Assert.ThatAsync(
|
||||
async () => await DeleteTestNodeAsync(manager, id, service).ConfigureAwait(false),
|
||||
Throws.TypeOf<ServiceResultException>()
|
||||
.With.Property(nameof(ServiceResultException.StatusCode)).EqualTo(cleanupStatus))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Assert.That(manager.Find(id), Is.Null);
|
||||
bool cleanupSucceeded = StatusCode.IsGood(cleanupStatus);
|
||||
Assert.That(foreignTarget.ReferenceExists(ReferenceTypeIds.HasCause, true, id),
|
||||
Is.EqualTo(!cleanupSucceeded));
|
||||
foreignManager.Verify(value => value.DeleteReferenceAsync(
|
||||
foreignTarget, ReferenceTypeIds.HasCause, true, id, false, It.IsAny<CancellationToken>()),
|
||||
Times.Once());
|
||||
ArrayOf<ModelChangeStructureDataType> changes = GetReportedDeletionChanges();
|
||||
Assert.That(changes.Count, Is.EqualTo(cleanupSucceeded ? 2 : 1));
|
||||
bool deletionReported = false;
|
||||
bool foreignReferenceReported = false;
|
||||
foreach (ModelChangeStructureDataType change in changes)
|
||||
{
|
||||
if (change.Affected == id)
|
||||
{
|
||||
deletionReported = true;
|
||||
Assert.That(change.Verb,
|
||||
Is.EqualTo((byte)Opc.Ua.Server.NodeManager.ModelChangeVerbs.NodeDeleted));
|
||||
}
|
||||
if (change.Affected == foreignTarget.NodeId)
|
||||
{
|
||||
foreignReferenceReported = true;
|
||||
Assert.That(change.Verb,
|
||||
Is.EqualTo((byte)Opc.Ua.Server.NodeManager.ModelChangeVerbs.ReferenceDeleted));
|
||||
}
|
||||
}
|
||||
Assert.That(deletionReported, Is.True);
|
||||
Assert.That(foreignReferenceReported, Is.EqualTo(cleanupSucceeded));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal and legacy deletion report foreign non-hierarchical endpoints after successful cleanup.
|
||||
/// </summary>
|
||||
[TestCase(false)]
|
||||
[TestCase(true)]
|
||||
public async Task DeleteNodeReportsForeignNonHierarchicalReferenceAsync(bool isInverse)
|
||||
{
|
||||
using ITestNodeManager manager = CreateManager();
|
||||
var node = new BaseObjectState(null);
|
||||
NodeId id = await manager.CreateNodeAsync(
|
||||
manager.SystemContext, default, ReferenceTypeIds.Organizes,
|
||||
new QualifiedName("ForeignCleanup", manager.NamespaceIndexes[0]), node).ConfigureAwait(false);
|
||||
var foreignId = new NodeId(
|
||||
"ForeignTarget", m_namespaceTable.GetIndexOrAppend("urn:test:foreign-node-manager"));
|
||||
node.AddReference(ReferenceTypeIds.HasCause, isInverse, foreignId);
|
||||
m_mockServer.Invocations.Clear();
|
||||
|
||||
await DeleteTestNodeAsync(manager, id, false).ConfigureAwait(false);
|
||||
|
||||
Assert.That(manager.Find(id), Is.Null);
|
||||
ArrayOf<ModelChangeStructureDataType> changes = GetReportedDeletionChanges();
|
||||
Assert.That(changes.Count, Is.EqualTo(1));
|
||||
Assert.That(changes[0].Affected, Is.EqualTo(foreignId));
|
||||
Assert.That(changes[0].Verb, Is.EqualTo((byte)Opc.Ua.Server.NodeManager.ModelChangeVerbs.ReferenceDeleted));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Absolute targets are not part of local reference cleanup or deletion reporting.
|
||||
/// </summary>
|
||||
[TestCase(false)]
|
||||
[TestCase(true)]
|
||||
public async Task DeleteNodeDoesNotReportAbsoluteReferenceTargetsAsync(bool isInverse)
|
||||
{
|
||||
using ITestNodeManager manager = CreateManager();
|
||||
var node = new BaseObjectState(null);
|
||||
manager.EnableModelChangeTrackingFor(node);
|
||||
NodeId id = await manager.CreateNodeAsync(
|
||||
manager.SystemContext, default, ReferenceTypeIds.Organizes,
|
||||
new QualifiedName("RemoteReferenceSource", manager.NamespaceIndexes[0]), node).ConfigureAwait(false);
|
||||
node.AddReference(ReferenceTypeIds.HasCause, isInverse, new ExpandedNodeId("Remote", "urn:test:remote", 1));
|
||||
m_mockServer.Invocations.Clear();
|
||||
|
||||
await DeleteTestNodeAsync(manager, id, false).ConfigureAwait(false);
|
||||
|
||||
Assert.That(manager.Find(id), Is.Null);
|
||||
ArrayOf<ModelChangeStructureDataType> changes = GetReportedDeletionChanges();
|
||||
Assert.That(changes.Count, Is.EqualTo(1));
|
||||
Assert.That(changes[0].Affected, Is.EqualTo(id));
|
||||
Assert.That(changes[0].Verb, Is.EqualTo((byte)Opc.Ua.Server.NodeManager.ModelChangeVerbs.NodeDeleted));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Committed reference removals are reported even when cleanup fails or is cancelled.
|
||||
/// </summary>
|
||||
[TestCase(false, false)]
|
||||
[TestCase(false, true)]
|
||||
[TestCase(true, false)]
|
||||
[TestCase(true, true)]
|
||||
public async Task DeleteNodeReportsCommittedChangesAfterCleanupFailureAsync(bool service, bool cancellation)
|
||||
{
|
||||
using ITestNodeManager manager = CreateManager();
|
||||
Assume.That(manager is TestableAsyncCustomNodeManager, "Requires asynchronous cleanup.");
|
||||
var asynchronous = (TestableAsyncCustomNodeManager)manager;
|
||||
var parent = new BaseObjectState(null);
|
||||
PropertyState<string> version = manager.EnableModelChangeTrackingFor(parent);
|
||||
NodeId parentId = await manager.CreateNodeAsync(
|
||||
manager.SystemContext, default, ReferenceTypeIds.Organizes,
|
||||
new QualifiedName("TrackedParent", manager.NamespaceIndexes[0]), parent).ConfigureAwait(false);
|
||||
var child = new BaseObjectState(null);
|
||||
NodeId childId = await manager.CreateNodeAsync(
|
||||
manager.SystemContext, parentId, ReferenceTypeIds.HasComponent,
|
||||
new QualifiedName("DeletedChild", manager.NamespaceIndexes[0]), child).ConfigureAwait(false);
|
||||
child.AddReference(
|
||||
ReferenceTypeIds.Organizes, false, new NodeId("ExternalTarget", manager.NamespaceIndexes[0]));
|
||||
string before = version.Value;
|
||||
using var request = new CancellationTokenSource();
|
||||
var failure = new InvalidOperationException("Injected reference cleanup failure.");
|
||||
m_mockMasterNodeManager.Setup(value => value.RemoveReferencesAsync(
|
||||
It.IsAny<List<LocalReference>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<List<LocalReference>, CancellationToken>((_, token) =>
|
||||
{
|
||||
if (cancellation)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
return default;
|
||||
}
|
||||
throw failure;
|
||||
});
|
||||
if (cancellation)
|
||||
{
|
||||
asynchronous.NodeRemovedCallback = (removed, _) =>
|
||||
{
|
||||
if (ReferenceEquals(removed, child))
|
||||
{
|
||||
request.Cancel();
|
||||
}
|
||||
return default;
|
||||
};
|
||||
}
|
||||
m_mockServer.Invocations.Clear();
|
||||
|
||||
await Assert.ThatAsync(
|
||||
async () => await DeleteTestNodeAsync(manager, childId, service, request.Token).ConfigureAwait(false),
|
||||
cancellation
|
||||
? Throws.InstanceOf<OperationCanceledException>()
|
||||
: Throws.TypeOf<InvalidOperationException>().And.SameAs(failure))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Assert.That(manager.Find(childId), Is.Null);
|
||||
Assert.That(version.Value, Is.Not.EqualTo(before));
|
||||
ArrayOf<ModelChangeStructureDataType> changes = GetReportedDeletionChanges();
|
||||
Assert.That(changes.Count, Is.EqualTo(1));
|
||||
Assert.That(changes[0].Affected, Is.EqualTo(parentId));
|
||||
Assert.That(changes[0].Verb, Is.EqualTo((byte)Opc.Ua.Server.NodeManager.ModelChangeVerbs.ReferenceDeleted));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A partial deletion reports removed nodes, not descendants that remain registered.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task DeleteNodeReportsOnlyCommittedSubtreeRemovalsAsync()
|
||||
{
|
||||
using ITestNodeManager manager = CreateManager();
|
||||
var root = new BaseObjectState(null);
|
||||
manager.EnableModelChangeTrackingFor(root);
|
||||
NodeId rootId = await manager.CreateNodeAsync(
|
||||
manager.SystemContext, default, ReferenceTypeIds.Organizes,
|
||||
new QualifiedName("RemovedRoot", manager.NamespaceIndexes[0]), root).ConfigureAwait(false);
|
||||
var child = new BaseObjectState(null);
|
||||
manager.EnableModelChangeTrackingFor(child);
|
||||
NodeId childId = await manager.CreateNodeAsync(
|
||||
manager.SystemContext, rootId, ReferenceTypeIds.HasComponent,
|
||||
new QualifiedName("SurvivingChild", manager.NamespaceIndexes[0]), child).ConfigureAwait(false);
|
||||
var failure = new InvalidOperationException("Injected root removal failure.");
|
||||
SetDeletionFailureCallback(manager, root, failure);
|
||||
m_mockServer.Invocations.Clear();
|
||||
|
||||
await Assert.ThatAsync(
|
||||
async () => await manager.DeleteNodeAsync(manager.SystemContext, rootId).ConfigureAwait(false),
|
||||
Throws.TypeOf<InvalidOperationException>().And.SameAs(failure)).ConfigureAwait(false);
|
||||
|
||||
Assert.That(manager.Find(rootId), Is.Null);
|
||||
Assert.That(manager.Find(childId), Is.SameAs(child));
|
||||
ArrayOf<ModelChangeStructureDataType> changes = GetReportedDeletionChanges();
|
||||
Assert.That(changes.Count, Is.EqualTo(1));
|
||||
Assert.That(changes[0].Affected, Is.EqualTo(rootId));
|
||||
Assert.That(changes[0].Verb, Is.EqualTo((byte)Opc.Ua.Server.NodeManager.ModelChangeVerbs.NodeDeleted));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Versioned descendants remain eligible when their deleted root has no NodeVersion.
|
||||
/// </summary>
|
||||
[TestCase(false)]
|
||||
[TestCase(true)]
|
||||
public async Task DeleteUnversionedRootReportsVersionedDescendantAsync(bool service)
|
||||
{
|
||||
using ITestNodeManager manager = CreateManager();
|
||||
Assume.That(!service || manager is TestableAsyncCustomNodeManager, "Requires node-management services.");
|
||||
var root = new BaseObjectState(null);
|
||||
NodeId rootId = await manager.CreateNodeAsync(
|
||||
manager.SystemContext, default, ReferenceTypeIds.Organizes,
|
||||
new QualifiedName("UnversionedRoot", manager.NamespaceIndexes[0]), root).ConfigureAwait(false);
|
||||
var child = new BaseObjectState(null);
|
||||
manager.EnableModelChangeTrackingFor(child);
|
||||
NodeId childId = await manager.CreateNodeAsync(
|
||||
manager.SystemContext, rootId, ReferenceTypeIds.HasComponent,
|
||||
new QualifiedName("VersionedChild", manager.NamespaceIndexes[0]), child).ConfigureAwait(false);
|
||||
m_mockServer.Invocations.Clear();
|
||||
|
||||
await DeleteTestNodeAsync(manager, rootId, service).ConfigureAwait(false);
|
||||
|
||||
ArrayOf<ModelChangeStructureDataType> changes = GetReportedDeletionChanges();
|
||||
Assert.That(changes.Count, Is.EqualTo(1));
|
||||
Assert.That(changes[0].Affected, Is.EqualTo(childId));
|
||||
Assert.That(changes[0].Verb, Is.EqualTo((byte)Opc.Ua.Server.NodeManager.ModelChangeVerbs.NodeDeleted));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancellation before removal does not announce an uncommitted deletion.
|
||||
/// </summary>
|
||||
[TestCase(false)]
|
||||
[TestCase(true)]
|
||||
public async Task CancelledNodeDeletionDoesNotReportUncommittedChangesAsync(bool service)
|
||||
{
|
||||
using ITestNodeManager manager = CreateManager();
|
||||
Assume.That(manager is TestableAsyncCustomNodeManager, "Requires cancellable deletion.");
|
||||
var node = new BaseObjectState(null);
|
||||
PropertyState<string> version = manager.EnableModelChangeTrackingFor(node);
|
||||
NodeId id = await manager.CreateNodeAsync(
|
||||
manager.SystemContext, default, ReferenceTypeIds.Organizes,
|
||||
new QualifiedName("RetainedNode", manager.NamespaceIndexes[0]), node).ConfigureAwait(false);
|
||||
string before = version.Value;
|
||||
m_mockServer.Invocations.Clear();
|
||||
|
||||
await Assert.ThatAsync(
|
||||
async () => await DeleteTestNodeAsync(
|
||||
manager, id, service, new CancellationToken(true)).ConfigureAwait(false),
|
||||
Throws.InstanceOf<OperationCanceledException>()).ConfigureAwait(false);
|
||||
|
||||
Assert.That(manager.Find(id), Is.SameAs(node));
|
||||
Assert.That(version.Value, Is.EqualTo(before));
|
||||
Assert.That(GetReportedDeletionChanges().Count, Is.Zero);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Neither the deletion failure nor a concurrent reporting failure is lost.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public async Task DeleteNodePreservesDeletionAndReportingFailuresAsync()
|
||||
{
|
||||
using ITestNodeManager manager = CreateManager();
|
||||
var node = new BaseObjectState(null);
|
||||
manager.EnableModelChangeTrackingFor(node);
|
||||
NodeId id = await manager.CreateNodeAsync(
|
||||
manager.SystemContext, default, ReferenceTypeIds.Organizes,
|
||||
new QualifiedName("FailingNode", manager.NamespaceIndexes[0]), node).ConfigureAwait(false);
|
||||
var deletionFailure = new InvalidOperationException("Injected deletion failure.");
|
||||
var reportingFailure = new InvalidOperationException("Injected reporting failure.");
|
||||
SetDeletionFailureCallback(manager, node, deletionFailure);
|
||||
m_mockServer.Setup(value => value.ReportEvent(It.IsAny<IFilterTarget>())).Throws(reportingFailure);
|
||||
|
||||
await Assert.ThatAsync(
|
||||
async () => await manager.DeleteNodeAsync(manager.SystemContext, id).ConfigureAwait(false),
|
||||
Throws.TypeOf<AggregateException>()
|
||||
.With.Property(nameof(AggregateException.InnerExceptions))
|
||||
.EquivalentTo(new[] { deletionFailure, reportingFailure })).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletion preserves the node's pre-removal model-change eligibility.
|
||||
/// </summary>
|
||||
@@ -10330,6 +10816,64 @@ namespace Opc.Ua.Server.Tests
|
||||
/// Creates the selected manager implementation, optionally enabling asynchronous legacy method dispatch.
|
||||
/// </summary>
|
||||
/// <param name="asynchronousLegacyCall">Whether the legacy manager opts into asynchronous method calls.</param>
|
||||
private ArrayOf<ModelChangeStructureDataType> GetReportedDeletionChanges()
|
||||
{
|
||||
var changes = new List<ModelChangeStructureDataType>();
|
||||
foreach (GeneralModelChangeEventState change in m_mockServer.Invocations
|
||||
.Where(invocation => invocation.Method.Name == nameof(IServerInternal.ReportEvent))
|
||||
.SelectMany(invocation => invocation.Arguments.OfType<GeneralModelChangeEventState>()))
|
||||
{
|
||||
foreach (ModelChangeStructureDataType entry in change.Changes!.Value)
|
||||
{
|
||||
changes.Add(entry);
|
||||
}
|
||||
}
|
||||
return [.. changes];
|
||||
}
|
||||
|
||||
private static void SetDeletionFailureCallback(
|
||||
ITestNodeManager manager, NodeState node, Exception failure)
|
||||
{
|
||||
void OnRemoved(NodeState removed)
|
||||
{
|
||||
if (ReferenceEquals(removed, node))
|
||||
{
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
if (manager is TestableAsyncCustomNodeManager asynchronous)
|
||||
{
|
||||
asynchronous.NodeRemovedCallback = (removed, _) =>
|
||||
{
|
||||
OnRemoved(removed);
|
||||
return default;
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
((TestableCustomNodeManager2)manager.SyncNodeManager).NodeRemovedCallback = OnRemoved;
|
||||
}
|
||||
}
|
||||
|
||||
private static async ValueTask DeleteTestNodeAsync(
|
||||
ITestNodeManager manager, NodeId nodeId, bool service, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (service)
|
||||
{
|
||||
using var context = new OperationContext(
|
||||
new RequestHeader(), null!, RequestType.DeleteNodes, RequestLifetime.None, Mock.Of<ISession>());
|
||||
ServiceResult result = await manager.DeleteNodeAsync(
|
||||
context, new DeleteNodesItem { NodeId = nodeId, DeleteTargetReferences = true }, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
Assert.That(ServiceResult.IsGood(result), Is.True);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.That(await manager.DeleteNodeAsync(
|
||||
manager.SystemContext, nodeId, cancellationToken).ConfigureAwait(false), Is.True);
|
||||
}
|
||||
}
|
||||
|
||||
private ITestNodeManager CreateManager(bool asynchronousLegacyCall = false)
|
||||
{
|
||||
if (m_managerType == AsyncCustomNodeManagerType.CustomNodeManager2ViaAdapter)
|
||||
|
||||
Reference in New Issue
Block a user