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:
Cristi Pogacean
2026-10-14 14:03:23 +02:00
committed by GitHub
parent 5b03e9afa4
commit af601ebca8
7 changed files with 854 additions and 70 deletions
@@ -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)