Files
UA-.NETStandard/docs/AsyncServerSupport.md
romanett 556c9cb797 Add an async iteration seam to NodeBrowser (#4450)
# Description

`NodeBrowser` was synchronous end to end, so a node manager whose browse
result depends on I/O had nowhere to `await`. The aggregation server
sample is the concrete case: its browser has to browse the aggregated
server, and the only way to write it was `Next() =>
NextAsync().GetAwaiter().GetResult()`, which holds a request worker for
the full remote round trip on every browse.

This is option (1) from the issue, the small non-breaking step:

- `INodeBrowser` and `NodeBrowser` gain `ValueTask<IReference?>
NextAsync(CancellationToken cancellationToken = default)`. The default
completes synchronously with the result of `Next()`, so every existing
browser that only overrides `Next()` keeps working unchanged (the
samples' `MemoryBufferBrowser` and the migration-analyzer test snippets
compile untouched).
- `AsyncCustomNodeManager.BrowseAsync` and `TranslateBrowsePathAsync`
iterate browsers through `NextAsync` with the request's cancellation
token. The loop bodies already awaited; only the iteration could not.
- `DirectoryBrowser` was the one browser the stack ships that blocked on
async I/O. It now awaits the file-system provider enumeration in
`NextAsync` and keeps `Next()` as the bridge for the remaining
synchronous consumers (`UANodeSetHelpers` export, `CustomNodeManager2`).
Cancellation now propagates instead of being reported as an empty
directory.
- The synchronous paths (`CustomNodeManager2`, `CoreNodeManager`
diagnostics, nodeset export) are unchanged on purpose; they have no
place to await.

**Not done, by design.** Browser creation stays synchronous.
`OnCreateBrowser` runs under the node's browse lock, so the documented
contract is that a browser fetches lazily on its first `NextAsync`. The
issue itself notes the async creation seam is not needed for the
aggregation case. `IAsyncEnumerable` (option 3) is not attempted.

**Compatibility note.** A type that implements `INodeBrowser` directly
without deriving from `NodeBrowser` has to add the member. Nothing in
this repo or the samples does that; derived browsers are unaffected.

**Docs** updated in `NodeManagers.md`, `AsyncServerSupport.md`,
`MigrationGuide.md` and `migrate/2.0.x/node-states.md`, with a was/now
example for a browser that previously bridged sync-over-async.

**Tests**
- `NodeBrowserTests` (new): default wrapper completes synchronously and
yields the same sequence as `Next()`, push-back through `NextAsync`, a
`Next`-only override observed through `NextAsync`, an awaited
`NextAsync` override with cancellation.
- `AsyncCustomNodeManagerTests`: a browser whose `Next()` throws is
driven entirely through `NextAsync` by browse, by the continuation-point
round trip (push-back on the async path), and by translate-path.
- `DirectoryObjectStateTests`: the async path of the directory browser,
including the throwing provider and cancellation.

Verified locally: `Opc.Ua.Server` builds on net10.0 and net48 with 0
errors; the browser-related server suites (1342 tests) and the new unit
tests pass on net10.0; `dotnet format whitespace --verify-no-changes` is
clean on the changed files.

## Related Issues

- Fixes #4415

## Checklist

- [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.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 19:56:53 +02:00

13 KiB

Support of the TAP (Task Asynchronous Pattern) for server operations

The OPC UA .NET Standard stack has supported asynchronous operations for a long time. The asynchronous operations are based on the IAsyncResult pattern, which is also called the APM (Asynchronous Programming Model) or the Begin/End pattern. This pattern was introduced with .NET Framework 1.0 and is still supported in .NET 8.0.

In addition to the APM, the TAP (Task Asynchronous Pattern) is now also supported for server operations. The TAP is based on the Task and async/await keywords introduced in .NET Framework 4.0 and is the recommended way to implement asynchronous operations in modern .NET applications.

In the future APM support will be deprecated, as it was never implemented for NodeManagers.

Starting with 1.5.378 the server library allows users to also implement Task based NodeManagers. Implementing the TAP allows to improve the scalability of the server, as the TAP is significantly more efficient in terms of resource usage and performance.

In order to support the TAP pattern, the following changes have been made to the server library:

  • Introduce a Task based RequestQueue
  • Introduce a Task based TransportListenerCallback
  • Update the generated Code to support Task based operations
  • Update of the MasterNodeManager to support Task based operations
  • Introduce a Task based IAsyncNodeManager interface
  • Introduce AsyncNodeManagerAdapter and SyncNodeManagerAdapter classes to support sync and async node managers side by side.

Upgrading an existing server

  • Update INodeManager.CreateMonitoredItems to support the new MonitoredItemIdFactory.
  • In a future release an CustomNodeManagerAsync class will be provided to simplify the creation of fully async NodeManagers.
  • The existing CustomNodeManager2 class can be used as is, and async operations can be implemented as needed using the different interfaces provided by the server library:
    • IAsyncNodeManager for full async support
    • ICallAsyncNodeManager for async method calls
    • IReadAsyncNodeManager for async reading
    • IWriteAsyncNodeManager for async writing
    • IHistoryReadAsyncNodeManager for async history read
    • IHistoryUpdateAsyncNodeManager for async history update
    • IConditionRefreshAsyncNodeManager for async condition refresh
    • ITranslateBrowsePathAsyncNodeManager for async translate browse path
    • IBrowseAsyncNodeManager for async browsing
    • ISetMonitoringModeAsyncNodeManager for async monitoring mode changes
    • ITransferMonitoredItemsAsyncNodeManager for async monitored item transfer
    • IDeleteMonitoredItemsAsyncNodeManager for async monitored item deletion
    • IModifyMonitoredItemsAsyncNodeManager for async monitored item modification
    • ICreateMonitoredItemsAsyncNodeManager for async monitored item creation

--> The MasterNodeManager automatically detects if a NodeManager implements any of the async interfaces and uses the async implementation if available. If no async interface is implemented, the sync implementation is used.

  • The Server already allows to register fully async NodeManagers, which implement the IAsyncNodeManager interface. To register a fully async Nodemanager use StandardServer.RegisterNodeManager(IAsyncNodeManagerFactory). For compatibility reasons the IAsyncNodeManager has a property SyncNodeManager, this needs to be implemented by passing your IAsyncNodeManager to the SyncNodeManagerAdapter.

Async Method call

Support for async method callbacks is already implemented by CustomNodeManager2 to enable the support just add IAsyncNodeManager to your NodeManager implementation. All generated code already has support for Async Methods e.g. UpdateCertificateMethodState.OnCallAsync. If the NodeManager implements IAsyncNodeManager the async callback is used automatically. If a generic Method handler shall be used the MethodState.OnCallMethod2Async handler shall be used.

AsyncCustomNodeManager

AsyncCustomNodeManager is the recommended base class for building fully async, TAP-native node managers. Unlike the older CustomNodeManager2, it implements IAsyncNodeManager directly rather than INodeManager3. This has two practical consequences:

  • All virtual methods are async ValueTask-returning from the start, so there is no boilerplate wrapping of synchronous code inside Task.Run or similar helpers.
  • The SyncNodeManager property (required by IAsyncNodeManager) is satisfied automatically: the constructor calls this.ToSyncNodeManager() and stores the resulting INodeManager3 adapter. Callers that still require an INodeManager3 reference (e.g. legacy subscription code) use that adapter; the node manager itself never needs to implement the synchronous interface.

Registering an AsyncCustomNodeManager

Use the IAsyncNodeManagerFactory overload of StandardServer.RegisterNodeManager:

server.RegisterNodeManager(context =>
    new MyAsyncNodeManager(server, configuration));

Async browse iteration

Every hook a custom node manager overrides is awaitable, and that includes browsing. AsyncCustomNodeManager.BrowseAsync and TranslateBrowsePathAsync iterate an INodeBrowser through NextAsync(CancellationToken) rather than Next(), so a browser that has to reach an underlying system — the aggregation case, where the references come from another server — awaits that fetch on the first NextAsync instead of holding a request worker on a blocking call. The default NextAsync wraps Next(), so the browsers the stack ships and every existing custom browser keep working untouched. See NodeManagers.md for the browser contract.

Locking strategy vs CustomNodeManager2

CustomNodeManager2 protects its entire address space with a single coarse-grained monitor lock stored in the Lock property:

lock (Lock)
{
    // all reads and writes go through this single lock
}

While simple, this serialises all concurrent requests for the whole node manager and blocks the calling thread, which prevents the use of await inside the critical section.

AsyncCustomNodeManager replaces this with a two-tier, await-compatible locking model:

1. Global write semaphore — m_writeSemaphore

A SemaphoreSlim(1, 1) that serialises all write operations across the node manager. Because it is a SemaphoreSlim it can be acquired with await:

await m_writeSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
    // safe to write any node
}
finally
{
    m_writeSemaphore.Release();
}

Only one write request runs at a time, preventing concurrent modifications of the address space. Read operations do not acquire this semaphore.

2. Monitored-item semaphore — m_monitoredItemSemaphore

A second SemaphoreSlim(1, 1) that serialises all monitored-item management operations (create, modify, delete, set-monitoring-mode, subscribe-to-events, condition-refresh, transfer). This keeps subscription state consistent without blocking reads or writes:

await m_monitoredItemSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
    // create / delete / modify monitored items
}
finally
{
    m_monitoredItemSemaphore.Release();
}

