## Summary
### Documentation
- Stage the documentation index as a learning path: a first application,
independent client, server, and PubSub paths, then data and services,
companion models, production, advanced integration, and draft or
experimental capabilities.
- Add Getting started (repository samples or NuGet packages), OPC UA
concepts, Your first information model, and a production readiness
checklist.
- Add Native AOT application publishing, a first PubSub publisher and
subscriber exchange, and the UDP transport options.
- Add or expand contents navigation throughout `docs/`, and keep
contributor documentation separate.
- Correct outdated SDK examples and capability guidance, consolidate
repeated explanations into canonical guides, and repair local links and
heading structure.
- Rewrite What's New in 2.0 as a concise feature overview with short
linked details; keep migration instructions in the Migration Guide.
- Expand the feature catalogue with specification IDs, GDS, LDS, the OPC
10000-21 onboarding registrar, ISA-95, xRegistry, OpenUSD, and
sample-local models; distinguish draft status, explain crypto
pluggability, and bring performance guidance forward.
- Address review feedback on wording, active monitored items during
shadow reload, and the `Samples.md` filename and references.
- Regenerate the `opcua-v20-migration` plugin's bundled copy of the
migration guides so it matches the edited `docs/migrate/2.0.x` guides.
- Merge the latest `master`, preserving upstream resource-isolation
guidance within the task-based documentation index.
- Retain deferred Lingity findings where appropriate; this PR does not
claim that every document meets the Lingity score thresholds.
### Fixes found while verifying the guides
Each fix has regression tests that fail without it.
- **Client:** V2 subscriptions compared their lifetime with the
operation timeout instead of the session timeout, and logged a spurious
warning. `ISubscriptionEngineContext`, which is new in 2.0, gains a
`SessionTimeout` property; implementations must add it.
- **PubSub:** a subscriber now registers the `DataSetMetaData`
configured on its readers (OPC 10000-14 6.2.9.4), and the UADP decoder
decodes DataSetMessages that carry no ConfigurationVersion. Before, a
secured subscriber of the reference sample received no DataSets.
- **Source generator:** a ModelDesign method that declares its arguments
inline, without a method type, produced code that did not compile; it
now uses `MethodState`. The generated code of existing models is
unchanged.
- **MinimalClient sample:** waits for its first data-change notification
before it disconnects.
### Known issues, not addressed here
- UDP multicast: the publisher does not send on the interface that the
subscriber joins, and `MulticastLoopback` defaults to false. The PubSub
guide uses unicast for the exchange on one computer.
- The UDP metadata announcement is sent unsecured on secured writer
groups; a secured subscriber drops it with a warning.
- A server does not start in globalization-invariant mode, because
`ResourceManager` creates `CultureInfo("en-US")`. The Native AOT guide
cautions against the mode.
## Validation
- `git diff --check` and
`./.azurepipelines/validate-migration-plugin.ps1` (15 bundled migration
docs validated, plugin manifests synchronized).
- Local Markdown links, anchors, file-link casing, and contents lists
checked across all 87 documents under `docs/`. The index links every
top-level guide and every target of the previous index.
- Both Getting started routes, Your first information model, the PubSub
exchange, and the Native AOT publish of the Getting started client and
server were run as documented. The NuGet route and the tutorial use the
public `2.0.0-preview.5` packages.
- The changed libraries build for all target frameworks in Release
without warnings.
- Tests on .NET 10: Opc.Ua.SourceGeneration.Tests 199 passed;
Opc.Ua.SourceGeneration.Core.Tests 3,977 passed, 8 skipped;
Opc.Ua.Server.Tests `Fluent` category 621 passed; Opc.Ua.Client.Tests
subscription-engine tests 70 passed; Opc.Ua.PubSub.Tests 1,374 passed, 2
skipped; PubSub fuzz replay 145 passed. The new and affected tests also
pass on .NET Framework 4.8.
- Earlier merge validation: 174 resource-isolation tests passed on .NET
10.
- The full solution suite was not run locally.
Review and validation artifacts are maintained outside the repository.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
13 KiB
Support of the TAP (Task Asynchronous Pattern) for server operations
The OPC UA .NET Standard stack has long supported asynchronous server
operations through the APM (Asynchronous Programming Model). APM uses
IAsyncResult and the Begin/End pattern. .NET Framework 1.0 introduced
this pattern, which remains supported in .NET 8.0.
The stack also supports the TAP (Task Asynchronous Pattern) for server
operations. TAP uses Task and the async/await keywords introduced
in .NET Framework 4.0. It is the recommended way to implement asynchronous
operations in modern .NET applications.
Server applications can implement node managers with TAP. This pattern improves scalability by using server resources more efficiently.
The server library provides these TAP features:
- Task-based
RequestQueueandTransportListenerCallback - Generated code and
MasterNodeManagersupport Task-based operations. - The
IAsyncNodeManagerinterface supports fully asynchronous node managers. AsyncNodeManagerAdapterandSyncNodeManagerAdapterlet synchronous and asynchronous node managers run side by side.
Contents
- Upgrading an existing server
- Async method calls
- AsyncCustomNodeManager
Upgrading an existing server
- Update
INodeManager.CreateMonitoredItemsto support the newMonitoredItemIdFactory. AsyncCustomNodeManageris the recommended base class for fully asynchronous node managers; see AsyncCustomNodeManager.- Keep using
CustomNodeManager2and implement the asynchronous interfaces your server needs:IAsyncNodeManagerfor full async supportICallAsyncNodeManagerfor async method callsIReadAsyncNodeManagerfor async readingIWriteAsyncNodeManagerfor async writingIHistoryReadAsyncNodeManagerfor async history readIHistoryUpdateAsyncNodeManagerfor async history updateIConditionRefreshAsyncNodeManagerfor async condition refreshITranslateBrowsePathAsyncNodeManagerfor async translate browse pathIBrowseAsyncNodeManagerfor async browsingISetMonitoringModeAsyncNodeManagerfor async monitoring mode changesITransferMonitoredItemsAsyncNodeManagerfor async monitored item transferIDeleteMonitoredItemsAsyncNodeManagerfor async monitored item deletionIModifyMonitoredItemsAsyncNodeManagerfor async monitored item modificationICreateMonitoredItemsAsyncNodeManagerfor async monitored item creation
MasterNodeManagerdetects which asynchronous interfaces a node manager implements. It uses an asynchronous implementation when available and otherwise falls back to the synchronous implementation.
- The server supports fully asynchronous node managers that implement
IAsyncNodeManager. Register one withStandardServer.RegisterNodeManager(IAsyncNodeManagerFactory).IAsyncNodeManager.SyncNodeManageris required for compatibility; pass the node manager toSyncNodeManagerAdapterto provide it.
Async method calls
CustomNodeManager2 supports asynchronous method callbacks. Implement
IAsyncNodeManager on your node manager to enable them. Generated method
state classes expose asynchronous callbacks such as
UpdateCertificateMethodState.OnCallAsync; the server uses these callbacks
when the node manager implements the interface. For a generic method handler,
use MethodState.OnCallMethod2Async.
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 insideTask.Runor similar helpers. - The
SyncNodeManagerproperty (required byIAsyncNodeManager) is satisfied automatically: the constructor callsthis.ToSyncNodeManager()and stores the resultingINodeManager3adapter. Callers that still require anINodeManager3reference (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, including browse
operations. AsyncCustomNodeManager.BrowseAsync and
TranslateBrowsePathAsync iterate an INodeBrowser with
NextAsync(CancellationToken) instead of Next(). A browser can therefore
fetch data from an underlying system asynchronously. For example, it can
retrieve references from another server without occupying a request worker
with a blocking call. The default NextAsync wraps Next(), so the browsers
provided by the stack and existing custom browsers continue to work.
See NodeManagers.md
for the browser contract.
Locking strategy vs CustomNodeManager2
The synchronous CustomNodeManager2 service paths use coarse-grained manager
synchronization. AsyncCustomNodeManager instead coordinates writes and
monitored-item management with awaitable semaphores while leaving node-state
synchronization inside NodeState.
The following describes the implementation, not locks that application callers should acquire. Use the manager's service, lifecycle, and fluent APIs so operation admission, cancellation, and disposal remain coordinated.
1. Global write semaphore — m_writeSemaphore
The manager serializes its Write service operations through an awaitable semaphore. Read operations do not acquire it. The semaphore does not turn external updates or several attribute accesses into a transaction; use the node APIs for state access.
2. Monitored-item semaphore — m_monitoredItemSemaphore
A second SemaphoreSlim(1, 1) serializes monitored-item management
operations. It keeps subscription state consistent without blocking reads
or writes. The semaphore protects:
- create, modify, and delete operations
- monitoring-mode changes
- event subscriptions and condition refresh
- monitored-item transfers
3. Per-node locking for read and attribute access
Reads do not acquire a manager-wide lock. NodeState synchronizes attribute and
collection access internally; the async manager awaits ReadAttributeAsync so that
asynchronous value handlers can complete without blocking a thread.
Do not use lock (node) or lock (handle.Node). Those monitors do not coordinate
with the node's private locks and cannot protect its state. Use the node's attribute,
child, and reference APIs instead. Individually synchronized operations do not form
an atomic multi-attribute transaction.
See the threading contract for nodes and browsers for supported operations, snapshot boundaries, and browser ownership.
| Concern | CustomNodeManager2 |
AsyncCustomNodeManager |
|---|---|---|
| Address-space reads | Manager synchronization | Internally synchronized node APIs |
| Write service operations | Manager synchronization | Awaitable write serialization |
| Monitored-item management | Manager synchronization | Awaitable monitored-item serialization |
| Awaiting asynchronous handlers | Use the asynchronous path | Supported by asynchronous operations |
| 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 awaitingNodeState.ClearChangeMasksAsyncafter 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 negotiatedSamplingInterval. 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 sends value changes and events to monitored items
through an asynchronous push path. This path does not block a thread when a
node uses 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 |
These additions do not break existing synchronous callers or sinks.
MonitoredNodeMonitoredItemManager wires the asynchronous sinks. After a
successful write, AsyncCustomNodeManager.WriteAsync flushes changes by
awaiting handle.Node.ClearChangeMasksAsync(...).
The asynchronous sink reads each monitored attribute through
NodeState.ReadAttributeAsync. When registered, an asynchronous
OnReadValueAsync or OnSimpleReadValueAsync handler supplies the value. The
sink then enqueues that snapshot through the bounded per-node channel and
awaits completion. It reads the value before enqueueing the change, so each
notification captures the node value at the time of the change. Awaiting both
operations keeps the producing thread from blocking.
The synchronous ClearChangeMasks and ReportEvent methods also drive the
asynchronous sinks. They complete inline when the node is synchronously
readable and the channel has capacity. They block the calling thread only
while an asynchronous read is in flight or the channel is full. Synchronous
node managers continue to work without changes, but may block in those two
cases.
Each monitored item's queue controls overflow and discard behavior,
including FIFO/LIFO DiscardOldest and the Overflow status bit. The shared
per-node channel does not drop notifications. As a result, the configured
queue policy applies 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);
}
}