Files
UA-.NETStandard/Docs/NodeManagement.md
Marc Schier 81b3c9b371 [Server] Implement NodeManagement service set and fix DeleteAddressSpace (#3839)
# Description

Adds the OPC UA NodeManagement service set (AddNodes / DeleteNodes /
AddReferences / DeleteReferences) to the server SDK and fixes a
long-standing shutdown bug where `NodeState.Delete` callbacks never
fired on address-space teardown.

### NodeManagement service set (#3736)

- New optional `INodeManagementAsyncNodeManager` facet interface (added
to `INodeManager.cs` alongside other facets, discovered via `as`,
**not** part of the `IAsyncNodeManager` composite). Opt-in per
NodeManager via `AllowNodeManagement` — default is `false` so existing
NodeManagers preserve binary compatibility.
- Dispatcher in `MasterNodeManager` routes:
- **AddNodes** by `RequestedNewNodeId.NamespaceIndex` when supplied,
otherwise to the parent's owning NodeManager. When the parent's owner is
read-only (e.g. `ObjectsFolder` owned by `CoreNodeManager`), the
dispatcher falls back to the first opted-in NodeManager so conformance
flows work out of the box.
- **DeleteNodes** / **AddReferences** / **DeleteReferences** to the
owning NodeManager of the target or source node, returning
`BadUserAccessDenied` when that NodeManager has not opted in.
Cross-NodeManager `AddReference` / `DeleteReference` best-effort mirrors
the inverse edge on the target's NodeManager.
- `AsyncCustomNodeManager` provides a default implementation: per-item
validation, `RequestedNewNodeId` honored, browse-name dedup for both
local and cross-NodeManager parents, `DeleteTargetReferences` semantics,
and a NodeId allocator fallback when an `INodeIdFactory.New` override
returns a null `NodeId` (e.g. parent-based hierarchical naming with no
parent attached).
- `StandardServer` adds the four service overrides plumbed through
`OperationLimits.MaxNodesPerNodeManagement`, audit events, and
`OnRequestComplete`; new `ReportAuditAddReferencesEvent` /
`ReportAuditDeleteReferencesEvent` helpers in `AuditEvents`.
- Quickstarts `ReferenceNodeManager` opts in via `AllowNodeManagement =>
true`; the legacy partial-class override in
`ReferenceServer.NodeManagement.cs` is removed.

### `NodeState.Delete` on shutdown (#3762)

- `CustomNodeManager.DeleteAddressSpace` and
`AsyncCustomNodeManager.DeleteAddressSpaceAsync` now snapshot root
`PredefinedNodes` (skipping children whose parent is in the snapshot to
prevent double-fire), invoke `NodeState.Delete(SystemContext)` on each
root (which recurses into children and raises `OnBeforeDelete` /
`OnAfterDelete` / `NodeStateChangeMasks.Deleted`), and then clear the
map.
- Derived overrides in Quickstarts `ReferenceNodeManager`,
`SampleNodeManager`, and `FileSystemNodeManager` chain to `base` so the
callback fires for their nodes too.

### Tests

- **21 new dispatcher unit tests** in
`Tests/Opc.Ua.Server.Tests/NodeManager/AsyncCustomNodeManagerNodeManagementTests.cs`
covering `AllowNodeManagement` default/opt-in, AddNode validation
(`BadBrowseNameInvalid`, `BadParentNodeIdInvalid`, `BadNodeIdRejected`,
`BadNodeIdExists`, `BadBrowseNameDuplicated`, `BadNodeClassInvalid`),
RequestedNewNodeId paths, DeleteNode + `DeleteTargetReferences`,
AddReference duplicate detection (`BadDuplicateReferenceNotAllowed`),
and DeleteReference (`BadNoMatch`).
- **2 new DeleteAddressSpaceAsync tests** in
`AsyncCustomNodeManagerTests` verifying `NodeState.Delete` invocation
and no double-fire on children.
- Updated InformationModel
`NodeManagementAddRefTests.AddReferenceBetweenExistingNodesSucceedsAsync`
to accept `BadUserAccessDenied` / `BadNotSupported` when the source NM
has opted out.
- **Full `Opc.Ua.Server.Tests` on net10.0: 1709 passed, 0 failed.**
- **All 26 InformationModel `Category=NodeManagement` conformance tests
pass** against the reference server (1 skipped where source NM opts
out).

### Docs

- New [`Docs/NodeManagement.md`](Docs/NodeManagement.md) with
architecture, opt-in pattern, dispatch and routing rules,
request-handling table, error-code table, audit events, and
custom-behavior examples.
- `Docs/MigrationGuide.md` gets two new sections — *NodeManagement
service set* (downstream migration steps) and *DeleteAddressSpace
invokes NodeState.Delete (#3762)* (shutdown behavior change) — with TOC
entries.
- `Docs/README.md` links the new `NodeManagement.md`.

## Related Issues

- Fixes #3736
- Fixes #3762

## Checklist

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

- [ ] 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.
2026-06-04 13:02:32 +02:00

192 lines
9.2 KiB
Markdown

# NodeManagement service set
The OPC UA NodeManagement service set (OPC 10000-4 §5.8) gives clients
the ability to manipulate the server's address space at runtime:
| Service | Description |
| --- | --- |
| `AddNodes` | Create a new node beneath an existing parent (Object, Variable, ...). |
| `DeleteNodes` | Remove a node and, optionally, all references that target it. |
| `AddReferences` | Add a reference between two existing nodes. |
| `DeleteReferences` | Remove a reference between two existing nodes. |
The SDK ships full request validation, per-item dispatch, audit-event
emission, and per-NodeManager opt-in. NodeManagers do not need to
override the four `StandardServer` service methods themselves.
## Architecture
1. The client request lands on `StandardServer.AddNodesAsync` /
`DeleteNodesAsync` / `AddReferencesAsync` / `DeleteReferencesAsync`.
2. Each service override validates the request and forwards the
per-item collection to the matching
`IMasterNodeManager.AddNodesAsync` etc. dispatcher.
3. `MasterNodeManager` looks up the owning NodeManager for every item
(parent's owner for `AddNodes`, target node's owner for the rest)
and asks it to perform the work via the optional
`INodeManagementAsyncNodeManager` interface.
4. The aggregated per-item results are wrapped in the matching response
envelope and the audit event (`AuditAddNodesEvent`,
`AuditDeleteNodesEvent`, `AuditAddReferencesEvent`,
`AuditDeleteReferencesEvent`) is emitted with an aggregated status.
NodeManagers that do not implement `INodeManagementAsyncNodeManager` —
or have not opted in — return `BadUserAccessDenied`, which is the
status defined for "server does not allow this operation". This keeps
the default behavior of every existing NodeManager unchanged.
## Opting in: `INodeManagementAsyncNodeManager`
```csharp
public interface INodeManagementAsyncNodeManager
{
// Sub-class returns true to opt in. Default is false.
bool AllowNodeManagement { get; }
ValueTask<(ServiceResult result, NodeId addedNodeId)> AddNodeAsync(
OperationContext context,
AddNodesItem item,
CancellationToken cancellationToken = default);
ValueTask<ServiceResult> DeleteNodeAsync(
OperationContext context,
DeleteNodesItem item,
CancellationToken cancellationToken = default);
ValueTask<ServiceResult> AddReferenceAsync(
OperationContext context,
AddReferencesItem item,
CancellationToken cancellationToken = default);
ValueTask<ServiceResult> DeleteReferenceAsync(
OperationContext context,
DeleteReferencesItem item,
CancellationToken cancellationToken = default);
}
```
`AsyncCustomNodeManager` already implements
`INodeManagementAsyncNodeManager` with default behavior that handles
every common case. Sub-classes opt in by overriding a single property:
```csharp
public sealed class MyNodeManager : AsyncCustomNodeManager
{
public MyNodeManager(IServerInternal server, ApplicationConfiguration config, ILogger logger)
: base(server, config, logger, "http://example.org/MyNodes/")
{
}
public override bool AllowNodeManagement => true;
}
```
That is the only change needed for a NodeManager to start accepting
AddNodes / DeleteNodes / AddReferences / DeleteReferences requests.
The reference server's `ReferenceNodeManager` opts in this way; you can
use it as the working example.
## Dispatch and routing rules
- **AddNodes**: routed by the namespace of `RequestedNewNodeId` when
the caller supplies one. When `RequestedNewNodeId` is null, routed
to the parent's owning NodeManager. As a pragmatic fallback for the
common case where the parent is `ObjectsFolder` (owned by the
read-only `CoreNodeManager`), the dispatcher routes to the first
NodeManager that has opted in to NodeManagement.
- **DeleteNodes**: routed to the owning NodeManager of the target node.
- **AddReferences / DeleteReferences**: routed to the source node's
owning NodeManager. When the target node lives in a different
NodeManager that has also opted in, the dispatcher mirrors the
complementary (inverse) edge on the target's owning NodeManager —
best-effort; a failure on the target side is logged at Warning level
and does not roll the source side back.
## Request handling on `AsyncCustomNodeManager`
The default implementation honors the following request fields:
| Field | Behavior |
| --- | --- |
| `RequestedNewNodeId` | Used as the new node's `NodeId` when valid; otherwise `BadNodeIdRejected`. When null, a fresh `NodeId` is allocated via `INodeIdFactory.New`; if the override returns a null `NodeId` (for example, because it derives identifiers from a parent that is not yet attached) the SDK falls back to the base allocator so AddNodes always yields a usable identifier. `BadNodeIdExists` if it collides with an existing node. |
| `BrowseName` | Must be non-null. `BadBrowseNameDuplicated` when a sibling under the same parent already uses the same browse name — both local children and previously-added cross-NodeManager children attached to the same parent by this NodeManager are checked. |
| `ParentNodeId` | Used to attach the new child to the parent. Cross-NodeManager parents are supported — the forward `parent → child` edge is added via the master so the parent's NodeManager records it, and the inverse `child → parent` edge is added on the new node. |
| `ReferenceTypeId` | Must be a `HierarchicalReferences` subtype. |
| `NodeAttributes` | Optional `VariableAttributes` / `ObjectAttributes` are applied to the new node (`DisplayName`, `Description`, `DataType`, `ValueRank`, `AccessLevel`, `UserAccessLevel`, `Historizing`, `MinimumSamplingInterval`, `Value`, `EventNotifier`). |
| `DeleteTargetReferences` | When true, `DeleteNodes` also removes references on other NodeManagers that target the deleted node. |
| `DeleteBidirectional` | When true on `DeleteReferences`, the inverse edge on the target's owning NodeManager is also deleted. |
## Error codes returned
| Service | Status | Reason |
| --- | --- | --- |
| AddNodes | `BadBrowseNameInvalid` | `BrowseName` is null. |
| AddNodes | `BadParentNodeIdInvalid` | `ParentNodeId` is null or unknown. |
| AddNodes | `BadReferenceTypeIdInvalid` | `ReferenceTypeId` is null or unknown. |
| AddNodes | `BadReferenceNotAllowed` | `ReferenceTypeId` is not a hierarchical reference. |
| AddNodes | `BadNodeIdRejected` | `RequestedNewNodeId` is outside this NodeManager's namespace. |
| AddNodes | `BadNodeIdExists` | `RequestedNewNodeId` already exists. |
| AddNodes | `BadBrowseNameDuplicated` | A sibling beneath the same local parent already uses the browse name. |
| AddNodes | `BadNodeClassInvalid` | Only `Object` and `Variable` are supported by the default implementation. |
| AddNodes | `BadNodeAttributesInvalid` | The supplied attributes extension object does not match the node class. |
| AddNodes / DeleteNodes / AddReferences / DeleteReferences | `BadUserAccessDenied` | The owning NodeManager has not opted in to NodeManagement. |
| DeleteNodes | `BadNodeIdInvalid` / `BadNodeIdUnknown` | The node to delete is null or unknown. |
| AddReferences | `BadSourceNodeIdInvalid` | Source is null or unknown to this NodeManager. |
| AddReferences | `BadTargetNodeIdInvalid` | Target is null. |
| AddReferences | `BadDuplicateReferenceNotAllowed` | The exact reference (type + direction + target) already exists on the source. |
| DeleteReferences | `BadNoMatch` | No reference with the exact triple (type + direction + target) was found on the source. |
## Audit events
Every NodeManagement service emits its corresponding audit event:
- `AuditAddNodesEventType`
- `AuditDeleteNodesEventType`
- `AuditAddReferencesEventType`
- `AuditDeleteReferencesEventType`
The audit `Status` is the first bad per-item status, or `Good` when
every item succeeded. Audit events are emitted from both the success
and failure paths so an early `ServiceResultException` (request-level
validation) still appears in the audit log.
## Client-side example
The standard `Session` / `ManagedSession` AddNodes / DeleteNodes /
AddReferences / DeleteReferences APIs work unchanged against any
server whose NodeManager has opted in. See OPC 10000-4 §5.8 for the
service contract.
## Custom behavior
To customize behavior for a particular NodeManager (e.g. enforce a
custom NodeId scheme, persist the new node, or veto specific node
classes), override the matching method on
`INodeManagementAsyncNodeManager` after opting in. For example, to
restrict additions to `BaseObjectState`:
```csharp
public override async ValueTask<(ServiceResult result, NodeId addedNodeId)> AddNodeAsync(
OperationContext context,
AddNodesItem item,
CancellationToken cancellationToken = default)
{
if (item.NodeClass != NodeClass.Object)
{
return (new ServiceResult(StatusCodes.BadNodeClassInvalid), NodeId.Null);
}
return await base.AddNodeAsync(context, item, cancellationToken).ConfigureAwait(false);
}
```
## See also
- [Reference Server](../Applications/README.md) — opt-in example in
`ReferenceNodeManager`.
- [Source generated NodeManagers](SourceGeneratedNodeManagers.md) —
combine NodeManagement with generated models.
- [Model Change Tracking](ModelChangeTracking.md) — `AddNodes` and
`DeleteNodes` emit `GeneralModelChangeEvent` automatically when
model change tracking is enabled.