3. Per-node locking for read and attribute access

Reads do not acquire any manager-wide lock. Instead they lock only the individual NodeState object being accessed. This allows many reads to run truly in parallel across different nodes:

lock (handle.Node)
{
    errors[ii] = handle.Node.ReadAttribute(
        systemContext,
        nodeToRead.AttributeId,
        nodeToRead.ParsedIndexRange,
        nodeToRead.DataEncoding,
        value);
}

The same per-node lock is used for the old-value read inside WriteAsync and for FindChildBySymbolicName lookups in the component cache.

Concern CustomNodeManager2 AsyncCustomNodeManager
Address-space reads Global lock (Lock) Per-node lock (node) (parallel)
Address-space writes Global lock (Lock) await m_writeSemaphore (serial)
Monitored-item management Global lock (Lock) await m_monitoredItemSemaphore
await inside critical section Not possible Supported everywhere
Implemented interface INodeManager3 IAsyncNodeManager

Monitored-item manager selection

The constructor accepts an optional useSamplingGroups flag:

// Default: change-triggered (MonitoredNodeMonitoredItemManager)
public MyNodeManager(IServerInternal server, ApplicationConfiguration config)
    : base(server, config) { }

// Opt-in: timer-based sampling (SamplingGroupMonitoredItemManager)
public MyNodeManager(IServerInternal server, ApplicationConfiguration config)
    : base(server, config, useSamplingGroups: true) { }
  • MonitoredNodeMonitoredItemManager (default): node value changes are propagated to subscribers immediately by awaiting NodeState.ClearChangeMasksAsync after every successful write (see Fully asynchronous change notifications). No background threads are created per subscription.
  • SamplingGroupMonitoredItemManager: a background timer thread samples the current node value at the negotiated SamplingInterval. Write changes are not pushed immediately; instead the next scheduled sample detects and delivers them. Choose this mode when the data source produces values independently of OPC UA write requests (e.g. hardware polling).

Fully asynchronous change notifications

AsyncCustomNodeManager propagates value changes and events to monitored items over a fully asynchronous push path, so no thread is blocked when a node exposes an asynchronous value read handler or when the per-node notification channel applies back-pressure.

NodeState exposes asynchronous counterparts of its change-notification API alongside the existing synchronous members:

Synchronous Asynchronous
ClearChangeMasks(context, includeChildren) ClearChangeMasksAsync(context, includeChildren, ct)
OnStateChanged / StateChanged OnStateChangedAsync / StateChangedAsync
ReportEvent(context, e) ReportEventAsync(context, e, ct)
OnReportEvent OnReportEventAsync

The additions are non-breaking: existing synchronous callers and sinks continue to work unchanged. MonitoredNodeMonitoredItemManager wires the asynchronous sinks, so after a successful write AsyncCustomNodeManager.WriteAsync flushes changes with await handle.Node.ClearChangeMasksAsync(...). The value for each monitored attribute is read at that moment through NodeState.ReadAttributeAsync — honoring an asynchronous OnReadValueAsync / OnSimpleReadValueAsync handler when one is registered — and the resulting snapshot is enqueued by awaiting the bounded per-node channel. Because the read is materialized before the change is enqueued, every notification carries the node value at the instant of the change (an enqueue-time snapshot); because both the read and the enqueue are awaited, the producing thread is never blocked.

The synchronous ClearChangeMasks / ReportEvent still drive the asynchronous sinks: they complete inline when the node is synchronously readable and the channel has capacity, and block the calling thread only when a genuinely asynchronous read is in flight or the channel is full. A synchronous node manager therefore keeps working without any change, paying a blocking cost only in those two cases.

Overflow and discard — the FIFO/LIFO DiscardOldest behavior and the Overflow status bit — remain a property of each monitored item's own queue. The shared per-node channel is a lossless conduit and does not drop notifications, so a monitored item's configured queue policy is always applied to the full stream of changes.

Creating a custom node manager

Derive from AsyncCustomNodeManager and override only the virtual methods you need:

public class MyNodeManager : AsyncCustomNodeManager
{
    public MyNodeManager(IServerInternal server, ApplicationConfiguration config)
        : base(server, config, "http://my.org/UA/Data/")
    {
    }

    public override async ValueTask CreateAddressSpaceAsync(
        IDictionary<NodeId, IList<IReference>> externalReferences,
        CancellationToken cancellationToken = default)
    {
        await base.CreateAddressSpaceAsync(externalReferences, cancellationToken)
                  .ConfigureAwait(false);

        // build your nodes here
        var myVar = new BaseDataVariableState(null);
        myVar.NodeId  = new NodeId("MyVar", NamespaceIndex);
        myVar.Value   = 42;
        await AddNodeAsync(SystemContext, default, myVar, cancellationToken)
              .ConfigureAwait(false);
    }
}