# Description Remove stock phrasing and correct misleading or inconsistent technical documentation. The initial two passes covered 367 Markdown files, followed by an interactive review of 169 editorial flags. The U5 interpretation originally deferred in that review has since received the upstream Mantis answer recorded by #4843; remaining server/spec follow-ups stay explicit in the CTT log. - Correct package responsibilities, migration steps, API names, ownership, protocol descriptions and links. Keep bundled migration guides synchronized. Preserve historical conclusions as historical records with separate re-check notes. - Provide at least two rendered TOC levels throughout `docs/`. Convert appropriate architecture figures to Mermaid without changing code examples, transcripts or historical quotations. - Address the October 19 feedback with concrete setup/migration instructions, corrected certificate paths and PubSub names, shorter repeated caveats, consistent XML comments, and a full review of the Concepts guide. Retain #4839's API corrections. - Integrate #4841's runtime/performance guidance and review incoming/recent prose. Preserve dated measurements rather than claiming new benchmark runs; clarify allocation ratios, startup-only processor settings, JIT warm-up and the benchmark commit's scope. Missing raw measurement artifacts and repeat ranges remain tracked by #4842. - Merge master through `e8c5d800a` in `32c6394b7`. Resolve the CTT log conflict using the updated Mantis states and OPC answers, retaining historical framing and re-checks for C28/C32/C43. - Address the latest naming/index feedback in `adb5bf693`: rename the guides to `docs/WotCon.md` and `docs/WotNodeSetConversion.md`; move the provider and DataType-declaration contracts into the conversion guide and remove `docs/WoT.md`. Rename `samples/DI` to `samples/Di` and `samples/Isa95` to `samples/ISA95`. Add content-index READMEs to Di, FluentApi, ISA95, Lds and PubSub; update solution entries, project references, Docker/CI paths, test fixture paths and documentation links with exact casing. **WoT guide scope.** This branch retains the detailed conversion guide that #4758 had replaced with an anchor stub. Conversion limits, verified document sets, preservation rules and provider ownership now live together in `WotNodeSetConversion.md`; binding/runtime guidance remains in `WotBindings.md` and `WotCon.md`. The design-rationale/test-ledger prose was shortened, the oversized argument-table cell moved into its section, and existing anchors preserved. **SDK policy is a separate, explicit user-directed change.** `global.json` sets a stable minimum of `10.0.400`, `rollForward: latestMajor`, and `allowPrerelease: false`. Newer major SDKs are permitted; CI's explicit SDK pins are unchanged. README, GettingStarted, DeveloperGuide and Recycling instructions reflect the possible analyzer differences. The image-contract regression fix accepts compatible stable SDK images instead of requiring exact equality with the floor. The latest four rename/index threads are resolved after publication. Three older threads remain open: universal obsolete-removal timing, unconditional nonce-strength wording, and a blanket Vision NativeAOT claim. Existing implementation/evidence constraints are explained in their inline replies. The two-level TOCs remain as requested. ## Validation - `git diff --check` and staged diff checks: passed. The untouched upstream AutoID NodeSet retains its provenance bytes, including two existing license-header trailing spaces. - `.\.azurepipelines\validate-migration-plugin.ps1`: passed; 15 bundled migration documents and manifests synchronized. - Documentation/path validation: **101** documents retain two rendered TOC levels (**2,228** entries), with idempotent formatting. **52** new relative links and their anchors passed exact-case checks. No tracked references to the old guide/sample paths remain. Both relocated provider-contract sections are preserved in the destination. - `dotnet sln UA.slnx list`: passed; shows the renamed Di/ISA95 projects and the existing FluentApi/Lds/PubSub projects. Updated solution and pump ProjectReference paths also passed exact-case existence checks. - Latest focused commands, run from the repository root: ```powershell # Passed: 61 channel send-order and reconnect tests. dotnet test tests\Opc.Ua.Core.Tests\Opc.Ua.Core.Tests.csproj -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~UaSCBinaryChannelSendOrderTests|FullyQualifiedName~TcpReconnectKeyAgreementTests' --verbosity minimal # Passed: 5 pump datasheet tests; builds the renamed pump sample. dotnet test tests\Opc.Ua.Di.Tests\Opc.Ua.Di.Tests.csproj -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~PumpDatasheetConformanceTests' --verbosity minimal # Passed: 4 repository NodeSet path/round-trip tests, including the moved pump models. dotnet test tests\Opc.Ua.Types.Tests\Opc.Ua.Types.Tests.csproj -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~RepositorySourceSurvivesSerializedLinkedPartitionsAsync' --verbosity minimal # Passed: build, zero warnings/errors. dotnet build samples\ISA95\MinimalIsa95Server\MinimalIsa95Server.csproj -p:CustomTestTarget=net10.0 -f net10.0 --verbosity minimal # Passed: dependent sample-test project build, zero warnings/errors. dotnet build tests\Opc.Ua.WotCon.Samples.Tests\Opc.Ua.WotCon.Samples.Tests.csproj -p:CustomTestTarget=net10.0 -f net10.0 --verbosity minimal ``` Earlier focused integration checks passed 2,620 tests across AutoID, event queues/scheduling, source generation, MCP/package profiles and WoT. Both reviewed Mermaid diagrams rendered locally without network access. These checks are not a full solution run. The full solution has **not** been rerun for this review. Earlier `dotnet test UA.slnx -p:CustomTestTarget=net10.0 --verbosity quiet` did not pass: three AOT projects rejected VSTest and 20 Vision tests failed during graphics initialization with `OutOfMemoryException`. Remaining long-running tests were stopped at the user's request. A focused Vision rerun passed 558 tests and reproduced those 20 failures. The earlier `dotnet test UA.slnx -p:CustomTestTarget=net48 --verbosity quiet` was blocked by the former SDK pin and was not retried after the policy change. The user approved proceeding with those limitations documented; current remote CI is not claimed green. ## Related Issues - Follow-up #4842 tracks the requested 2.0.2+ benchmark/tuning and diagnostic-tool documentation refresh. #4841 supplies documentation and earlier results, not all newly requested measurements. - #4758 explains the conversion guide's earlier consolidation; the current provider contracts are now consolidated into `WotNodeSetConversion.md`. - #4839 supplies the reviewed API corrections; #4843 supplies the updated CTT issue record. - The original prose cleanup and SDK policy were user-requested without a source issue. No new runtime architecture is introduced by these documentation edits. ## 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. - [ ] 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. - [ ] 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. --------- Co-authored-by: Roman Ettlinger <romanett98@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
162 KiB
Node Managers
A node manager exposes part of a server's address space: variables, methods, events, and their behavior. Start with the overview and first model, then use the architecture and lifecycle sections when you need custom or dynamically loaded models. If you have not run a server yet, start with Getting started.
Table of contents
- Overview
- Start with a model
- Built-in node managers
- Core vs custom node managers
- Registering node managers
- Server address-space metadata
- Source-generated node managers
- What the generator produces
- Opting in
- Wiring callbacks: the
Configurepartial - Typed model-traversal — the
Configure(I{Manager}NodeManagerBuilder)partial - Event sources — typed
Publish<TEvent>on notifier wrappers - Multi-namespace and manager-swap subclassing
- NativeAOT publishing
- Runtime NodeSet alternative
- Building richer node managers — the fluent extension surface
- Engineering units & EU range
- Bulk property initialisation
- References & dynamic child objects
- Creating instances of model types
- Alarm setup (MVP)
- Boolean supervision → alarm activation (NAMUR pattern)
- Simulation timers
- Pushing runtime value changes to subscribers
- Subscription-gated sources
- Multi-model composition
- Mixing ModelDesign and NodeSet2 in one project
- Importing a NodeSet2 overlay at runtime —
builder.Import - NodeSet2 access-level bitmasks
- Materialising instances at runtime — NodeId assignment
- Current limitations
- Sample
Overview
A node manager is the server-side component that owns a portion of the server address space and implements the service behavior for the nodes in that portion. In this stack a node manager is an IAsyncNodeManager (or the older synchronous INodeManager adapted to it) that can create nodes during address-space startup, return manager handles for NodeIds it owns, browse and translate references, read and write attributes, dispatch methods, validate monitored items, and participate in history, events, and node-management services.
StandardServer creates a MasterNodeManager while the server starts. The master node manager is the server's routing layer: OPC UA service implementations call it, and it dispatches each operation to the node manager that owns the requested node. Ownership is resolved primarily from the NodeId.NamespaceIndex, then confirmed by asking the candidate manager for a handle. This keeps application models, runtime NodeSets, diagnostics, configuration, and namespace 0 infrastructure independent while presenting one coherent address space to clients.
Developers need to care about node managers when they expose application data, methods, events, alarms, file-system objects, alias names, runtime NodeSets, or companion-spec models from a server. A node manager is also where model-specific behavior is attached: read and write callbacks, method callbacks, historian providers, event notifiers, permissions, model-change notifications, and cross-manager references to nodes owned elsewhere. For simple generated models, the source generator and fluent builder hide much of the plumbing; for dynamic or backed-by-service models, a custom manager is the boundary between the OPC UA services and the application's data source.
The server builds the initial set of node managers before accepting connections. Additional managers can be registered by hosting extensions such as AddNodeManager and AddRuntimeNodeSet, and the lifecycle API can add, reload, or remove lifecycle-managed managers while the server is running. Regardless of how a manager is supplied, it must cooperate with the master node manager's routing and reference-merging rules so clients can browse, monitor, and call nodes consistently across namespace and manager boundaries.
Start with a model
For a first server, use the MinimalBoilerServer sample. It supplies a model and a generated node-manager factory, so you can start with variables and callbacks rather than implementing service dispatch. To write a model of your own and use it from a client, follow the Your first information model tutorial.
- Follow source-generated node managers to include your model and generate its factory.
- Register the factory with
AddOpcUa().AddServer(...).AddNodeManager<TFactory>(); the single-file host example shows the complete hosting shape. - Attach value and method handlers in the generated
Configurepartial, then connect a client and browse the model.
If models change independently of the binary, start with runtime NodeSets instead. Both paths use the same routing, permissions, and lifecycle contracts below.
Single-file Program.cs — what it looks like
The shipping services.AddOpcUa().AddServer(...) extension wires the
server into the .NET Generic Host: configuration, certificate check,
ApplicationInstance lifetime and Ctrl+C/SIGTERM handling are all owned
by the host. User code stays at ~12 lines.
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Logging.AddConsole();
builder.Services
.AddOpcUa()
.AddServer(o =>
{
o.ApplicationName = "MyServer";
o.ApplicationUri = "urn:localhost:MyServer";
o.ProductUri = "uri:opcfoundation.org:MyServer";
o.AutoAcceptUntrustedCertificates = true;
o.EndpointUrls.Add("opc.tcp://localhost:51210/MyServer");
})
.AddNodeManager<MyModel.MyModelNodeManagerFactory>();
await builder.Build().RunAsync();
AddOpcUa() registers a ServiceProviderTelemetryContext that adapts
the host's ILoggerFactory to ITelemetryContext — no separate logging
pipeline is required. IOpcUaServerBuilder.AddNodeManager<T>() registers
an IAsyncNodeManagerFactory; use AddSyncNodeManager<T>() for the
legacy INodeManagerFactory. For advanced configuration (custom security
policies, additional builder calls), set OpcUaServerOptions.ConfigureBuilder.
That's the whole server. The Boiler version is in
samples/MinimalApi/MinimalBoilerServer/Program.cs.
AutoAcceptUntrustedCertificatesis enabled here only for a local sample. Configure trusted certificates and disable automatic acceptance in production; see Certificates.
Built-in node managers
Replicated application managers require a fixed replica NodeId identity configuration. It runs before their constructors, so namespace indexes and callback keys are already final. The shared registration policy survives factory rebasing/replacement and runs before predefined-node indexing, including during import and hydration. Shared model namespaces must be declared up front; loading a new shared namespace at runtime requires a coordinated layout revision. Built-in core, diagnostics and configuration namespaces retain their replica-local role.
Every StandardServer creates a MasterNodeManager and asks the server's IMainNodeManagerFactory for the main managers that are always present. The default MainNodeManagerFactory creates one ConfigurationNodeManager and one CoreNodeManager; application-provided managers from AddNodeManager or derived-server overrides are appended after those built-ins.
Master node manager
MasterNodeManager is not an address-space model in the same sense as an application manager. The server stores this coordinator as IServerInternal.NodeManager and exposes it through IMasterNodeManager. Service implementations route through it for reads, writes, browsing, TranslateBrowsePaths, method calls, node management, monitored-item setup, history, and event-related operations.
Internally, service-call dispatch and lifecycle coordination are separated. The session-service implementations live in the internal NodeManagerServiceDispatcher, which depends only on the lock-free routing-table snapshot and never acquires lifecycle semaphores; MasterNodeManager keeps the public service surface (every virtual entry point and protected helper delegates to the dispatcher, so derived classes are unaffected) together with NodeManager lifecycle coordination. The node-management services (AddNodes, DeleteNodes, AddReferences, DeleteReferences) sit between the two: they dispatch per item like other services but serialize address-space mutation with runtime NodeManager lifecycle operations.
AddNodes rejects colliding identifiers whether supplied by the client,
derived from a parent and browse name, or returned by a custom allocator.
Registration atomically admits the new root without replacing an existing
node. This add-only rule does not change the explicit runtime
replacement/re-registration APIs.
Variable admission also checks the supplied value against its datatype, rank
and array dimensions. Each nonzero ArrayDimensions entry is a maximum length:
shorter values are valid, while exceeding any dimension returns
BadNodeAttributesInvalid without registering the node. Zero denotes an
unknown maximum; null and empty arrays remain valid for compatible array types.
The master node manager builds a routing table keyed by namespace index. During construction it ensures the configured dynamic namespace URI is present, registers the configuration/diagnostics manager first, registers the core node manager second, and then registers application managers. For a service request, GetManagerHandleAsync uses the NodeId.NamespaceIndex to find the candidate manager list and asks each candidate for a handle until one claims the node. If no explicit route exists for the namespace, it falls back to the core node manager. This means a namespace route is a candidate list, not a single-owner map.
Multiple managers can serve the same namespace. RegisterNamespaceManager(string namespaceUri, IAsyncNodeManager nodeManager) appends a manager to the namespace route instead of replacing the existing route; the routing table also preserves manager order during lifecycle replacement. This is important for namespace 0 and for generated or runtime models that add nodes in namespaces already used by another manager.
Read and Write call ReadAsync/WriteAsync only on the managers that appear in the route of a requested namespace, in dispatch order, so a request does not pay for managers that cannot own any of its nodes. If any valid node is in a namespace without a route, every manager is called as before. A manager that processes nodes outside the namespaces it registers must register them with RegisterNamespaceManager to keep receiving those Reads and Writes.
The master node manager also merges references across managers. Each manager receives the shared externalReferences table while CreateAddressSpaceAsync runs. After all managers have created their nodes and historical-access advertisement has been reconciled, the master calls AddReferencesAsync(externalReferences, ...) on every manager. MasterNodeManager.CreateExternalReference is the helper most custom managers use to put a cross-manager reference in that table. The target manager then materialises the reference on the node it owns, so a node owned by one manager can be browseable from a parent, folder, metadata object, or notifier owned by another manager. Attaching a child only in the source manager is not enough when the source and target owners differ.
At runtime, the same reference handling is used for lifecycle-managed managers. A prepared manager builds its address space while hidden from client routing, the master adds its external references during commit, and rollback/removal removes the cross-manager references that were added for that generation.
Core node manager
CoreNodeManager is the always-present manager for the core address-space infrastructure. The default master-node-manager constructor registers it for namespace index 0 and for the built-in server namespace route, and it also uses it as the fallback when a namespace has no explicit route. CoreNodeManager derives from AsyncCustomNodeManager, implements ICoreNodeManager, and uses sampling groups for monitored items.
AsyncCustomNodeManager.Dispose() stops admission to gate-backed operations and
starts cleanup without blocking on outstanding work. DisposeAsync() also waits
for admitted operations and semaphore owners to finish before releasing the
address space, monitored-item manager and synchronization resources. Diagnostics
mutations participate in the same lifetime. MasterNodeManager and the server's
asynchronous teardown await this cleanup; prefer await using when directly
owning a manager. Subclasses release deferred resources in DisposeAsyncCore,
which runs after admitted operations drain, and await its base implementation.
Cleanup starts outside the admission lock, including when all operations have
already completed. Guarded helpers remain available within the active
DisposeAsyncCore async context, but new caller operations remain rejected.
Subclasses must await their teardown operations before the callback returns.
Operation lifetime extends through post-processing callbacks even after their
semaphore scope has ended.
CustomNodeManager2 also closes admission before releasing its monitored-item
manager. Its synchronous service and lifecycle calls, and its optional
asynchronous method callbacks, retain operation leases until they return.
Cleanup waits for those leases without holding the node lock or the admission
lock, so a sampling worker can observe closed admission and finish. New calls
after admission closes throw ObjectDisposedException, except Find and both
FindPredefinedNode overloads, which return null. Nested calls belonging to an
operation that is still admitted can finish; a captured context cannot grant
access after that operation returns. Dispose() initiates
this shutdown; DisposeAsync() awaits the shared completion and propagates
cleanup failures even when a legacy Dispose(bool) override omits its base call.
AsyncNodeManagerAdapter invokes the wrapped asynchronous disposal directly,
without first invoking synchronous disposal, so
the master and server also await cleanup for adapted synchronous managers.
An admitted callback may initiate shutdown with Dispose(), but must return
before its caller awaits DisposeAsync().
Before serializing address-space deletion or disposal, the master drains configuration work that can call back into the server. Session-closing notifications and the address space remain available until accepted user deactivations and deferred configuration effects complete.
The core manager owns and imports built-in nodes that other server components need to expose as part of the standard server address space. It is also the target for nodes loaded by the diagnostics/configuration manager from generated model output: DiagnosticsNodeManager.CreateAddressSpaceAsync loads predefined diagnostics/configuration nodes and then imports them into the core manager with ImportNodesAsync(..., isInternal: true). When application nodes are imported with isInternal: false, the core manager updates the diagnostics manager so diagnostics metadata stays in sync.
Diagnostics and configuration node manager
The default configuration and diagnostics manager is a single object. MainNodeManagerFactory.CreateConfigurationNodeManager creates a ConfigurationNodeManager; ConfigurationNodeManager derives from DiagnosticsNodeManager and implements IConfigurationNodeManager. ServerInternalData.SetNodeManager assigns DiagnosticsNodeManager, ConfigurationNodeManager, and CoreNodeManager from the master node manager, and the master exposes both diagnostics and configuration properties from index 0 of its manager list. In the default server, therefore, ServerInternal.DiagnosticsNodeManager and ServerInternal.ConfigurationNodeManager refer to the same ConfigurationNodeManager instance through different interfaces.
As a diagnostics manager, it loads the standard diagnostics and server-support nodes generated for the stack, manages session and subscription diagnostics, diagnostics enable/disable state, aggregate functions, event notifier updates, and the well-known OPC UA Part 17 alias-name methods that dispatch through the server-wide alias-name registry. It registers namespace URIs for the OPC UA namespace and the diagnostics namespace.
As a configuration manager, the same instance exposes push certificate-management and server-configuration functionality from OPC UA Part 12. It owns the server-configuration methods and state that interact with trust lists, certificate groups, transaction coordination, pending regenerated keys, endpoint and listener registries, and post-ApplyChanges effects. The class is a partial split by concern (ConfigurationNodeManager.PushMethods.cs, .PushValidation.cs, .CertificateSlots.cs, .ApplyChanges.cs, .TrustMaterial.cs, .CertificateAlarms.cs, .NamespaceMetadata.cs) over one core file, and delegates namespace-metadata tracking and alarm scheduling to the internal NamespaceMetadataRegistry and CertificateAlarmScheduler collaborators.
Managers supplied by server features
The default server does not create file-system, alias-name, or runtime-NodeSet managers unless the application opts in. Hosting extensions register their factories as normal startup node-manager factories: for example, file-system support uses FileSystemNodeManagerFactory, runtime NodeSet loading uses RuntimeNodeSetNodeManagerFactory, and alias-name support can use AliasNameNodeManager. Once registered, these managers are routed by the same master-node-manager table and follow the same cross-reference and lifecycle rules as hand-written or source-generated managers.
Runtime lifecycle provider
StandardServer also creates a NodeManagerLifecycle provider. It is not itself a node manager; it is the host control-plane object behind INodeManagerLifecycle. Hosted servers expose it through dependency injection, and direct StandardServer users can access StandardServer.NodeManagerLifecycle. The lifecycle provider prepares, commits, reloads, removes, and drains lifecycle-managed managers through the master node manager.
Why the lifecycle provider exists
Without it, a node manager can only be added at startup. Changing the address space of a running server would then require a restart, which drops every session and subscription. The provider lets an application add, reload or remove a manager while the server runs, and it coordinates the hand-over so that in-flight requests, MonitoredItems and Browse continuation points are neither lost nor served by a half-disposed manager. If you do not need runtime changes, register the manager as a normal startup factory and ignore the provider.
Core vs custom node managers
CoreNodeManager is the built-in owner of core server nodes. It derives from
AsyncCustomNodeManager and therefore shares its NodeState-based service,
monitoring, and synchronization infrastructure. For application models, prefer
source-generated managers or an application-owned AsyncCustomNodeManager;
CustomNodeManager2 remains the synchronous compatibility base.
Storage and data structures
| Concern | CoreNodeManager / AsyncCustomNodeManager | CustomNodeManager2 |
|---|---|---|
| Nodes | NodeState instances indexed by PredefinedNodes |
NodeState instances indexed by PredefinedNodes |
| Service handles | Validated NodeHandle objects |
Validated NodeHandle objects |
| Namespace ownership | Core owns built-in routes; application managers declare their namespaces | Application managers declare their namespaces |
| Synchronization | Awaitable manager operations and privately synchronized node state | Synchronous manager operations and privately synchronized node state |
Neither design exposes a node's internal lock. Follow the
threading contract, not an external
lock (node).
Extensibility
Use the generated Configure partial and fluent callbacks for model behavior.
For specialized services, use AsyncCustomNodeManager's asynchronous extension
points. The core manager is server infrastructure, not the preferred home for
application business logic. Its ImportNodesAsync adds predefined nodes and
updates diagnostics where required.
Operational behaviour
Reading & Writing
The async manager validates ownership and delegates attribute access to
NodeState.ReadAttributeAsync / WriteAttributeAsync. Core inherits that path.
The synchronous compatibility manager uses the corresponding synchronous
attribute operations. Type/rank checks, access checks, audit reporting, and
semantic-change notifications remain part of the manager service path; directly
mutating a node value is not a replacement for a client Write service call.
For OnWriteValueAsync and OnSimpleWriteValueAsync, successful results normally
copy the requested value, status and source timestamp into the node cache.
An external committed-state publisher can opt out for that operation:
return new AttributeWriteResult(result)
{
SuppressCacheUpdate = true
};
This returns the handler's result without changing cached data or manufacturing
a value-change mask. Access, type and writable-metadata checks still run.
The external publisher owns observation updates and their notifications; for an
indexed write, the handler also owns applying the range to its authoritative data.
The default is false, so existing handlers and ordinary write-through variables
keep their current behavior. Suppression does not turn an uncommitted or failed
command into a successful write.
Method Calls
CustomNodeManager2.Call completes synchronously through its synchronous
Call override and MethodState.Call. It does not launch asynchronous
overrides or discard their results. An unhandled dispatch exception is
returned to the caller as an exception, while errors translated by
MethodState retain their normal ServiceResult status.
Use CallAsync and the asynchronous overrides for work that can suspend.
The awaitable path completes only after that work finishes. Both paths
share node ownership, method resolution, role-permission checks, and
argument-result handling.
Browsing and path translation
Both manager bases browse through NodeState.CreateBrowser, validate browse
permissions, and participate in cross-manager path resolution. The async manager
uses INodeBrowser.NextAsync for iteration; see the
browser contract.
Browse continuation ownership transfers explicitly between the dispatcher, node manager, and session store. Permission rejection, metadata/fetch failure, and cancellation release the claimed point. If a batch is canceled, any earlier retained pages that were not returned to the client are released too. Successful continuation pages remain owned by the session until resumed, released, or expired.
Runtime subtype replacement (IPredefinedNodeSubtypeReplacer)
AsyncCustomNodeManager implements the IPredefinedNodeSubtypeReplacer capability interface. It swaps an already-registered predefined instance node for a differently-typed instance (typically a generated subtype) at runtime, while preserving the node's identity in the address space:
- the replacement inherits the existing node's
NodeId,BrowseName,SymbolicName,DisplayNameandReferenceTypeId; - children shared by both types (matched by
BrowseNameat any depth) keep the existing child'sNodeIdand value, so well-known instance NodeIds survive the swap; - children that only exist on the replacement take their
NodeIdfrom a caller-suppliedBrowseName → NodeIdmap, or a freshly minted one; - the old subtree is removed and the new one registered in the manager's
PredefinedNodesindex, and aModelChangeis emitted (subject toModelChangeEmissionEnabled) so live clients observe the new type definition and members.
When to use it. Reach for this capability when a well-known instance node's concrete type is a runtime decision — for example modelling Server.ServerRedundancy as TransparentRedundancyType vs NonTransparentRedundancyType from configuration, and changing that mode live (see Opc.Ua.Redundancy.Server.ServerRedundancyController). It is the right tool whenever you would otherwise mutate a node's TypeDefinitionId in place and hand-build the subtype-specific children.
When not to use it. If you only need to re-index an already-reparented replacement of the same type (e.g. promoting a passive nodeset node to a typed proxy), the lighter ReplacePredefinedNode(nodeId, node) index-only swap is sufficient — this is what RoleStateBinding and the ConfigurationNodeManager passive→typed promotion do today. If you are creating a new node subtree, use AddNodeAsync / AddPredefinedNodeAsync or the fluent CreateInstance<TState>(...) builder instead.
Create the replacement with the generated CreateInstanceOf<Type> factory, then hand it to the capability:
// server.DiagnosticsNodeManager (or any AsyncCustomNodeManager) exposes the capability.
if (server.DiagnosticsNodeManager is IPredefinedNodeSubtypeReplacer replacer)
{
ISystemContext context = server.DefaultSystemContext;
ServerObjectState serverObject = server.ServerObject;
var existing = serverObject.ServerRedundancy;
// Build the target subtype instance (typed, generated).
NonTransparentRedundancyState subtype = context.CreateInstanceOfNonTransparentRedundancyType();
await replacer.ReplacePredefinedInstanceSubtypeAsync(
context,
existing,
subtype,
// well-known NodeIds for members that only exist on the subtype
newChildNodeIds: new Dictionary<QualifiedName, NodeId>
{
[new QualifiedName(BrowseNames.ServerUriArray, 0)]
= VariableIds.Server_ServerRedundancy_ServerUriArray
},
// keep the parent's typed backing slot in sync (setters don't reparent)
onReplaced: node => serverObject.ServerRedundancy = (ServerRedundancyState)node,
cancellationToken);
}
The operation is deliberately exposed as a capability interface method rather than a construction-time fluent builder: the fluent INodeBuilder surface models building a node before it is registered, whereas subtype replacement mutates a node that is already live in the address space. Callers that already hold a fluent builder can still create the replacement instance with CreateInstance<TState>(...) and then pass the built node to the capability.
Monitoring and subscriptions
Core uses the async manager's IMonitoredItemManager infrastructure with
sampling groups enabled. Application AsyncCustomNodeManager instances can use
change-triggered monitoring or opt into sampling groups. Both manager bases
support filter validation, event subscriptions, condition refresh, and role checks.
See monitoring selection
for the tradeoff between periodic sampling and change notifications.
For terminal observations before deleting dynamic nodes, use the
final-value publication barrier,
not ClearChangeMasksAsync alone.
A sampling group checks the Read permission of an item through
ValidateRolePermissionsAsync when it starts sampling the item, and reuses the
verdict until one of its inputs changes: the Session or its effective identity
(ActivateSession with another user, or granted roles refreshed after a role
change), the item's node handle, the RolePermissions or UserRolePermissions
of any NodeState, or the default permissions of a namespace. A revoked
permission therefore yields BadUserAccessDenied within one or two sampling
cycles, as before. Each item
is also revalidated at least once per second, which bounds the delay for
permissions a node manager computes without such a change, for example through
OnReadRolePermissions.
For an asynchronous, caller-specific Value read policy, register
AsyncCustomNodeManager.RegisterValueReadAuthorization(node, authorize) and keep
its disposable registration for the binding's lifetime. The policy runs at
monitored-item admission and each subscriber delivery, independently of cached
role verdicts. It receives the subscriber's current identity and session context,
not the context that reported a change. Endpoint access restrictions are also
rechecked around awaited reads and authorization. An OnReadValue check alone
does not authorize a shared notification snapshot.
The default session retains its original endpoint contract separately from the last successfully accepted channel's transport evidence. Reactivation cannot reduce effective confidentiality merely by retaining the same UA security mode and policy: an HTTPS-to-plaintext-TCP downgrade is rejected before rebinding, protecting values already queued for Publish as well as future notifications. Confidential HTTPS-to-HTTPS or HTTPS-to-WSS transitions retain their supported behavior; this is not a blanket transport-profile equality rule.
Retiring the registration denies further reads with BadShutdown, including
work already awaiting the policy; a replacement registration can authorize new
work. Binding shutdown must cancel and drain its admitted asynchronous work
before releasing callback resources.
Sampling interval revision
When a client creates or modifies a monitored item the node manager revises the
requested samplingInterval before the server returns it in
revisedSamplingInterval. Three inputs take part:
| Input | Where it comes from |
|---|---|
| Requested sampling interval | MonitoringParameters.SamplingInterval; a negative value means "use the default sampling interval" (see below) |
| Node minimum | BaseVariableState.MinimumSamplingInterval of the monitored node, and only for the Value Attribute |
| Server minimum | ServerConfiguration.MinSupportedSamplingInterval, published in Server.ServerCapabilities.MinSupportedSampleRate |
The rule applied by SubscriptionManager.CalculateRevisedSamplingInterval is:
- A requested interval below zero is resolved to the default sampling interval:
the publishing interval of the subscription when the item is created, and
the item's current sampling interval when it is modified. A
ModifyMonitoredItemscall that leaves the sampling interval unspecified therefore does not silently retune the item. - If the node declares
MinimumSamplingIntervals.Continuous(0) for theValueAttribute, it reports by exception and no lower bound is applied — the requested interval is returned unchanged. - Otherwise the interval is raised to the larger of the node minimum and
MinSupportedSamplingInterval. Attributes other thanValue, nodes that declareMinimumSamplingIntervals.Indeterminate(-1), and nodes that are not Variables are only bound byMinSupportedSamplingInterval. double.MaxValueis capped to one year.
Event monitored items do not sample and are not affected.
MinSupportedSamplingInterval defaults to 0, which means the server does not
impose a server-wide lower bound. Configure it in XML:
<ServerConfiguration>
<!-- ... -->
<MaxNotificationsPerPublish>1000</MaxNotificationsPerPublish>
<MinSupportedSamplingInterval>2000</MinSupportedSamplingInterval>
<!-- ... -->
</ServerConfiguration>
or with the fluent configuration builder:
application.Build(applicationUri, productUri)
.AsServer([endpointUrl])
.SetMinSupportedSamplingInterval(2000);
With MinSupportedSamplingInterval set to 2000 ms, a client that requests a
10 ms sampling interval on a node that declares a minimum of 100 ms is revised
to 2000 ms; a node that declares 5000 ms still wins and is revised to 5000 ms.
CustomNodeManager2 and AsyncCustomNodeManager pick the configured value up
automatically through their MinSupportedSamplingInterval property. Node
managers that implement INodeManager directly can call
SubscriptionManager.CalculateRevisedSamplingInterval to apply the same rule.
Final-value publication before node removal
AsyncCustomNodeManager implements INodeValuePublication for terminal
observations such as a completed production job. Stop the subtree's producers,
keep its values unchanged, and await the owning manager's barrier before deletion:
await manager.WaitForValuePublicationAsync(
[job], TimeSpan.FromSeconds(10), cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
await manager.DeleteNodeAsync(manager.SystemContext, job.NodeId, cancellationToken);
Completion means final Value notifications entered responses to real Publish
requests, not merely a queue, transport delivery or client acknowledgment.
Only the terminal queue entry counts; an earlier equal-valued notification
cannot satisfy the barrier.
The barrier makes permission-checked final reads, preserves index ranges and
protects pending terminal samples from overwrite. Ordinary refreshes should
continue using ClearChangeMasksAsync without waiting for Publish requests.
Membership is a snapshot of reporting Value items with live sessions; descendants are included. New subscriptions, event/non-Value items and detached durable subscriptions are outside it. Losing a captured subscriber releases its obligation; disabling publishing does not. There is no atomic subscription-admission/deletion transaction.
The default deadline is 30 seconds; explicit deadlines must be positive and finite. Timeout, cancellation, read/permission failure, transfer or unsupported aggregate/custom items fail rather than imply delivery. Retain the nodes on failure. Never await the barrier while holding a gate its callbacks need.
History
Core inherits the async manager's history dispatch rather than maintaining a
separate ILocalNode history path. Advertising history access does not itself
provide storage: configure the appropriate historian provider and node handlers.
See Historical Access for provider contracts, read/update
operations, and capability advertisement.
Security
Both manager bases participate in access-level, attribute-write, and role-permission checks. Core uses the async base's implementation. Application callbacks must preserve those service checks and enforce any additional data-source permissions. See role-based user management.
Threading contract for nodes and browsers
A NodeState synchronizes itself. Its attributes, children, notifiers and references each sit
behind a private lock inside the node, and NodeState.CreateBrowser holds a browse lock while it
assembles the browser. No caller — inside or outside the stack — may take a lock on a node
instance. A lock (node) is a lock on a monitor that guards nothing: every path that touches the
node's data takes the node's own locks instead, so the two never interlock. This is enforced by
convention rather than by the compiler, so the rule is: if you feel the need to lock a node, the
operation you want is missing from NodeState — add it there.
| You want | Use |
|---|---|
| A consistent read of one attribute | ReadAttribute / ReadAttributeAsync — already guarded |
| A consistent read of several attributes | ReadAttributes — each attribute is guarded; there is deliberately no cross-attribute transaction |
| A consistent snapshot of the references | GetReferences, GetChildren — already guarded |
| Add a reference only if it is absent | AddReferenceIfMissing — the check and the insert are one critical section |
| Everything browsable, without locking the node | CreateBrowser — the node guards the build |
What CreateBrowser does and does not promise. Browser construction on a node is
serialized, so two concurrent browses do not interleave their PopulateBrowser /
OnPopulateBrowser work — a handler that mutates the node during population depends on that.
The browser is a point-in-time copy: later changes to the node do not appear in it. It is not
an atomic snapshot across the node's children, notifiers and references. Writers take those
collections' own locks, not the browse lock, so a browser built while a writer runs can pair
children from before a change with references from after it. Each collection is read
consistently; the combination is not a transaction. Making it one would mean funnelling every
child, notifier and reference write through the browse lock, which is a far larger contract than
browsing needs.
An INodeBrowser is single-consumer. It performs no synchronization of its own and belongs
to whoever created it. Where a browser outlives a single service call — the instance parked in a
continuation point for BrowseNext — its owner serializes access to it: AsyncCustomNodeManager
does so through the continuation point's BrowserContext, CustomNodeManager2 with a lock
around the iteration. A derived browser must not add locking of its own; NodeBrowser no longer
exposes one to inherit (see migration).
An INodeBrowser is iterated through NextAsync by the async server paths. Next() and
NextAsync(CancellationToken) drain the same sequence, and the default NextAsync simply wraps
Next(), so a browser that only overrides Next() works unchanged. AsyncCustomNodeManager
drives both BrowseAsync and TranslateBrowsePathAsync through NextAsync, which gives a
browser whose references come from I/O — an aggregating server that browses another server, a
device, a file system — a place to await instead of holding a request worker on a blocking
call. Creation stays synchronous: OnCreateBrowser and CreateBrowser build the browser under
the node's browse lock, so the fetch happens lazily on the first NextAsync, not in the
constructor.
public sealed class RemoteBrowser : NodeBrowser
{
public override async ValueTask<IReference?> NextAsync(CancellationToken cancellationToken)
{
// In-memory references first, exactly as the base class hands them out.
IReference? reference = base.Next();
if (reference != null)
{
return reference;
}
m_remote ??= await m_session.BrowseAsync(..., cancellationToken).ConfigureAwait(false);
return m_remote.Count > 0 ? m_remote.Dequeue() : null;
}
// Synchronous consumers still exist (the nodeset exporter, CustomNodeManager2);
// bridge them rather than duplicating the fetch.
public override IReference? Next()
{
return NextAsync(CancellationToken.None).AsTask().GetAwaiter().GetResult();
}
}
Three rules follow for node types that customise browsing:
- An override of
PopulateBrowserruns with the node's browse lock held. Keep it to in-memory work — the lock is held for its duration, so blocking on I/O there stalls every other browse of that node. A browser that has to reach an underlying system does that lazily in its ownNextAsync, outside every node lock, asDirectoryBrowserdoes for the file-system provider. - A browser that overrides
NextAsyncalso overridesNext(), because the baseNext()only sees the in-memory references. BridgingNext()toNextAsyncis the usual shape; the async server never calls it, so the blocking only ever happens on a synchronous caller's own thread. - An override of
CreateBrowserthat builds its own browser instead of delegating tobase.CreateBrowsermust fill it throughPopulateBrowserSynchronized. CallingPopulateBrowserdirectly leaves construction unserialized against other browses of the same node and skipsOnPopulateBrowseraltogether.
Delivery and callback lifecycle
Composed asynchronous session resources use
AsyncCustomNodeManager.RegisterSessionClosingHandler and retain its
registration lease. The existing session-close pipeline awaits each handler
outside manager gates and attempts the remaining handlers after a failure.
Disposing the lease unregisters future calls; manager disposal drains admitted
calls. Overrides of SessionClosingAsync must await their base implementation.
A handler must not await disposal of its own manager. The synchronous
SessionManager.SessionClosing event remains suitable only for nonblocking
cleanup.
FluentNodeManagerBase.RegisterNodeResource attaches one composite resource
to an exact indexed node. Actual node removal and manager disposal await its
cleanup, including disposal-only startup aborts. The resource must join
concurrent disposal calls; Machinery's per-machine resource group supplies
that behavior. This registration does not use value change-mask callbacks,
which can run on an unrelated publisher's execution path.
Event delivery for a node follows enqueue order. The Server node and nodes opting into parallel event consumers deliver each event concurrently to independent monitored items, then complete that event before advancing the shared queue. A slow permission check cannot let a later event overtake an earlier one; the bounded queue still applies backpressure.
Background event and value delivery use the subscriber's current effective identity and accepted channel, not the identity that reported a change. Role invalidation is reconciled before authorization; an asynchronous check cannot enqueue a result after its captured authorization context changes.
Changes to semantic properties such as EURange resample the exact monitored
source under each subscriber's read permissions and channel restrictions.
The semantic-property writer's permission does not authorize reading the
parent value. Both monitored-node and sampling-group consumers own this work
for their lifetime and recheck authorization before queuing the result.
Namespace defaults and registered value-read authorizers apply to these reads
as they do to ordinary monitored delivery.
Monitored-item deletion and mode changes isolate failures by owner. Already completed results are retained, remaining items for a failed owner receive an explicit error, and other owners still run. Event unsubscribe failures likewise do not skip later owners or monitored items. Request cancellation continues to stop the operation rather than being converted into an ordinary per-item failure.
Fluent node builders reject new handler registration once graph authoring is sealed, including callbacks wired directly to node slots. History, removal, condition-refresh, and monitored-item callbacks on ad-hoc child and instance builders use the same dispatcher as regular node builders. A manager without that dispatcher rejects those registrations explicitly.
Monitored-source lifecycle ownership is rechecked after a worker stops. A superseded deactivate/reactivate cycle keeps its live source rather than reacquiring it and releasing the replacement. Callback invocation is coordinated without holding a gate across its asynchronous completion, so first-subscriber callbacks can still reenter monitoring operations. Virtual monitored sources register the same shutdown behavior as concrete sources; every live materialized instance is released at teardown.
Registering node managers
A NodeManager owns a part of the server address space. This section explains the three points at which a NodeManager can be registered with a server, and what the server guarantees when registrations change while the server is running.
For how to author a NodeManager, see source-generated NodeManagers, runtime NodeSets, and CoreNodeManager vs CustomNodeManager2.
There are several ways a NodeManager originates and is added to a server, shown in the following table.
| Registration point | API | When the address space is built |
|---|---|---|
| Compile time | A source-generated or hand-written AsyncCustomNodeManager / CustomNodeManager2 type |
When the server creates its address space |
| Startup | IOpcUaServerBuilder.AddNodeManager(...), IOpcUaServerBuilder.AddRuntimeNodeSet(...) |
During CreateAddressSpaceAsync, before the server accepts connections |
| Runtime | INodeManagerLifecycle.AddAsync / ReloadAsync / RemoveAsync |
While the server is running and serving Clients |
Compile-time and startup registration are the normal path. Use runtime registration only when the set of models has to change without restarting the server.
Startup registration
AddNodeManager and AddRuntimeNodeSet register a factory on IOpcUaServerBuilder; the server
builds its address space from all factories while it starts. Each application NodeManager then
appears as a generation-1 entry in INodeManagerLifecycle.Registrations.
services.AddOpcUa()
.AddServer(o => { /* … */ })
.AddNodeManager(sp => new MyNodeManager(sp.GetRequiredService<ITelemetryContext>()));
For a one-shot fluent node manager, the callback creates and places the complete contributed graph. The hosting API does not add an implicit root:
const string namespaceUri = "urn:example:line";
services.AddOpcUa()
.AddServer(o => { /* … */ })
.AddNodeManager(namespaceUri, builder =>
{
ushort namespaceIndex =
(ushort)builder.Context.NamespaceUris.GetIndex(namespaceUri);
builder.CreateInstance(
new QualifiedName("Line", namespaceIndex),
parent => new FolderState(parent))
.Configure(node => node.UnderObjectsFolder());
});
Awaited readiness
Implement INodeManagerReadinessNotification when your manager needs initialized
server subsystems or registers dependent runtime NodeManagers:
public interface INodeManagerReadinessNotification
{
ValueTask OnServerReadyAsync(CancellationToken cancellationToken = default);
}
Create the manager's own address space in CreateAddressSpaceAsync and register dependent runtime
managers in OnServerReadyAsync, when the server can process lifecycle operations. The async and
sync adapters forward the callback. Readiness runs:
- At startup, after the initial address spaces are built; startup awaits every initial manager.
- After a runtime
AddAsyncor reload, once the new generation is visible; the call awaits it before returning the handle.
A manager added by a readiness callback gets its own callback through AddAsync, not a second one
as an initial manager.
Concurrent operations, cancellation and failure
- Unrelated registrations, including dependent
AddAsynccalls, stay available while readiness runs. Removing or reloading the initializing registration throwsInvalidOperationException; retry afterwards, and never do it from inside its own readiness callback. - Forward the callback's cancellation token. Cancellation after publication does not undo it: clients may already see the manager, and several registrations are not published atomically.
- A readiness failure after publication leaves the registration live. Add throws a post-commit
InvalidOperationExceptionand the handle remains inRegistrations; reload throwsNodeManagerReloadCommittedExceptionwhoseRegistrationis the replacement. A startup failure is reported afterStandardServershuts everything down. - Keep the handle of every dependency you add and remove them in
DeleteAddressSpaceAsync, which the host runs outside the lifecycle operation, so it can await those removals. Dispose injected registries or stores only if you own them. - Shutdown rejects new lifecycle operations and waits for active readiness work. After an orderly
StopAsyncthe same server can be started again.
Initializing managers in a custom host
A host that does not use StandardServer must initialize the address spaces and server
subsystems, enable runtime lifecycle operations, then await readiness for a snapshot of the
initial managers taken beforehand:
ArrayOf<IAsyncNodeManager> initialManagers = [.. master.AsyncNodeManagers];
// Initialize server subsystems and enable runtime lifecycle operations here.
for (int ii = 0; ii < initialManagers.Count; ii++)
{
if (initialManagers[ii] is INodeManagerReadinessNotification participant)
{
await participant.OnServerReadyAsync(cancellationToken).ConfigureAwait(false);
}
}
Your host handles startup failure and shuts down initialized managers. Runtime additions and
reloads through INodeManagerLifecycle already await readiness; do not call it again.
Registration generations
A generation identifies one published NodeManager instance within a logical registration.
NodeManagerRegistration.Id stays the same across reloads; Generation counts the instances:
startup adoption and AddAsync create generation 1 and each committed reload
creates the next. Generation is independent of the information model's version and namespace
index. After a reload, use the returned handle; the previous one is stale even if a shadow reload
keeps the old manager alive. A new registration starts at generation 1 with a new Id.
Runtime registration
A running server exposes INodeManagerLifecycle. Resolve it from dependency injection in a hosted
server, or use StandardServer.NodeManagerLifecycle when constructing the server directly.
public sealed class ModelLoader(INodeManagerLifecycle lifecycle)
{
private NodeManagerRegistration? m_registration;
public async ValueTask LoadAsync(IAsyncNodeManagerFactory factory, CancellationToken ct)
{
m_registration = await lifecycle.AddAsync(factory, callerContext: null, ct);
}
public async ValueTask ReloadAsync(IAsyncNodeManagerFactory replacement, CancellationToken ct)
{
m_registration = await lifecycle.ReloadAsync(m_registration!, replacement, callerContext: null, ct);
}
public ValueTask RemoveAsync(CancellationToken ct)
{
return lifecycle.RemoveAsync(m_registration!, callerContext: null, ct);
}
}
Each add returns an immutable NodeManagerRegistration; reload returns the next generation and
invalidates the previous handle. Registrations also lists application NodeManagers composed
before startup (not the protected diagnostics, configuration and core managers), so a control plane
can remove them or, if they implement INodeManagerReloadParticipant, reload them.
INodeManagerLifecycle is a host control-plane API. Teardown waits for requests that captured the
retired generation, so a lifecycle call made from inside an OPC UA service or Method callback would
wait for itself. Every lifecycle method therefore takes the operation the caller is running under;
pass it from a callback and the server throws InvalidOperationException instead of deadlocking:
private async ValueTask<ServiceResult> OnReloadModelAsync(
ISystemContext context,
MethodState method,
IList<Variant> inputArguments,
IList<Variant> outputArguments,
CancellationToken ct)
{
// Throws InvalidOperationException: the call is serving a Client request.
await m_lifecycle.ReloadAsync(
m_registration,
replacement,
context.GetOperationContext(),
ct);
return ServiceResult.Good;
}
A control-plane caller — a hosted service, or anything resolved from dependency injection — is not
serving a request and passes null.
The guard is an identity check against the requests the server is currently executing. A caller
that is inside a request but passes no operation is not detected; its wait is bounded by the longest
outstanding request deadline plus RequestManager.RequestDrainTimeout, after which the operation
fails with a TimeoutException. This applies to event-source and session callbacks too: a callback
that starts a lifecycle operation must pass the OperationContext it was given. Passing null
from inside a callback is unsupported, because the operation is then treated as an ordinary
external caller and waits for the callback's own request to finish.
A lifecycle operation validates the replacement address space before its client-visible commit and rolls back on preparation failure. Failures after publication, including readiness, can leave a live registration, so do not treat them as pre-commit rollback.
Node manager lifecycle impact on clients
Reload modes
All reload modes build and validate the replacement generation before Clients can see it, then switch new requests to it atomically. Namespace indexes are append-only, so reloads can add namespace URIs but never renumber existing ones. Requests that captured the retired generation complete on it before it is disposed. Choose the mode by what should happen to existing subscriptions:
| Mode | Existing MonitoredItems | Choose it when |
|---|---|---|
ReloadAsync (normal) |
Compatible items move to the replacement and keep their ids; removed or incompatible items get one BadNodeIdUnknown. |
A compatible model update where Clients should keep their subscriptions. The default. |
ShadowReloadAsync |
Items stay on the retired generation and keep sampling; only new items use the replacement. | Old subscriptions must keep the old model until they drain. Runs two generations at once. |
ImmediateReloadAsync |
Every old item gets BadNodeIdUnknown; none are migrated. |
Destructive or security-sensitive changes where Clients must resubscribe. |
Browse continuation points that captured a retired generation are invalidated after the request drain in normal and immediate reload; shadow reload retains them (see Continuation points).
MonitoredItems
On a normal reload a compatible NodeId keeps the same MonitoredItem and Subscription without a
transient bad status. A removed or incompatible NodeId is detached and publishes one
BadNodeIdUnknown data-change notification (Part 4 §5.8.4.1); adding a compatible Node with that
NodeId later reattaches the item automatically. Event MonitoredItems recover their source binding
without a data-change status.
That notification is queued in its natural position (Part 4 §5.13.1.5) and is never discarded: on
a full queue an incoming value is dropped instead. At the default queueSize of 1 there is no
queue, so the notification simply becomes the served value and a later sample replaces it. Only one
such notification is pending at a time, and it is not preserved across a durable subscription
restart. Issue #4102 records the
specification ambiguity behind this.
Derive from CustomNodeManager2 or AsyncCustomNodeManager to get these transitions, or implement
INodeManagerMonitoredItemLifecycle (and ISubscriptionMonitoredItemLifecycle for a custom
Subscription). An implementation the server cannot migrate safely fails with
NotSupportedException before any routing changes.
Continuation points
A Client may page through a large Browse or HistoryRead result while you reload the NodeManager that produced it. A saved continuation point keeps its issuing NodeManager, and every old NodeManager that owns a reference target it still needs (matched by exact NodeId), alive until it is released. Each page uses the original model and is checked against the Session's current permissions; new requests use the active generation. A paged HistoryRead keeps its exact provider, source Node and query, and another Session cannot consume the token.
Retention ends on the last page, an explicit release, cache eviction, a failed or cancelled
BrowseNext, or Session close. A retired NodeManager is deleted only after its MonitoredItems,
continuation points and in-flight requests have finished. A normal or immediate reload or removal
instead invalidates every continuation point that needs the removed manager; BrowseNext then
returns BadContinuationPointInvalid. Shutdown discards saved continuation points.
Custom parts, each only needed if you replace the built-in one:
- Lazy browser: implement
IBrowseContinuationDependenciesonContinuationPoint.Dataand list every NodeManager owning a target it can still return. Empty means only the issuing manager. If it cannot be declared, paged Browse returnsBadNotSupported; unpaged Browse is unchanged. - Dynamic history provider: implement
IHistorianContinuationDependenciesand declare the extra local Nodes a later page needs. A missing declaration returnsBadNotSupportedwith no partial data. See historian pagination. - Session cache: use the cache
Sessionsupplies for dynamic history; a replacement is rejected withBadNotSupported. A custom Browse cache implementsISessionContinuationPointLifecycleand checksContinuationPoint.RequiresManager, not onlyManager. If it cannot answer, graceful cleanup refuses to delete the manager and reportsCleanupFailurewithout rolling back.
A replicated history record lets a standby clean up the original Session's state but cannot resume
the read; the Client gets BadContinuationPointInvalid and starts a new read.
Namespaces
Namespace indexes are append-only for the lifetime of a running server. Removing a model removes its
Nodes and routing but leaves its namespace URI in NamespaceArray, and a later reload or add reuses
the same index. When a live add appends a URI, the server updates NamespaceArray and UrisVersion.
A Client with model change tracking enabled re-reads its namespace table
when it observes the resulting model-change notification, so NodeIds from the newly added namespace
resolve without any application code. See
Namespace table refresh. A Client that does not
track model changes keeps the namespace table it fetched while the Session was opened and has to
call ISession.FetchNamespaceTablesAsync itself.
DataTypes
Runtime DataType registrations are additive. Reload accepts an existing DataType only when its definition is structurally compatible, rejects incompatible changes, and retains removed stand-in encodeables so existing Sessions and in-flight values remain decodable.
INodeManagerBatchLifecycle stages several lifecycle changes (and their type, reference-type,
encoding and encodeable-factory registrations) in private images, then publishes them together with
the routing snapshot. In-flight requests keep the images they captured, and writes to a serving
image fail with InvalidOperationException while the decision callback runs.
await using IPreparedNodeManagerBatch batch = await batchLifecycle.PrepareAsync(
[NodeManagerBatchChange.Add(factory)], ct);
NodeManagerBatchResult result = await batch.CommitAsync(
async token => await store.SaveDecisionAsync(token), ct);
PrepareAsync(changes, ct) and BeginAsync(ct) are shortcuts for ordinary external callers; they
forward callerContext: null. Code running inside an OPC UA request or binding callback must call the
overload that takes its OperationContext; a null context there is not a compatibility mode and
waits for its own request.
- A completed commit releases the batch;
DisposeAsyncafterwards is optional and does not undo publication. An unpublished batch, including one after a failed commit, must be disposed to discard its candidates. - Before the durable decision callback runs, the batch reserves the serving routing revision. All
routing writers fail with
InvalidOperationExceptionwhile it is held; readers are unaffected. Retry after it releases. A noncommit or cancellation releases only that batch's reservation. - Prepared batches need the default
EncodeableFactory; a custom factory is rejected before candidate creation.
Invocation publication units
INodeManagerPublicationLifecycle groups sequential prepared batches into one logical invocation.
CapturePublication records the serving revisions; BeginAsync(callerContext, ct) reserves the
invocation, and IsCurrent reports whether the revisions still match. A stale invocation must be
disposed and recaptured. Units are prepared with INodeManagerPublication.PrepareAsync and follow
the same decision and cleanup contracts.
Admission stays held across every unit. Other lifecycle mutations fail BadServerTooBusy before
effects and can retry after disposal, so callback code should defer conflicting lifecycle work
instead of awaiting a nested mutation. Disposal aborts remaining units, reports cleanup failures and
releases admission; shutdown waits for it.
Prepared Session and all-events bindings
Preparing a batch does not activate Sessions or subscribe event sources on its candidates. After the decision is accepted, the host publishes the new routing and binds the candidates to the live Sessions and all-events MonitoredItems. New activations and MonitoredItem create, modify and delete operations wait for this reconciliation and then use the published routes, so no arrival is missed or duplicated.
A Session or all-events callback that starts a lifecycle operation must pass the OperationContext
it received (see the sample above); the server then lets that nested operation proceed instead of
waiting on the callback's own admission. Without the context the call is treated as external and
waits for the callback, which is unsupported. Waiting follows the normal cancellation and quiescence
rules; there is no shortcut around them.
Closed Sessions and deleted MonitoredItems are not restored. Exceptions and bad subscription
results after publication are reported in NodeManagerBatchResult.CleanupFailure; they do not turn
a committed decision into an abort or block readiness and retirement.
Prepared external references
Prepared batches also stage the external references of surviving and candidate NodeManagers and
publish them with the other images, so Browse, Translate and reference exports see one consistent
view. Abort leaves serving references unchanged. This path supports the in-memory NodeState
ownership model of AsyncCustomNodeManager and adapted CustomNodeManager2; a custom external
reference store is rejected before the decision.
Change notifications
Every committed lifecycle transaction emits one compressed model-change notification. Reload also
emits a semantic-change notification when values of Properties marked with the SemanticChange
access-level bit changed.
How to make a node manager reloadable
A NodeManager can be added and removed through the lifecycle provider without extra work. Reload
needs more, because the references other NodeManagers hold into the retired address space have to be
carried over to the replacement. A NodeManager can only be reloaded when it implements
INodeManagerReloadParticipant; reloading one that does not fails with NotSupportedException
before anything changes.
The contract is a single method:
public interface INodeManagerReloadParticipant
{
ValueTask<ArrayOf<LocalReference>> PrepareReloadAsync(
IAsyncNodeManager replacement,
CancellationToken ct = default);
}
The server calls it on the outgoing generation, with the already-built replacement, before any
routing changes. It has two jobs: re-add the references your manager contributed to Nodes it does
not own (such as an Organizes reference from the ns=0 Objects folder to your root) so the foreign
Nodes point at the new generation, and return, as LocalReference counterpart edges
(sourceId, referenceTypeId, isInverse, targetId) with source and target swapped and isInverse
negated, every inbound reference whose target the replacement no longer contains.
A minimal implementation looks like this:
public async ValueTask<ArrayOf<LocalReference>> PrepareReloadAsync(
IAsyncNodeManager replacement,
CancellationToken ct = default)
{
if (replacement is not MyNodeManager target)
{
throw new NotSupportedException(
"This NodeManager can only be reloaded with another instance of the same type.");
}
// 1. hand the references we added to foreign Nodes to the replacement.
Dictionary<NodeId, IList<IReference>> addedReferences = GetAddedReferences();
await target.AddReferencesAsync(addedReferences, ct).ConfigureAwait(false);
// 2. report the counterparts whose target the replacement no longer has.
var dropped = new List<LocalReference>();
foreach (KeyValuePair<NodeId, IList<IReference>> entry in addedReferences)
{
if (target.ContainsNode(entry.Key))
{
continue;
}
foreach (IReference reference in entry.Value)
{
if (!reference.TargetId.IsAbsolute)
{
dropped.Add(new LocalReference(
(NodeId)reference.TargetId,
reference.ReferenceTypeId,
!reference.IsInverse,
entry.Key));
}
}
}
return new ArrayOf<LocalReference>(dropped.ToArray());
}
Practical guidance:
- Keep a record of the cross-manager references you create (for example the
externalReferencesdictionary handed toCreateAddressSpaceAsync) soPrepareReloadAsynccan replay it; the base classes do not track them. - Throw
NotSupportedExceptionfor an incompatible replacement rather than skipping the transfer. - Do not mutate your own Nodes: the replacement already owns the address space.
- Reload is transactional: if you throw, Clients never see a partially applied model.
RuntimeNodeSetNodeManager is the only built-in implementation (see
src/Opc.Ua.Server/RuntimeNodeSet/RuntimeNodeSetNodeManager.cs), so runtime NodeSets reload out of
the box. A CustomNodeManager2 or AsyncCustomNodeManager can be added and removed live and
becomes reloadable once it implements the interface.
Related documentation
- Runtime NodeSets — loading NodeSet2 XML without source generation.
- Source-generated NodeManagers — compile-time models.
- Dependency Injection — the
services.AddOpcUa()hosting surface. - Model Change Tracking — how Clients observe address-space changes.
Server address-space metadata
This guide covers two server-startup behaviours that keep the published address space consistent with what the server can actually serve:
- namespace metadata objects under
Server/Namespaces; - historical-access advertisement on variables.
NamespaceMetadata for every namespace
OPC UA Part 5 requires the Server/Namespaces object to describe the
namespaces exposed by a server. Companion specifications repeat the
same requirement in their namespace-metadata clauses so clients can
compare NamespaceVersion and NamespacePublicationDate against cached
models.
StandardServer calls the overridable
PublishNamespaceMetadataAsync(IServerInternal, CancellationToken) seam
during startup, after conformance units are published and before the
server accepts sessions. The default implementation uses
NamespaceMetadataPublisher to walk NamespaceArray and ensure every
namespace URI has a NamespaceMetadataType object under
Server/Namespaces.
For source-generated models, the publisher fills
NamespaceVersion and NamespacePublicationDate from the
ModelDependencyAttribute stamped on model assemblies. Existing
metadata objects and already-populated values are preserved.
Node-manager authoring note
Attaching a child to an object owned by another node manager is not
enough to make it browseable through the master node manager. This is
common for namespace metadata because Server/Namespaces is a namespace
0 object owned by the configuration node manager, while the metadata
object may be created by another manager. Register the link as a
cross-manager reference with AddReferencesAsync when the owner differs.
NamespaceMetadataPublisher does this check automatically for metadata
objects it creates.
Servers that publish namespace metadata themselves can override
StandardServer.PublishNamespaceMetadataAsync and either add custom
metadata or return without doing work.
Historical-access reconciliation
Official companion NodeSets often declare Historizing="true" or set
AccessLevel bits such as HistoryRead on variables whose type is
capable of history. A concrete server still needs a historian provider
before it can serve HistoryRead or HistoryUpdate for those variables.
During master-node-manager startup, every AsyncCustomNodeManager
reconciles this advertisement before external references are applied.
For each variable that advertises historical access, the server checks
whether an IHistorianProvider resolves through:
- the node manager's
GetHistorianProvider(NodeState)override; - the server-wide historian registry (
RegisterForNode,RegisterForNamespace, thenRegisterDefault).
If no provider resolves, the server clears Historizing and masks
HistoryRead / HistoryWrite from AccessLevel,
UserAccessLevel, and the corresponding attribute read callbacks. This
keeps direct reads of the attributes consistent with the values stored
on the node.
Variables with a historian keep their NodeSet-declared history surface.
Use builder.UseHistorian() and .Historize() from the fluent server
API, or override GetHistorianProvider, when a NodeSet variable should
continue advertising historical access.
See also
- Historical Access — historian provider model
and fluent
.Historize()wiring. - Source-generated NodeManagers — NodeSet2 import, fluent node creation, and runtime instance NodeId assignment.
Source-generated node managers
This guide explains how to use the OPC UA stack source generator to emit a
ready-to-host AsyncCustomNodeManager for an information model design XML, and
how to wire callbacks (read/write/method/lifecycle) using the fluent
INodeManagerBuilder API. The combination is designed for single-file,
NativeAOT-friendly servers — see
samples/MinimalApi/MinimalBoilerServer for the canonical sample.
What the generator produces
The base source generator already emits, for each model design:
Add{Ns}(NodeStateCollection, ISystemContext)— populates a node collection.Add{Ns}(INodeStateFactoryBuilder)— registers strongly-typed activators.Add{Ns}DataTypes(IEncodeableFactoryBuilder)— registers encodeables.
When ModelSourceGeneratorGenerateNodeManager=true is set or a
class is annotated with [Opc.Ua.Server.Fluent.NodeManagerAttribute],
the generator additionally emits, in either the {ModelNamespace}
namespace (legacy MSBuild mode) or the user class's namespace
(attribute mode):
public partial class {Ns}NodeManager : AsyncCustomNodeManager(legacy) orpublic partial class {UserClass} : AsyncCustomNodeManager(attribute)- Constructor
(IServerInternal, ApplicationConfiguration). - Pre-registers the model namespace URI.
LoadPredefinedNodesAsyncreturnsnew NodeStateCollection().Add{Ns}(context)wrapped in aValueTask<NodeStateCollection>.CreateAddressSpaceAsyncawaitsLoadPredefinedNodesAsync, then builds a fluentINodeManagerBuilder,awaitsConfigureAsync(builder, ct)(the awaitable wiring seam — see below), invokesConfigure(builder),awaitsCompleteConfigureAsync(re-running the reverse-reference pass so nodes created insideConfigurepublish their references to nodes owned by other managers — e.g. an inverseOrganizesto the ns=0Objectsfolder — into theexternalReferencesdictionary), andawaitsSealConfigurationAsync(builder, ct)— which seals the builder, replaysNotifyNodeAddedfor every predefined node so per-node lifecycle hooks fire deterministically, and only then completes the registrationsConfigurecould not await and starts the simulations.AddPredefinedNodeAsync/RemovePredefinedNodeAsyncoverrides forward to base and then dispatch the lifecycle notification.OnMonitoredItemCreated(still synchronous on the base) dispatches the per-node hook.- Declares
partial void Configure(INodeManagerBuilder builder);for synchronous user wiring, and inheritsprotected virtual ValueTask ConfigureAsync(INodeManagerBuilder builder, CancellationToken cancellationToken)fromFluentNodeManagerBasefor wiring that has toawait(materialising instances, reading a store).ConfigureAsyncruns first, so the nodes it creates exist by the timeConfigurewires callbacks against them. The hook is avirtualmethod rather than a secondpartialbecause a partial method can be optional (partial void) or awaitable (extended partial, must be implemented) — not both. Use either or both. - Implements
INodeSetImportFactoryProviderby delegating to the model's generated{Ns}NodeSetImportFactoryProvider, so a NodeSet2 document imported throughbuilder.Importmaterialises this model's nodes as their generated*Statesubclasses. Apartial void AddNodeSetImportFactories(List<INodeSetImportFactory>)hook lets a dependency model contribute its own factories. See Importing a NodeSet2 overlay at runtime.
- Constructor
public sealed class {Ns}NodeSetImportFactoryProvider- One
INodeSetImportFactoryper model type/declaration, each calling a concrete constructor for an empty state.
- One
public class {Ns}NodeManagerFactory : IAsyncNodeManagerFactory- Returns the namespace URI in
NamespacesUris. CreateAsync(IServerInternal, ApplicationConfiguration, CancellationToken)returns aValueTask<IAsyncNodeManager>containing a new manager instance.- Both members are
virtualso consumers can subclass to add a second namespace or swap in a manager subclass.
- Returns the namespace URI in
AddNodeManager on StandardServer has overloads for both
INodeManagerFactory and IAsyncNodeManagerFactory; the generated
async factory binds to the latter automatically.
The generated activation pipeline
The order the generated CreateAddressSpaceAsync runs in is part of the
contract — the ordering test in NodeManagerGeneratorTests pins it — so
it is worth seeing whole:
sequenceDiagram
autonumber
participant M as Generated NodeManager
participant U as Your partial or override
participant B as NodeManagerBuilder
M->>M: await LoadPredefinedNodesAsync
Note over M: LoadPredefinedNodesAsync has<br/>populated PredefinedNodes
M->>B: construct, then AttachToBuilder
M->>U: await ConfigureAsync(builder, ct)
Note over U: the awaitable seam — materialise<br/>instances, read a store, await I/O
M->>U: Configure(builder), Configure(typedBuilder)
Note over U: synchronous wiring, against nodes<br/>ConfigureAsync has already created
M->>M: await RegisterAuthoredNodesAsync(builder, ct)
Note over M: staged nodes reach the address space
M->>M: await CompleteConfigureAsync(externalReferences, ct)
Note over M: reverse-reference pass, so authored nodes<br/>publish edges to other managers
Note over M,B: await SealConfigurationAsync(builder, ct)
M->>B: SealGraphAuthoring
Note over B: no further authoring, nothing activated yet
M->>U: replay NotifyNodeAdded for every predefined node
M->>B: CompleteSealAsync
Note over B: drain staged root notifiers,<br/>then start the simulations
Both ends of the seal matter. Sealing before the replay stops an
OnNodeAdded handler from authoring nodes nothing would register any
more; activating after it keeps a simulated value change from preceding
the OnNodeAdded handler of its own node.
Opting in
Reference the OPCFoundation.NetStandard.Opc.Ua.SourceGeneration NuGet package.
NuGet imports the package's build props automatically; no explicit import is needed.
Only the in-repository ProjectReference setup requires an explicit import of
OPCFoundation.Opc.Ua.SourceGeneration.props; see
Project Reference (internal development).
Then choose one of the two opt-in modes:
Per-class opt-in via [NodeManager] (recommended)
Annotate the user-authored partial class that should host the generated manager:
using Opc.Ua.Server.Fluent;
namespace MyCompany.MyServer;
[NodeManager]
public partial class MyDeviceNodeManager
{
partial void Configure(INodeManagerBuilder builder)
{
// wire your callbacks here
}
}
The generator emits a sibling partial class MyDeviceNodeManager : AsyncCustomNodeManager and a MyDeviceNodeManagerFactory (implementing
IAsyncNodeManagerFactory) in the same namespace as the user class. No
MSBuild flag is required.
When a project carries multiple model designs, disambiguate which design the attribute targets via either:
[NodeManager(NamespaceUri = "http://opcfoundation.org/UA/Boiler/")]
or by file stem:
[NodeManager(Design = "BoilerDesign")]
Set GenerateFactory = false to suppress factory emission when you want
to ship a hand-written IAsyncNodeManagerFactory.
When the manager also owns namespaces beyond the model's own — most commonly a separate instance namespace for nodes created at runtime — declare them on the attribute:
[NodeManager(
NamespaceUri = "http://opcfoundation.org/UA/Boiler/",
AdditionalNamespaceUris = new[]
{
"http://opcfoundation.org/UA/Boiler/Instance"
})]
The generated constructor passes them to the base manager together with
the model namespace and the generated factory advertises them in
NamespacesUris, so the master node manager routes requests for those
namespaces to this manager from the moment it is built. Calling
SetNamespaces later is not sufficient — the master builds its
namespace routing from what the manager reported when it was
constructed.
The URI expressions must be available to Roslyn before this generator
runs: use string literals, const values declared in ordinary source,
or constants from a referenced assembly. A constant emitted by another
generator in the same compilation is not available. Such an
expression reports MODELGEN035 at the offending argument instead of
silently dropping the namespace from the generated manager and factory.
Taking over the constructor and the namespace order
The generator emits two constructors:
public MyDeviceNodeManager(IServerInternal server, ApplicationConfiguration configuration)
: this(server, configuration, null) { }
protected MyDeviceNodeManager(
IServerInternal server,
ApplicationConfiguration configuration,
string[]? namespaceUris)
: base(server, configuration, namespaceUris ?? DefaultNamespaceUris()) { … }
Chain to the protected one from a constructor of your own when the
manager needs collaborators the generated signature does not carry, or
when it owns a different set of namespaces. DefaultNamespaceUris()
returns the attribute's set, model namespace first.
The order decides which namespace becomes NamespaceIndexes[0] and
therefore the manager's own NamespaceIndex — the one a browse path
without an explicit ns= prefix resolves in, and the one
CreateFluentBuilder defaults to. It does not decide where runtime
NodeIds are minted: that is the NodeId factory's
DefaultNamespaceIndex, so a manager whose instances belong in a
namespace of its own points the factory at that namespace and leaves the
order alone. See
Which namespace.
[NodeManager(
NamespaceUri = Namespaces.MyModel,
AdditionalNamespaceUris = ["urn:my:instances"],
GenerateDefaultConstructor = false,
GenerateFactory = false)]
public partial class MyDeviceNodeManager
{
private readonly IMyDatabase m_database;
public MyDeviceNodeManager(
IServerInternal server,
ApplicationConfiguration configuration,
IMyDatabase database)
// null adopts DefaultNamespaceUris(): the model namespace first
: this(server, configuration, null)
{
m_database = database;
// the records this manager creates are its own instances, so they
// are minted next to the model rather than into it
NodeIdFactory = NodeIdFactory.WithDefaultNamespaceIndex(
(ushort)server.NamespaceUris.GetIndex("urn:my:instances"));
}
}
GenerateDefaultConstructor = false suppresses the public two-argument
form so callers cannot build the manager without its collaborators. The
generated factory constructs the manager through exactly that form, so a
manager that suppresses it — and does not declare a replacement with the
same signature — must set GenerateFactory = false too.
Binding to a model a referenced assembly supplies
A [NodeManager] normally lives in the project that emits the model.
That is impossible when the model belongs to a package which cannot
reference Opc.Ua.Server — a model-only assembly shared with clients,
for example. Point the attribute at the model anyway: add its design as
AdditionalFiles in the server-side project, and the generator emits
the manager and its fluent surface there while the model types keep
coming from the reference.
<!-- MyServer.csproj — the model itself lives in MyModel.csproj -->
<ItemGroup>
<ProjectReference Include="..\MyModel\MyModel.csproj" />
<AdditionalFiles Include="..\MyModel\Design\MyModel.xml" />
<AdditionalFiles Include="..\MyModel\Design\MyModel.csv" />
</ItemGroup>
Nothing else is needed — no ModelSourceGeneratorFluentAccessorsOnly.
Without a binding, a design that a reference already supplies stays
skipped, exactly as before, so this cannot start duplicating types by
accident.
Two conditions apply, both reported as MODELGEN014 when unmet: the
referenced assembly must publish the model under the same C# prefix, and
it must not already contain generated fluent accessors (a model-only
assembly built with ModelSourceGeneratorOmitFluentApi does not). The
project-wide ModelSourceGeneratorGenerateNodeManager switch is not a
substitute here and is still rejected in accessors-only builds: it would
emit conventionally named managers into the referenced model's own C#
namespace for every design in the project.
Project-wide opt-in via MSBuild property (legacy)
If you prefer a generator-derived class identity ({Prefix}NodeManager /
{Prefix}NodeManagerFactory) without authoring a stub partial, set the
opt-in property:
<PropertyGroup>
<ModelSourceGeneratorGenerateNodeManager>true</ModelSourceGeneratorGenerateNodeManager>
</PropertyGroup>
<ItemGroup>
<AdditionalFiles Include="Generated\MyModelDesign.xml" />
<AdditionalFiles Include="Generated\MyModelDesign.csv" />
</ItemGroup>
This emits {Prefix}NodeManager + {Prefix}NodeManagerFactory for
every design in the project. Wire callbacks by adding a sibling
partial class {Prefix}NodeManager that implements Configure.
Without either opt-in, only the existing Add{Ns}* extensions are
emitted — hand-written AsyncCustomNodeManager (or legacy
CustomNodeManager2) subclasses keep working unchanged.
Wiring callbacks: the Configure partial
Author a sibling partial that fills in Configure:
namespace MyModel;
public partial class MyModelNodeManager
{
// The source-generated constructor retains the exact startup
// configuration on FluentNodeManagerBase. Use the protected property
// from any user-authored partial; no custom factory is required.
private MyModelConfiguration? Settings =>
Configuration?.ParseExtension<MyModelConfiguration>();
partial void Configure(INodeManagerBuilder builder)
{
builder
.Node("Boilers/Boiler #1/Drum1001/LevelIndicator/Output")
.OnRead(MyReadHandler);
// Resolve a singleton instance by its TypeDefinitionId — stable
// across deployments and independent of where the instance sits
// in the tree. Ideal for well-known types like
// HistoryServerCapabilities or a single BoilerType instance.
builder
.NodeFromTypeId(ExpandedNodeId.ToNodeId(
MyModel.ObjectTypeIds.BoilerType, Server.NamespaceUris))
.OnNodeAdded((ctx, node) => /* ... */);
// For multi-instance types, disambiguate with a BrowseName:
builder
.NodeFromTypeId(
ExpandedNodeId.ToNodeId(MyModel.ObjectTypeIds.BoilerType, Server.NamespaceUris),
new QualifiedName("Boiler #2", nsIndex))
.OnRead(MyReadHandler);
}
}
Path syntax is /-separated BrowseNames, rooted at the model
namespace's predefined nodes. Optional ns=N; prefix lets you target a
different namespace.
Addressing modes
| Method | Resolves by | Use when |
|---|---|---|
Node(string path) |
BrowseName path | Deterministic tree layout, multiple siblings |
Node(NodeId id) / Node<TState>(NodeId id) |
Absolute NodeId | You own the id (e.g. generated Variables.*) |
NodeFromTypeId(NodeId typeId) / NodeFromTypeId<TState>(NodeId typeId) |
BaseInstanceState.TypeDefinitionId |
Singleton instance of a well-known type |
NodeFromTypeId(NodeId typeId, QualifiedName browseName) |
TypeDefinitionId + BrowseName | Multi-instance types — pick one |
Node<TState>(TState node) |
Nothing — takes the node as given | You already hold the NodeState (see below) |
NodeFromTypeId walks every predefined node owned by this manager
(and their sub-trees) at Configure-time. Error matrix:
BadNodeIdInvalid—typeIdis null orIsNull.BadNodeIdUnknown— no instance carries thatTypeDefinitionId, or the optionalbrowseNamedisambiguator finds no match.BadBrowseNameDuplicated— more than one candidate and no disambiguator was supplied (or multiple candidates share the samebrowseName).BadTypeMismatch— typed overload'sTStatecast fails.
Node<TState>(TState node) is the one addressing mode that does not
consult the predefined-node graph. Use it for nodes that appear after
Configure has run — a service object a host contributes at runtime, a
subtree replaced in AddBehaviourToPredefinedNodeAsync, a device
discovered while the server is up — so the same fluent wiring recipe
applies to statically declared and runtime-created nodes alike:
private async Task ConfigureServiceAsync(MyServiceState service)
{
NodeManagerBuilder builder = CreateFluentBuilder(namespaceIndex);
builder.Node(service.DoSomething!)
.OnReadRolePermissions(GrantSelfAdmin);
await builder.SealAsync().ConfigureAwait(false);
}
The builder exposes:
| Method | Wires |
|---|---|
OnRead / OnReadAsync |
BaseVariableState.OnReadValue |
OnWrite / OnWriteAsync |
BaseVariableState.OnWriteValue |
OnCall / OnCallAsync |
MethodState.OnCallMethod* |
OnCallWithResult |
MethodState.OnCallMethodWithResultAsync — ordered outputs and resolved operation/input diagnostics |
OnReadRolePermissions / OnReadUserRolePermissions |
NodeState.OnRead*RolePermissions — compute the permission set per request (e.g. grant a caller a role on its own record) |
OnNodeAdded / OnNodeRemoved |
Lifecycle dispatch from NotifyNodeAdded |
OnEvent, OnConditionRefresh, OnHistoryRead, OnHistoryUpdate |
Node or manager-level dispatch keyed by NodeId |
OnCreateMonitoredItem, OnMonitoredItemCreated, OnMonitoredItemModified, OnMonitoredItemDeleted, OnMonitoringModeChanged |
Data-change monitored-item creation and lifecycle |
INodeManagerBuilder.NodeManager is typed as IAsyncNodeManager. Use
builder.NodeManager.SyncNodeManager to obtain the synchronous
INodeManager facade for legacy interop, or cast it to your concrete
manager type if you need direct access.
Ordinary Node(...) / Variable(...) resolution happens once
during CreateAddressSpaceAsync, against the in-memory predefined-node
tree. Virtual node families are registered during the same phase but
materialize individual nodes per service operation as described below.
There is no reflection, no Activator.CreateInstance, no
Expression.Compile — the whole pipeline is NativeAOT-safe.
For methods that need an operation result and per-input diagnostics together, see complete method invocation results. This asynchronous handler is exclusive with ordinary method handlers and also works on virtual method families.
On-demand virtual node families
Use ResolveNodes when the manager owns a potentially large or external
address space that must not be copied into PredefinedNodes. The first
delegate is a cheap ownership test and must not perform I/O. The second
delegate materializes the requested NodeState asynchronously:
partial void Configure(INodeManagerBuilder builder)
{
builder.ResolveNodes(
nodeId => TryParseRegisterId(nodeId, out _),
async (context, nodeId, ct) =>
{
RegisterAddress address = ParseRegisterId(nodeId);
RegisterMetadata? metadata =
await m_device.DescribeAsync(address, ct);
if (metadata is null)
{
return null;
}
return new BaseDataVariableState(parent: null)
{
NodeId = nodeId,
BrowseName = new QualifiedName(metadata.Name, nodeId.NamespaceIndex),
DisplayName = metadata.Name,
DataType = metadata.DataType,
ValueRank = ValueRanks.Scalar
};
})
.OnRead(ReadRegister)
.OnWrite(WriteRegister)
.OnCreateBrowser(CreateRegisterBrowser)
.OnMonitoredItemCreated(StartPushSource);
}
Predefined nodes always win. On a predefined-node miss,
FluentNodeManagerBase selects exactly one matching virtual family,
creates an unvalidated NodeHandle, and invokes the resolver during
normal node validation. Overlapping predicates fail with
BadConfigurationError rather than depending on registration order.
The resolver may return null for a syntactically valid id whose backing
object does not exist. A returned node with NodeId.Null receives the
requested id; a conflicting non-null id is rejected with BadNodeIdInvalid.
Resolver ServiceResultException failures and callback-template validation
errors retain their exact status and are not cached as missing nodes.
Batched operations report these errors only for the affected item and
continue processing other items. During stored monitored-item restoration,
a validation failure is logged and that item is skipped without aborting
the remaining items. Request cancellation still stops the operation.
Browse and TranslateBrowsePaths isolate resolver failures per target reference: the failed reference is logged and skipped without discarding healthy siblings, including branches in a multi-element path. A failure resolving the starting node still fails that browse or path.
The stack caches results only in its existing per-operation and monitored-component caches:
virtual nodes are never inserted into PredefinedNodes.
The returned IVirtualNodeBuilder applies one callback template to every
materialized member of the family. It supports read/write/call,
condition/event, history, browser, monitored-item creation, and
monitored-item lifecycle hooks. OnCreateBrowser uses the ordinary
NodeState.CreateBrowser contract, so custom browsers still participate
in browse filtering, continuation points, and translate-path handling.
Monitored-item creation and lifecycle
OnCreateMonitoredItem runs before the default sampled item is
allocated. It can keep the default path, reject the request with an exact
status, or supply a factory for a custom
ISampledDataChangeMonitoredItem:
builder.Node("Buffers/UInt32")
.OnCreateMonitoredItem((request, ct) =>
{
if (!request.Request.RequestedParameters.Filter.IsNull)
{
return new ValueTask<MonitoredItemCreateDecision>(
MonitoredItemCreateDecision.Refuse(
StatusCodes.BadFilterNotAllowed));
}
if (!request.Request.ItemToMonitor.ParsedIndexRange.IsNull)
{
return new ValueTask<MonitoredItemCreateDecision>(
MonitoredItemCreateDecision.Refuse(
StatusCodes.BadIndexRangeInvalid));
}
return new ValueTask<MonitoredItemCreateDecision>(
MonitoredItemCreateDecision.Use(
factory => new BufferMonitoredItem(factory)));
})
.OnMonitoredItemCreated(OnCreated)
.OnMonitoredItemModified(OnModifiedAsync)
.OnMonitoringModeChanged(OnModeChangedAsync)
.OnMonitoredItemDeleted(OnDeletedAsync);
The stack allocates the id and owns registration for a custom item. It
supplies the validated filter/range, revised sampling interval and queue
size, manager handle, subscription information, and durability setting
through MonitoredItemFactoryContext. The returned item must preserve
that identity and ownership. Both built-in monitored-item managers then
handle modify, monitoring-mode, delete, and manager-lifecycle operations
normally. Use(factory, queueInitialValue: true) additionally performs
the standard initial attribute read; push-style items omit it by default.
For a source that owns every value notification, use the standard push-item factory rather than only skipping the initial read:
builder.Node("Buffers/UInt32")
.OnCreateMonitoredItem((request, ct) =>
new ValueTask<MonitoredItemCreateDecision>(
request.Request.ItemToMonitor.AttributeId == Attributes.Value
? MonitoredItemCreateDecision.Use(
factory => factory.CreatePushMonitoredItem())
: MonitoredItemCreateDecision.UseDefault()));
CreatePushMonitoredItem retains the normal queue, filtering and lifecycle, but
node-change callbacks, re-enabling and compatible lifecycle attachment do not
read the Node on its behalf. The
source supplies initial and resumed values and must enforce the subscriber's
Read permissions before queueing them. Combining this factory with
queueInitialValue: true is a configuration error: creation is rolled back and
the allocated item and queue are disposed under either built-in item manager.
Retained monitored items use OnMonitoredItemAttached and
OnMonitoredItemDetached during handoff, not the creation/deletion callbacks.
Register these hooks when maintaining source membership across compatible
ReloadAsync operations. They run before first/last-subscriber reconciliation
and also support typed-variable and virtual builders. Multiple handoff handlers
run in registration order. A graceful ShadowReloadAsync instead lets existing
subscribers drain on the old manager; it does not migrate them to the new source.
The acquisition mode is retained in MonitoredItemTypeMask.ExternalValueSource
and the existing persisted IStoredMonitoredItem.TypeMask. Stores must preserve
all type bits. Test the DataChange or Events bit rather than comparing the
entire mask for equality. After restart, the node manager must register the
source lifecycle again; the persisted bit prevents restoration from silently
switching to Node reads. Source-managed items also stop accepting values when
disposed or detached, while preserving the Core missing-Node notification.
An initial read that rejects the attribute or data encoding removes the unaccepted monitored item from registration, sampling, and any newly acquired component-cache entry. Existing items on that node remain valid. Recoverable bad data values do not prevent creating the monitored item.
AsyncCustomNodeManager acquires current values through
NodeState.ReadAttributeAsync, including initial creation, aggregate-filter
validation and current-value fallbacks, lifecycle compatibility checks,
reattachment/recovery, and re-enabling monitoring. Async OnReadValueAsync
bindings therefore supply the first sample rather than the node's stored
placeholder (OPC UA Part 4 5.13.1.3).
Provider awaits run outside the monitored-item registry semaphore; registration
and cleanup remain serialized, and the operation lifetime prevents disposal
from releasing owned resources before the read completes. Request cancellation
removes unaccepted registrations instead of publishing a cancelled read as a
successful initial sample. Override ReadInitialValueAsync for custom initial
acquisition.
Manager-level asynchronous batch hooks receive only successful items and run after the monitored-item manager has applied its changes:
builder
.OnMonitoredItemsCreated(SubscribeRegisterSlicesAsync)
.OnMonitoredItemsDeleted(UnsubscribeRegisterSlicesAsync);
The existing synchronous OnCreateMonitoredItemsComplete override remains
supported and runs before the new async create-complete hook.
Creating nodes under other managers' nodes (Objects folder)
Anything a NodeSet can declare, Configure can declare identically —
including references whose target is owned by another node manager,
such as the ns=0 Objects folder managed by the CoreNodeManager.
Write the inverse reference on your node; after the Configure
partials return, the manager re-runs its reverse-reference pass
(FluentNodeManagerBase.CompleteConfigureAsync) and publishes the
matching forward edge into the externalReferences dictionary that the
master node manager distributes once every manager's address space is
built.
Two helpers make the common placement declarative, and a manager-level
CreateInstance<TState> creates root-level (parentless) instances,
materializing the subtree from the type model and minting NodeIds
through the manager's INodeIdFactory:
partial void Configure(INodeManagerBuilder builder)
{
// Instantiate a second boiler at runtime and place it under the
// ns=0 Objects folder — no LoadPredefinedNodesAsync override, no
// manual externalReferences bookkeeping.
builder.CreateInstance(
new QualifiedName("Boiler #2", NamespaceIndexes[1]),
p => new BoilerState(p))
.Configure(n => n.UnderObjectsFolder());
// Arbitrary parents work the same way:
// n.OrganizedBy(parentNodeId)
// adds the inverse Organizes reference; the forward edge reaches
// the owning manager automatically.
}
Inverse HasNotifier references to external notifiers get root-notifier
registration through the same pass. This covers startup-time
configuration only — for nodes created after startup use
IMasterNodeManager.AddReferencesAsync, which dispatches to the live
owning manager.
Creating nodes from scratch — the Add* surface
Configure can also create nodes, not just wire callbacks on nodes a
NodeSet or ModelDesign already declared. INodeManagerBuilder carries a
small creation surface for filling a namespace when there is no model to
generate from:
| Member | Creates |
|---|---|
AddFolder(name, parentId) |
a FolderState (Organizes) |
AddObject(name, parentId, typeDefinitionId) |
a BaseObjectState |
AddVariable<TValue>(name, parentId) |
a BaseDataVariableState whose DataType/ValueRank come from TValue |
AddMethod(name, parentId) |
an executable MethodState |
Add<TState>(node, parentId) |
an already-constructed state of any NodeState subclass |
Add<TState>(factory, parentId) |
a state built by a factory that receives the resolved parent |
AddRoot<TState>(node) |
a root, with its existing references left alone |
TryGetNode(nodeId, out node) |
lookup across created-but-not-yet-registered nodes and predefined nodes |
Each Add* takes the browse name as a string — qualified with the
manager's default namespace — or as a QualifiedName carrying an
explicit nonzero namespace index. parentId defaults to the ns=0
Objects folder.
partial void Configure(INodeManagerBuilder builder)
{
INodeBuilder<FolderState> machines = builder.AddFolder("Machines");
builder.AddVariable<double>("Pressure", machines.Node.NodeId)
.OnRead(() => m_sensor.Pressure);
builder.AddMethod("Reset", machines.Node.NodeId)
.OnCall(ResetAsync);
}
Three properties make this usable straight from Configure:
- NodeIds are final before the builder comes back. Every
Add*runs the node — and its whole subtree — through the manager'sINodeIdFactorybefore returning, soOnRead/OnWriteregistrations, which key offNodeState.NodeId, stay valid once the node is registered.machines.Node.NodeIdabove is the id clients will browse. This includes a subtree materialised from a type model withNodeState.Create(…, assignNodeIds: false), whose children still carry their declaration ids: those are rebased atAddtime rather than at registration, so they too are final when the builder returns. - Creation is staged, not immediate. Created nodes are held until
the manager calls
RegisterAuthoredNodesAsync, which the generatedCreateAddressSpaceAsyncemits after theConfigurepartials and beforeCompleteConfigureAsync. That ordering lets a node name a sibling created moments earlier and gets references to externally owned nodes (theObjectsfolder above) mirrored intoexternalReferencesby the same reverse-reference pass described in Creating nodes under other managers' nodes. - Custom state types stay typed.
Add<TState>returnsINodeBuilder<TState>, so a hand-writtenNodeStatesubclass keeps its type through the fluent chain. The factory overload exists for the case where a customINodeIdFactoryderives a child id from its parent: it resolves the parent first and hands it to the factory, substituting an identity-only proxy when the parent belongs to another node manager.
Creation is rejected outside that window. Calling an Add* after the
builder is sealed, or after the staged graph has been registered, throws
BadInvalidState. A NodeId in a namespace the manager does not own
throws BadNodeIdInvalid, and a parent in one of the manager's own
namespaces that was never created throws BadNodeIdUnknown.
A hand-written manager deriving from FluentNodeManagerBase gets the
whole pipeline without writing it: the base CreateAddressSpaceAsync
loads the predefined nodes, builds a builder for the manager's
NamespaceIndex, awaits ConfigureAsync(builder, ct), registers the
authored nodes, runs CompleteConfigureAsync and seals the builder. The
manager only overrides ConfigureAsync:
public sealed class MyNodeManager : FluentNodeManagerBase
{
public MyNodeManager(IServerInternal server, ApplicationConfiguration configuration)
: base(server, configuration, "urn:my:namespace")
{
}
protected override ValueTask ConfigureAsync(
INodeManagerBuilder builder,
CancellationToken cancellationToken)
{
FolderState devices = builder.AddFolder("Devices").Node;
builder.AddVariable<int>("Speed", devices.NodeId).Writable();
return default;
}
}
Hand-written managers that need a different order (for example work
between completing and sealing, as DiNodeManager does) override
CreateAddressSpaceAsync without calling the base implementation,
load their predefined nodes through LoadPredefinedNodesAsync, drive
CreateFluentBuilder themselves and get the same surface by calling
FluentNodeManagerBase.RegisterAuthoredNodesAsync(builder) in the same
position — after the configuration delegate, before
CompleteConfigureAsync. A builder that created nothing registers
nothing, so the call is safe to make unconditionally.
Typed model-traversal — the Configure(I{Manager}NodeManagerBuilder) partial
Alongside the string/NodeId/TypeId addressing surface above, the
generator emits a second Configure partial whose builder parameter
exposes one IntelliSense-aware accessor per predefined instance, child,
variable and method in the model. Every wiring site becomes a chain of
properties — typos are compile-time errors, not startup-time
ServiceResultExceptions.
public partial class BoilerNodeManager
{
// Untyped Configure remains available for nodes outside the model
// (e.g. dynamic instances, foreign-namespace nodes, or just to keep
// hand-written wiring side-by-side with typed wiring).
partial void Configure(INodeManagerBuilder builder)
{
builder
.Node("Boilers/Boiler #1/DrumX001/LIX001/Output")
.OnRead(GenerateDrumLevel);
}
// Typed Configure: every accessor below is a generated property
// resolved against the model. The compiler enforces both the path
// shape AND the value type of every leaf.
partial void Configure(IBoilerNodeManagerBuilder builder)
{
// Variable: typed Func<double> handler — the generator removed
// the ref-Variant boilerplate.
builder.Boilers.Boiler__1.LCX001.Measurement
.OnRead(GenerateLevelMeasurement);
// Variable, async: routes through BaseVariableState.ReadAttributeAsync
// outside the lock so the lambda may freely await.
builder.Boilers.Boiler__1.PipeX002.FTX002.Output
.OnRead(GenerateOutputFlowAsync);
// Method, async: typed OnCall(Func<CancellationToken,ValueTask>)
// overload. Bind sync Action variants the same way.
builder.Boilers.Boiler__1.Simulation.Halt
.OnCall(HaltSimulationAsync);
}
}
Both partials are optional and both run; wiring the same node from both is illegal and throws at startup. Choose whichever shape best fits each call site — typed for everything declared in the model, untyped for everything else.
What the generator emits per model
For a model with N ObjectTypes and M predefined instances/children
the generator emits, into a single {Manager}.FluentBuilders.g.cs:
internal interface I{Manager}NodeManagerBuilder : INodeManagerBuilder— one accessor per top-level predefined instance.internal sealed class {Manager}NodeManagerTypedBuilder— proxy that forwardsINodeManagerBuildermembers to the runtime builder while surfacing the typed accessors.- One
internal sealed classper instance node — whose properties map to typedIVariableBuilder<TValue>, child wrapper instances, and method wrappers. - One
internal sealed classper method — exposing typedOnCall(...)overloads bound to the method's declared arguments (the generator handlesVariant.TryGetValueunpacking andVariant.From<T>boxing — see Methods with arguments). A method with inputs but no output binds toOnCall(Action<TIn…>); a method with neither inputs nor outputs keeps the argument-lessOnCall(Action)/OnCall(Func<CancellationToken, ValueTask>)overloads.
All emitted types are internal sealed because Configure is a
private partial — the surface never escapes the assembly. Child
accessors resolve namespace indices lazily through
ISystemContext.NamespaceUris.GetIndexOrAppend(...) so the wrappers
work regardless of the namespace-table order at runtime. Object wrappers
use the generated concrete *State type when the model declares one,
and manager-level extensions such as Simulation(...) work through the
typed proxy just as they do through the untyped builder.
Methods with arguments — typed OnCall overloads
When a model method declares input or output arguments the generator
emits typed OnCall overloads that bind directly to the user
handler's parameters and return value. Inputs are unboxed via
Variant.TryGetValue<T>(out T), the boxed result is written back
through Variant.From<T>(value), and BadInvalidArgument /
BadArgumentsMissing is returned when the wire shape does not match
the declared signature — none of which the user has to spell out.
Two overloads are emitted per method, shaped by the declared arguments:
- Inputs and outputs →
OnCall(Func<TIn1, …, TResult> handler)(synchronous dispatch throughMethodState.OnCallMethod2) andOnCall(Func<TIn1, …, CancellationToken, ValueTask<TResult>> handler)(async dispatch throughMethodState.OnCallMethod2Async, awaited insideAsyncCustomNodeManager.CallAsyncso the lambda may freelyawait). - Inputs but no output (a
void-returning action) →OnCall(Action<TIn1, …> handler)andOnCall(Func<TIn1, …, CancellationToken, ValueTask> handler). The inputs are still unpacked viaVariant.TryGetValue<T>, sobuilder.X.SetOutputVal.OnCall((float v) => …)binds directly to the argument. - No inputs and no output → the argument-less
OnCall(Action)/OnCall(Func<CancellationToken, ValueTask>)overloads.
Methods with multiple output arguments are bound to a ValueTuple
return — slot i is written from __r.Item{i+1}.
The declared arguments are resolved from the method itself and, when the
method carries none of its own, from its method declaration / method type.
This means instance methods imported from a NodeSet2 (whose
InputArguments/OutputArguments live on the referenced declaration) get
the same typed OnCall overloads as methods authored in a ModelDesign.
A typed {Name}MethodState class is generated only for a method type, such
as AddMethodType in the calculator sample's model. A ModelDesign method that
declares its arguments directly, without a method type, is a plain
MethodState node: its InputArguments and OutputArguments properties and
its typed OnCall overloads are generated in the same way.
[NodeManager(NamespaceUri = "http://opcfoundation.org/UA/Calc/")]
public partial class CalcNodeManager
{
partial void Configure(ICalcNodeManagerBuilder builder)
{
// Sync int+int → int. The generator unpacks each Variant
// through Variant.TryGetValue<int> and boxes the result back
// through Variant.From<int>.
builder.Calculator.Add
.OnCall((int a, int b) => a + b);
// Async double+double → double. The CancellationToken is
// forwarded by AsyncCustomNodeManager.CallAsync so the
// handler may freely await and honour cancellation.
builder.Calculator.Multiply
.OnCall(async (double x, double y, CancellationToken ct) =>
{
await Task.Yield();
ct.ThrowIfCancellationRequested();
return x * y;
});
// Sync string+string → string. Reference-typed inputs and
// return values use the same Variant.TryGetValue / Variant.From
// path; the handler can null-coalesce safely because a missing
// input is reported as BadInvalidArgument before the lambda
// ever runs.
builder.Calculator.Concat
.OnCall((string left, string right) =>
(left ?? string.Empty) + (right ?? string.Empty));
}
}
The end-to-end sample lives in
samples/MinimalApi/MinimalCalcServer/ (model in Model/Calc.xml, wiring
in CalcNodeManager.Configure.cs). The companion AOT round-trip tests
in tests/Opc.Ua.Aot.Tests/CalculatorNodeManagerAotTests.cs exercise
each shape over a real Session.CallAsync(...).
Complete Method results and diagnostic forwarding
Use OnCallWithResult when a Method handler must return operation diagnostics
and ordered input-argument results as well as output values. The ordinary
OnCall handlers remain available for methods that only need an operation result
and output values.
Configure a predefined method through the fluent node-manager builder:
builder.Node<MethodState>(methodId)
.OnCallWithResult(async (context, method, objectId, inputs, ct) =>
{
MethodInvocationResult result =
await device.InvokeAsync(objectId, inputs, ct).ConfigureAwait(false);
return result;
});
The hook also works on the IVirtualNodeBuilder returned by ResolveNodes.
Its handler is applied to each materialized method without replacing an existing
handler. Typed method builders retain their concrete INodeBuilder<TState> type
when chaining the extension. For direct configuration, assign the same delegate
to MethodState.OnCallMethodWithResultAsync.
Fluent registration rejects duplicate complete-result handlers and mixing
complete-result and ordinary handlers with BadConfigurationError, in either
registration order. A synchronous call returns BadNotSupported rather than
blocking on a complete-result handler.
MethodState.CallAsync validates executability, access, input count, and input
types before invoking the handler. The handler receives the actual receiver
objectId, supplied inputs, and the caller's cancellation token.
OperationResultmust be non-null. Its status and diagnostic text are retained.OutputArgumentsuse declaration order. A Good or Uncertain operation result must include every declared output. Bad results do not expose outputs.InputArgumentResultsmay be omitted. When present, it must include one non-nullServiceResultfor every supplied input, including Good positions. The stack does not fabricate input results omitted by the handler.- The result snapshots both argument arrays during construction.
A null callback result, an incomplete input-result array, or an incorrect output
count for a Good or Uncertain result produces BadUnexpectedError without
copying partial outputs.
Store diagnostic strings in ServiceResult, not indexes from another response's
string table. When forwarding a decoded diagnostic, construct a ServiceResult
with the source response's string table; it resolves namespace URIs, symbolic
IDs, locale, and localized text before the server encodes them into its own
response table.
Nested diagnostic text is retained even when the inner status is Good.
Encoding respects DiagnosticInfo.MaxInnerDepth, counting the outer diagnostic
as one level.
For BadInvalidArgument, AsyncCustomNodeManager and CustomNodeManager2
retain every supplied input result, including useful diagnostics on Good
positions, and preserve the original operation diagnostics. They encode input
diagnostics into the receiving response's string table only when requested.
Additional information also requires the receiving session's authorization.
The client cannot supply the internal permission flag: request admission clears
it before pre-session or session-less returns and before session validation can
fail. An admitted session grants it only after the SecurityAdmin or ConfigureAdmin
role check; pre-session and session-less requests do not inherit that privilege.
Final Call assembly retains requested operation text on Good results and
inner-status-only diagnostics even when no strings are added to the response
table. Ordinary callbacks retain their null-ServiceResult success convention;
a null complete MethodInvocationResult remains invalid.
CustomNodeManager2 subclasses must explicitly implement ICallAsyncNodeManager
to enable asynchronous dispatch through AsyncNodeManagerAdapter. Without that
opt-in, a complete-result handler returns BadNotSupported on the legacy
synchronous path; the adapter never blocks on the asynchronous handler.
These contracts complement the OPC UA Part 4 Call service's ordered argument results and diagnostics; they do not add remote execution or change node ownership.
Event sources — typed Publish<TEvent> on notifier wrappers
Beyond reads, writes and method calls, the fluent API lets callers
register an IAsyncEnumerable<TEvent> against any notifier object so
events flow into the standard NodeState.ReportEvent path
automatically. The runtime owns the entire lifecycle: it starts the
iterator the first time a client subscribes to events on the notifier
(or any ancestor that walks via inverse HasNotifier /
HasEventSource references), cancels it when the last interested
monitored item disappears, and disposes it on manager teardown.
Generated managers derive from Opc.Ua.Server.Fluent.FluentNodeManagerBase
out of the box, so wiring is one call:
partial void Configure(IBoilerNodeManagerBuilder builder)
{
// The DrumX001 wrapper exposes Publish<TEvent> because the model
// declares EventNotifier=SubscribeToEvents on the node. Lazy by
// default — the iterator only runs while a client is monitoring.
builder.Boilers.Boiler__1.DrumX001
.Publish<BaseEventState>(GenerateDrumHeartbeatAsync);
}
private async IAsyncEnumerable<BaseEventState> GenerateDrumHeartbeatAsync(
BaseObjectState notifier,
ISystemContext context,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) { yield break; }
var ev = new BaseEventState(parent: notifier);
ev.Severity = PropertyState<ushort>.With<VariantBuilder>(
ev, (ushort)EventSeverity.Medium);
ev.Message = PropertyState<LocalizedText>.With<VariantBuilder>(
ev, new LocalizedText("Drum heartbeat"));
yield return ev;
}
}
The runtime auto-populates EventId, EventType, SourceNode,
SourceName (browse name of the notifier), Time, ReceiveTime,
Severity (Medium when 0) and Message (empty LocalizedText when
unset) on the way out, so the iterator only sets the user-meaningful
fields.
Where the typed overload appears
The generator emits Publish<TEvent> on a wrapper only when the
underlying node qualifies as an event source:
ObjectDesign.SupportsEvents == true(i.e. the model declaresEventNotifier=SubscribeToEvents,HasNotifier, orHasEventSource), or- The node has a forward
GeneratesEvent/AlwaysGeneratesEventreference.
TEvent is constrained to BaseEventState — pass any subtype that
fits the model's event hierarchy. For nodes outside the model, or
hand-written managers, the same Publish<TNotifier, TEvent> extension
is available directly on INodeBuilder<TNotifier> where
TNotifier : BaseObjectState.
Two registration shapes
// Direct stream — registry uses the same instance for every activation.
builder.Boilers.Boiler__1.DrumX001
.Publish<BaseEventState>(channel.Reader.ReadAllAsync(default));
// Factory — registry calls the factory each time a client subscribes,
// so the iterator can capture the live notifier / context / token.
builder.Boilers.Boiler__1.DrumX001
.Publish<BaseEventState>(
(notifier, context, ct) => GenerateAsync(notifier, context, ct));
Tuning lifecycle with EventPublishOptions
builder.Boilers.Boiler__1.DrumX001
.Publish<BaseEventState>(GenerateDrumHeartbeatAsync,
new EventPublishOptions
{
// Keep iterator running even with no monitored items.
AlwaysOn = false,
// Skip default population of EventId / EventType / Time /
// ReceiveTime / SourceNode / SourceName / Severity / Message.
SkipDefaultPopulation = false,
// Register the notifier as a server-wide root notifier so
// clients can monitor events on the Server object itself.
RegisterAsRootNotifier = true,
// Bound how long the registry waits for the iterator to
// honour cancellation on deactivation.
CancellationTimeout = TimeSpan.FromSeconds(5),
// Optional fault-handler invoked when the iterator throws.
OnError = (notifier, exception, context) => { /* log */ }
});
Hand-written node managers
The Server Object (i=2253) is the aggregate event subscription point
(OPC UA Part 5 8.3.2).
Server-wide fan-out treats an individual root or manager's BadNotSupported as
non-participation, not as a failure of every other source. Root registration
does not silently set EventNotifier.SubscribeToEvents; declare the capability
on actual notifiers as required by Part 3
7.18.
A direct subscription to an unsupported root still fails with BadNotSupported.
Legacy null success results are normalized to Good before per-root or
per-manager status inspection.
Other startup errors fail the item and roll back attempted registrations.
Unsubscription continues across independent roots and managers while reporting
genuine cleanup errors; unsupported participants do not turn successful deletion
into an error.
An event stream that connects an asynchronous upstream producer can also
implement IEventSourceReadiness. The registry enumerates the stream while
awaiting WaitUntilReadyAsync; creation of a corresponding event monitored
item completes only after the producer can deliver notifications. Readiness
must not wait for the first event. Failures are returned to the subscribing
client, reported through OnError, and stop that activation. For reactivatable
producers, return a new readiness-aware stream from the Publish factory on
each activation.
Lifecycle attachment, recovery and compensating reattachment await the same readiness contract without holding admission for unrelated monitored-item services. Callback failure compensates only that operation's binding and notifier count, preserving a newer binding. Cleanup remains available after request cancellation, and compensation errors are reported with the original failure. Compensation uses a fresh five-second deadline on the server's injected clock, even when ordinary source readiness has an infinite timeout.
Readiness belongs to each activation: completion of the previous iterator's drain does not make its replacement ready. A producer's readiness signal is independent of iterator entry and the first event.
Failed factories, iterators, and readiness checks are reported to the caller
and OnError. While a source is still wanted, retries use an exponential delay
from one second up to thirty seconds rather than an immediate restart loop.
Only the current activation can request a retry; a late failure from a cancelled
activation cannot retire its replacement. A failed event subscription rolls back
its monitored-node registration and notifier count before returning the error.
Managers that don't use the source generator can opt in by deriving
from Opc.Ua.Server.Fluent.FluentNodeManagerBase and calling
AttachToBuilder(builder) from inside their address-space-build
callback. Once attached, all Publish extensions resolve against the
manager's registry exactly as for generated managers.
The end-to-end sample lives in
samples/MinimalApi/MinimalBoilerServer/BoilerNodeManager.Configure.cs
(wiring GenerateDrumHeartbeatAsync on the drum). The companion AOT
round-trip test in
tests/Opc.Ua.Aot.Tests/PublishedEventsAotTests.cs subscribes a
real client MonitoredItem with an EventFilter and asserts the
heartbeats arrive end-to-end under NativeAOT constraints (no JIT, no
reflection).
Multi-namespace and manager-swap subclassing
Because the generated factory members are virtual, you can extend
without forking:
public sealed class MyExtendedFactory : MyModel.MyModelNodeManagerFactory
{
public override ArrayOf<string> NamespacesUris
{
get
{
var ns = base.NamespacesUris;
ns.Add("urn:my:second:namespace");
return ns;
}
}
public override ValueTask<IAsyncNodeManager> CreateAsync(
IServerInternal server,
ApplicationConfiguration cfg,
CancellationToken cancellationToken = default)
=> new(new MyExtendedNodeManager(server, cfg));
}
The tests/Opc.Ua.Server.Tests/Fluent/GeneratedManagerHybridTests.cs
suite verifies these subclassing scenarios.
NativeAOT publishing
The project that hosts the generated manager only needs the standard AOT settings:
<PropertyGroup>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
Use Microsoft.Extensions.Logging.Console for AOT-friendly logging
(Serilog providers vary in AOT compatibility). Validate with:
dotnet publish -c Release -r win-x64
samples/MinimalApi/MinimalBoilerServer publishes as a Native AOT
self-contained executable. Check diagnostics and executable size for your target runtime.
Runtime NodeSet alternative
When you want to host a NodeSet2 document without any source generation — for example a companion-spec XML received from a vendor, or a model that changes more frequently than you rebuild — use AddRuntimeNodeSet instead. The runtime path loads a file or stream, imports nodes in topological dependency order, and exposes them through the same untyped INodeManagerBuilder surface as the Configure partial above. Use AddRuntimeNodeSet for startup registration or INodeManagerLifecycle to add, reload, and remove a model while the server runs. See RuntimeNodeSets.md for a side-by-side comparison of the two paths.
Building richer node managers — the fluent extension surface
The Configure callback wires read/write/method/event hooks against
already-loaded predefined nodes, but real-world servers also need to
materialise dynamic instances, attach engineering units to measurements,
build alarms, run simulation loops, populate identification properties,
and compose multiple companion-spec models into a single address space.
The extensions below cover those workflows. All are AOT/trim safe and
follow the same return-the-same-builder chaining contract as the core
INodeBuilder API.
Engineering units & EU range
IVariableBuilder<TValue>.WithEngineeringUnits and .WithEURange
attach the standard EngineeringUnits and EURange property children
on a BaseAnalogState variable. The extensions create the property
child on demand (matching the runtime's AddEngineeringUnits /
AddEURange helpers) and then set the Value attribute.
builder.Variable<double>("Pumps/Pump_1/Operational/Measurements/FluidTemperature")
.OnRead(SimulateTemperature)
.WithEngineeringUnits(
new EUInformation("K", "Kelvin",
"http://www.opcfoundation.org/UA/units/un/cefact"))
.WithEURange(min: 263.15, max: 393.15);
// Convenience: set both at once.
builder.Variable<double>("Pumps/Pump_1/Operational/Measurements/DifferentialPressure")
.OnRead(SimulatePressure)
.WithUnits(EUInformations.Pascal, min: 0, max: 400_000);
Fail-fast behaviour: calling these on a non-BaseAnalogState variable
throws ServiceResultException with
StatusCodes.BadTypeMismatch — analog-only properties don't apply to
plain BaseDataVariableState nodes.
Bulk property initialisation
INodeBuilder.WithProperty writes the Value attribute of a property
child, creating the property first when it does not already exist.
Typed overloads exist for every built-in OPC UA scalar (string,
int, uint, double, bool, DateTimeUtc, NodeId,
LocalizedText, QualifiedName, etc.) plus a generic Variant escape
hatch.
builder.Node("Pumps/Pump_1/Identification")
.WithProperty("Manufacturer", new LocalizedText("SimPump Corp"))
.WithProperty("Model", new LocalizedText("PumpX-2000"))
.WithProperty("SerialNumber", "SN-001")
.WithProperty("DeviceClass", "Pump")
.WithProperty("ProductInstanceUri",
"urn:simdevice:SimPump:PumpX-2000:SN-001");
Pass the CLR type the model declares for the property — LocalizedText
for Manufacturer / Model / ComponentName, ushort for
YearOfConstruction, byte for MonthOfConstruction, and so on. The
typed overloads make the choice explicit at the call site.
Reference resolution is by browse-name only (case-sensitive,
namespace-agnostic), matching the AOT-safe constraint of the rest of
the fluent surface. When the child exists it is updated; when it exists
but isn't a variable the call throws BadTypeMismatch.
When the child is missing, WithProperty materialises a new
read-only PropertyState (data type inferred from the value) under the
current node and registers it with the owning node manager. This makes
the helper usable on freshly built nodes such as custom DI functional
groups — not just on properties that come from a loaded model:
// "Diagnostics" is a custom functional group with no model-defined
// properties; WithProperty creates each one on the fly.
node.WithProperty("LastError", string.Empty)
.WithProperty("ErrorCount", 0)
.WithProperty("LastSelfTest", (DateTimeUtc)DateTime.UtcNow);
Auto-created properties are read-only by default. Grant write access
with the fluent Writable() helper — either standalone on a resolved
variable, or inline via the WithProperty(name, value, configure)
overload that positions a builder on the new property:
node.WithProperty("LastError", Variant.From(string.Empty), p => p.Writable())
.WithProperty("ErrorCount", 0);
// or, on an existing variable:
builder.Node("Pumps/Pump #1/Operational/SetPoint").Writable();
References & dynamic child objects
INodeBuilder.Organizes, .HasComponent, .HasProperty and the
generic .AddReference(typeId, isInverse, target) add forward /
inverse references on the current node. They're the foundation for
OPC UA Device Integration (DI)'s FunctionalGroup pattern — group
unrelated variables under a shared object via Organizes.
// Wire existing measurement variables into a custom FunctionalGroup.
builder.Node("Pumps/Pump #1/Operational/MyGroup")
.Organizes(temperatureNodeId)
.Organizes(pressureNodeId)
.HasProperty(metadataNodeId);
INodeBuilder.OrganizedBy(parentId) and .UnderObjectsFolder() add
the inverse Organizes reference, placing the current node below an
organizing parent. The parent may be owned by another node manager
(e.g. the ns=0 Objects folder) — the forward edge is published
automatically when Configure completes; see
Creating nodes under other managers' nodes.
INodeBuilder.AddObject(browseName, typeDefinitionId) synthesises a
new BaseObjectState child under the current node and returns a typed
builder for the new object. NodeIds follow the
owning NodeManager's assignment policy. See NodeId assignment for the available
modes and custom-factory behavior. The helper registers the created node with the owning
AsyncCustomNodeManager, so the object is immediately browseable and
addressable by NodeId.
// Create a custom FunctionalGroup, then attach measurements.
builder.Node("Pumps/Pump #1")
.AddObject(new QualifiedName("CustomMetrics", pumpsNs))
.Organizes(t1).Organizes(t2);
Newly created objects are reachable through navigation from the parent
and through direct NodeId lookup immediately. Callers do not need to
index nodes created by AddObject themselves.
Creating instances of model types
INodeBuilder.CreateInstance<TState>(name, factory) materialises a
new BaseInstanceState subtype using a user-supplied factory delegate
— typically a generated Create<TypeName> method from the source
generator output. The returned IInstanceBuilder<TState> exposes
.Configure(builder => …) for inline child wiring, .AsNode() for a
typed INodeBuilder<TState> view, and .Done() to return to the
parent builder.
builder.Node("Pumps")
.CreateInstance(
new QualifiedName("Pump #2", pumpsNs),
pumpTypeId,
parent => context.CreatePumpType(parent))
.Configure(p2 =>
p2.AsNode()
.WithProperty("Manufacturer", "Vendor B")
.WithProperty("SerialNumber", "SN-002"));
The factory pattern keeps the API reflection-free and AOT safe — the
generator already emits the per-type Create<Type> extension methods
that the factory delegate calls into.
Like AddObject, CreateInstance<TState> registers the materialised
subtree with the owning node manager. The same registration behaviour
is used by the fluent state-machine creators, so generated instances
created from a builder can be browsed, read, and monitored without a
separate AddPredefinedNodeAsync call.
For root-level instances (no parent node in this manager),
INodeManagerBuilder.CreateInstance<TState>(name, factory) accepts a
plain constructor-style factory (p => new BoilerState(p), invoked
with a null parent), fully materialises the instance from its type
model, and mints per-instance NodeIds for the whole subtree through
the manager's INodeIdFactory. Combine with .UnderObjectsFolder()
(or .OrganizedBy(parentId)) to place the instance below a node owned
by another manager:
builder.CreateInstance(
new QualifiedName("Boiler #2", NamespaceIndexes[1]),
p => new BoilerState(p))
.Configure(n => n.UnderObjectsFolder());
Alarm setup (MVP)
INodeBuilder.CreateLimitAlarm, .CreateExclusiveLimitAlarm and
.CreateOffNormalAlarm attach a fresh alarm condition under the
current node and return an IAlarmBuilder<TState> for further
configuration. The helpers register the condition, add the
HasCondition reference, initialise SourceNode, SourceName, and
ConditionName from the source object, and promote the source object and its
ancestors with EventNotifiers.SubscribeToEvents. The source is also
registered as a root notifier so clients subscribing to the Server
object receive condition events. .MonitorVariable(variable) sets the
alarm's SourceNode, SourceName and InputNode to the variable and makes
it the condition source: the variable gets a HasCondition reference to the
alarm and the object gets a HasEventSource reference to the variable:
builder.Node("Pumps/Pump #1/Events")
.CreateLimitAlarm(new QualifiedName("OverTempAlarm", pumpsNs))
.WithLimits(highHigh: 380, high: 370, low: 273, lowLow: 263)
.MonitorVariable(temperatureNode)
.OnAcknowledge((ctx, condition, eventId, comment) => ServiceResult.Good)
.OnConfirm((ctx, condition, eventId, comment) => ServiceResult.Good);
For full state access (severity tables, retain flag, branches), use
the .ConfigureAlarm(Action<TState>) escape hatch:
builder.Node("Events")
.CreateLimitAlarm(new QualifiedName("Custom", ns))
.WithLimits(high: 100)
.ConfigureAlarm(alarm =>
{
alarm.Retain!.Value = true;
// any state-class mutation goes here
});
For any other condition type, such as an alarm type of a companion
specification or a server-specific subtype, .CreateAlarm<TState> takes a
factory for the uninitialized state. The alarm is attached, enabled and
released on teardown exactly like the alarms of the dedicated helpers:
builder.Node("Pumps/Pump #1/Events")
.CreateAlarm(
new QualifiedName("ValveDiscrepancy", pumpsNs),
parent => new DiscrepancyAlarmState(parent))
.OnAcknowledge((ctx, condition, eventId, comment) => ServiceResult.Good);
Boolean supervision → alarm activation (NAMUR pattern)
IVariableBuilder<bool>.OnRisingEdge / .OnFallingEdge register
callbacks that fire when the variable's value transitions. The
.ActivatesAlarm(alarmBuilder) extension wires the bool variable to
an AlarmConditionState's ActiveState so it flips in lockstep with
the supervision flag, updates Retain, adjusts severity, and reports a
condition event — exactly the OPC UA DI / NAMUR NE 107 pattern.
IAlarmBuilder<NonExclusiveLimitAlarmState> cavitationAlarm =
builder.Node("Events").CreateLimitAlarm(name)
.ConfigureAlarm(a => a.Severity!.Value = (ushort)EventSeverity.Medium);
builder.Variable<bool>("Pump #1/Events/Supervision/ProcessFluid/Cavitation")
.ActivatesAlarm(cavitationAlarm);
Detection is value-change based: transitions only fire when something
else (an OnWrite handler, a simulation tick, a client write) actually
mutates the variable.
Simulation timers
INodeManagerBuilder.Simulation(interval).OnTick(...) registers a
periodic background loop owned by the FluentNodeManagerBase. Each
tick fires on a PeriodicTimer and is cancelled when the manager is
disposed; exceptions inside handlers are logged and do not kill the
loop.
partial void Configure(INodeManagerBuilder builder)
{
builder.Simulation(TimeSpan.FromMilliseconds(250))
.OnTick((ctx, elapsed) =>
{
m_temperature = 313.15 + 5 * Math.Sin(m_t * 0.01);
m_pressure = 200000 + 50000 * Math.Sin(m_t * 0.03);
m_t++;
});
}
Async tick handlers receive a CancellationToken honouring manager
disposal — use it for any awaitable work inside the loop. Multiple
.OnTick calls on the same Simulation() builder all fire on every
tick.
The simulation registry requires the manager to derive from
FluentNodeManagerBase (the source generator-emitted manager already
does); calling .Simulation() on a plain CustomNodeManager2 throws
StatusCodes.BadConfigurationError.
Pushing runtime value changes to subscribers
OnRead getters are invoked on the Attribute (Read) service, but a
value that only lives behind a getter — or in a backing field mutated by
an OnCall handler — will not reach subscribed MonitoredItems on its
own. In previous implementations the fix was to mutate Node.Value and call
Node.ClearChangeMasks(...), but that node handle is deliberately unavailable
through the fluent surface once Configure returns (the builder is sealed).
Two fluent mechanisms close that gap.
1. Bind(out IValueUpdater<TValue>) — explicit push. Capture a runtime
handle during Configure and store it on the manager; the handle survives
sealing. SetValue assigns the value, timestamp and status and flushes the
change mask in one serialized call, so both reads and subscriptions see
the update:
private IValueUpdater<float> m_ao01 = null!;
partial void Configure(IMyNodeManagerBuilder builder)
{
builder.MyEquipment03.AO01.Builder.AsVariable<float>()
.Bind(out m_ao01);
builder.MyEquipment03.SetOutputVal
.OnCall((float value) => m_ao01.SetValue(value));
}
IValueUpdater<TValue> also exposes SetValue(value, statusCode),
SetValue(value, statusCode, sourceTimestamp), and NotifyChange() (flush
a notification after an in-place mutation without changing the value).
2. PollEvery(interval, getter) — opt-in auto-sampling. Register a
periodic loop that reads the getter and pushes a change only when the value
actually differs, so subscriptions update automatically with no
change-notification code. An initial sample is applied immediately:
builder.MyEquipment03.AO01
.PollEvery(TimeSpan.FromMilliseconds(250), () => m_ao01Value);
Like Simulation, PollEvery reuses the manager-owned loop
infrastructure and therefore requires the manager to derive from
FluentNodeManagerBase; calling it on a plain CustomNodeManager2 throws
StatusCodes.BadConfigurationError.
Subscription-gated sources
Use PollWhileMonitored when sampling an external source should consume
resources only while a client is interested. A disabled monitored item
does not keep the source active; Sampling and Reporting items do:
builder.Variable<double>("Dynamic/Temperature")
.OnFirstSubscriber((context, node, ct) =>
m_device.StartMonitoringAsync(node.NodeId, ct))
.OnLastSubscriber((context, node, ct) =>
m_device.StopMonitoringAsync(node.NodeId, ct))
.PollWhileMonitored(
TimeSpan.FromMilliseconds(100),
(context, ct) => m_device.ReadTemperatureAsync(ct));
The zero-to-one transition invokes OnFirstSubscriber, samples
immediately, and starts the worker. The one-to-zero transition cancels
the worker and invokes OnLastSubscriber. While active, the effective
period is the fastest revised sampling interval among active items,
bounded by the minimum period passed to PollWhileMonitored. Create,
modify, mode-change, and delete operations reconcile that period without
overlapping samples. The worker uses the server TimeProvider, pushes
only changed values through IValueUpdater<TValue>, and is cancelled when
the manager is disposed.
The same OnFirstSubscriber, OnLastSubscriber, and
PollWhileMonitored extensions are available on an
IVirtualNodeBuilder; the current materialized node is retained only for
the monitored-item lifetime.
The lifecycle overloads accepting an attribute id restrict the count to that attribute. For a value-only source, use the same filter on both callbacks:
builder.Variable<double>("Dynamic/Temperature")
.OnFirstSubscriber(Attributes.Value, (context, node, ct) =>
m_device.StartMonitoringAsync(node.NodeId, ct))
.OnLastSubscriber(Attributes.Value, (context, node, ct) =>
m_device.StopMonitoringAsync(node.NodeId, ct));
A DisplayName subscriber then neither starts that source nor keeps it alive after the last Value subscriber leaves. The filter also applies to virtual instances. Unknown attribute ids or different filters on the first and last callbacks are configuration errors. The original overloads continue to count all data-change attributes.
Multi-model composition
The only supported mode for combining models is source-generated
library references. Each companion spec is built once into its
own model library (a src/Opc.Ua.{Spec}/ project that
consumes the ModelDesign XML and emits an AddOpcUa{Spec}
extension method); the consumer adds project references and calls
the generated extensions directly in dependency order:
protected override ValueTask<NodeStateCollection> LoadPredefinedNodesAsync(
ISystemContext context, CancellationToken ct = default)
{
var nodes = new NodeStateCollection();
nodes.AddOpcUaDi(context);
nodes.AddOpcUaMachinery(context);
nodes.AddOpcUaPumps(context);
return new ValueTask<NodeStateCollection>(nodes);
}
Source-generated models are AOT-friendly, deterministic, and
produce typed *State / *Client proxies. Every application-
owned model must ship as source-generated content — companion
specs ship as project references; locally-owned NodeSet2 XMLs are
wired through <AdditionalFiles> so the source generator emits
the same typed surface inside the consuming assembly. Each
AddOpcUa{Spec}(context) extension is idempotent and re-entrant,
so direct chaining in dependency order is the recommended pattern.
Mixing ModelDesign and NodeSet2 in one project
A ModelDesign XML and a NodeSet2 XML can be combined in the same
project, and a node in one may reference a type defined in the other.
A common split is to author the reusable object types as a
NodeSet2 (e.g. exported from a modelling tool such as SiOME) and the
concrete instances as a ModelDesign whose TypeDefinition
points at those NodeSet2 types:
<!-- Instances.ModelDesign.xml -->
<opc:ModelDesign
xmlns:opc="http://opcfoundation.org/UA/ModelDesign.xsd"
xmlns:et="http://example.org/EquipmentTypes"
xmlns="http://example.org/EquipmentInstances"
TargetNamespace="http://example.org/EquipmentInstances">
<opc:Namespaces>
<opc:Namespace Name="EquipmentInstances"
>http://example.org/EquipmentInstances</opc:Namespace>
<!-- Bind the same URI to the "et" XML prefix used below. -->
<opc:Namespace Name="EquipmentTypes" XmlPrefix="et"
>http://example.org/EquipmentTypes</opc:Namespace>
</opc:Namespaces>
<!-- "et:" resolves to the NodeSet2 namespace declared via xmlns:et. -->
<opc:Object SymbolicName="Equipment01" TypeDefinition="et:SimpleEquipmentType" />
</opc:ModelDesign>
<Project>
<ItemGroup>
<AdditionalFiles Include="Model\EquipmentTypes.NodeSet2.xml">
<ModelSourceGeneratorModelUri>http://example.org/EquipmentTypes</ModelSourceGeneratorModelUri>
</AdditionalFiles>
<!-- A ModelDesign declares its own model URI (TargetNamespace). -->
<AdditionalFiles Include="Model\Instances.ModelDesign.xml" />
</ItemGroup>
</Project>
The generator resolves the cross-model reference automatically — every
input is supplied to the others as a resolution dependency (both
ModelDesign → NodeSet2 and ModelDesign → ModelDesign).
Binding a
[NodeManager]in a mixed project. A[NodeManager]may target the namespace of either input — the NodeSet2 type model or the ModelDesign instance model — by setting itsNamespaceUrito that model's URI. Binding is resolved across both the NodeSet2 and the ModelDesign generation passes, so a manager bound to the NodeSet2 types is not reported as unmatched (MODELGEN010) just because the project also contains a ModelDesign — and vice-versa. The generated node-manager class name and namespace come from the annotated partial class itself, not fromModelSourceGeneratorPrefix/Name(those control the generated*State/type class names — see the note below).
C# namespace of a NodeSet2 model. The generated C# namespace for a NodeSet2 input is derived from its
ModelUriunless you setModelSourceGeneratorPrefix(C# namespace / prefix) andModelSourceGeneratorName(theNamespacesclass identifier) on thatAdditionalFilesentry. APrefix/Namedeclared inside a referencing ModelDesign's<opc:Namespaces>does not rename the NodeSet2's generated types — set the per-file MSBuild metadata on the NodeSet2 entry to control it.
NodeSet2 ParentNodeId identifies a node's ownership relationship, not every
hierarchical reference to it. The generator preserves additional hierarchical
links, such as HasComponent, HasOrderedComponent, or HasAddIn to a node
owned by another parent, without moving that node. Only the exact reference
represented by the parent/child relationship is implicit. Address-space
registration completes reverse references, and generation deduplicates links
by their resolved reference type, direction, and target NodeId. Rebuild the
consuming project to regenerate its address-space code.
Importing a NodeSet2 overlay at runtime — builder.Import
The models above are compiled into the assembly. A NodeSet2 document
that is only known at runtime — an overlay shipped by an integrator, a
document downloaded from a device — can be imported into the same
manager from inside Configure:
partial void Configure(INodeManagerBuilder builder)
{
using var stream = File.OpenRead("overlay.NodeSet2.xml");
builder.Import(UANodeSet.Read(stream));
// Imported nodes resolve immediately, so they can be wired in the
// same pass.
builder.Node(new NodeId(3300u, NamespaceIndex))
.As<BaseDataVariableState>()
.OnSimpleRead(ReadOverlayValue);
}
What the import guarantees:
- One batch per
Configurepass. Every document imported during one pass is linked exactly once, after the pass returns. A node may therefore declare aParentNodeIdthat lives in another document of the same batch, or a node the manager already owns — which is how an overlay extends the generated model. - Typed states without reflection. The generated node manager
implements
INodeSetImportFactoryProvider. Its factories are matched by TypeDefinition (Object, Variable), by MethodDeclaration (Method) and by NodeId (declarations), so an imported node of a generated type materialises as its generated*Statesubclass. Each factory calls a concrete constructor — nothing is looked up at runtime, which keeps the path NativeAOT-safe. Pass anINodeSetImportFactoryProvidertoImportto supply factories from somewhere else, or implementpartial void AddNodeSetImportFactories(List<INodeSetImportFactory>)on the manager to add the factories of a dependency model. - Empty typed states. A factory returns a state without children: the document, not the model, decides which children the imported node has.
- Placeholder replacement. When an imported child lands in a slot
that the generated parent declares (same BrowseName), it replaces the
generated placeholder, and the displaced node and every descendant it
does not carry over are removed from the address space. References to
the displaced node are retargeted at the replacement. Wiring a node in
Configurebefore an import displaces it is rejected withBadInvalidStaterather than silently dropping the wiring — import first, then wire.
The three contracts (INodeSetImportFactoryProvider,
INodeSetImportFactory, NodeSetImportDiscriminator) live in
Opc.Ua.Server.Nodes. For a manager built entirely from NodeSet2
documents with no compiled model at all, see
Runtime NodeSets.
NodeSet2 access-level bitmasks
NodeSet2 imports preserve the verbatim AccessLevel bitmask. This
matters for values such as AccessLevel="5" (CurrentRead | HistoryRead): the legacy ModelDesign enum can describe the individual
named values but is not a [Flags] enum. The importer stores the raw
mask on VariableDesign.RawAccessLevel, and code generation emits the
corresponding Opc.Ua.AccessLevels constants instead of collapsing the
value to Read. UserAccessLevel intentionally mirrors
AccessLevel, matching the runtime NodeSet2 importer.
Materialising instances at runtime — NodeId assignment
Every model gets three families of instance helpers. They differ only in who owns the NodeIds of the nodes they produce:
| Helper | Produces | NodeIds |
|---|---|---|
context.CreateInstanceOf<Type>(parent, browseName) |
A full typed subtree | Rebased onto per-instance NodeIds minted by ISystemContext.NodeIdFactory whenever a browseName is supplied |
owner.Add<Child>(context, nodeId = default) |
One optional child (+ its declared subtree) | Per-instance NodeIds, or the explicit nodeId you pass |
owner.CreateOrReplace<Child>(context, replacement) |
One child slot — also the plumbing behind NodeState.CreateChild / ReplaceChild |
Per-instance NodeIds for a child that carries no NodeId yet or still carries the type-level one |
// Two instances of the same type never collide: the factory rebases the
// mandatory children, and every subsequent child materialisation mints
// its own NodeId from the parent chain.
PumpState pump = SystemContext.CreateInstanceOfPumpType(deviceSet, pumpBrowseName);
deviceSet.AddChild(pump);
pump.AddOperational(SystemContext); // optional child
pump.CreateChild(SystemContext, someBrowseName); // CreateOrReplace<Child>
await AddPredefinedNodeAsync(SystemContext, pump, cancellationToken);
The generated factory intentionally returns a graph whose create lifecycle
has not completed. Node manager registration completes
OnBeforeCreate/OnAfterCreate and clears change masks before the graph is
indexed. Asynchronous predefined-node registration repairs typed instance
subtrees which still carry null, foreign-namespace, or
type-declaration-colliding NodeIds first, so lifecycle callbacks see the
identifiers which enter the address space. NodeIds explicitly assigned in a
namespace owned by the manager are preserved. Synchronous registration keeps
the caller's identifiers; fluent helpers which materialise typed subtrees,
such as the state-machine creators, assign their instance child NodeIds before
registration. The behavior hook then receives a created node.
NodeState.IsCreated reports whether an individual node has completed that
lifecycle.
Most callers should configure the graph and then register it as shown above.
If code must read state established by OnAfterCreate, or write state or
handlers which an OnAfterCreate override would replace, call
CreateAsPredefinedNode after assembling the subtree and before that
ordering-sensitive code. The call is idempotent per node and still completes
children added since an earlier call. It does not re-run OnAfterCreate on
ancestors which were already created; assemble parent-wired children before
the parent's first completion, or wire those late children explicitly.
Explicit references to MandatoryPlaceholder or OptionalPlaceholder
declarations remain on generated type templates, not runtime instances.
Instance factories omit these references whether the model resolves their
targets by identifier or by a hierarchy path, including direct children of
object and variable types. Ordinary references are preserved in both contexts.
Notes:
- An explicit
browseNamemarks a dynamically materialised instance.CreateInstanceOf<Type>()without one (as used by the generatedNodeStateActivators and when replacing a well-known singleton) keeps the declaration NodeIds. - A NodeId you assigned is never overwritten — pass a fully
configured child as the
replacement, or useAdd<Child>(context, nodeId), to keep control. - The generated type factories opt out through
assignInstanceNodeIds: false: they build declaration subtrees whose NodeIdsCreateInstanceOf<Type>rebases in a single pass afterwards. The same parameter is available to you if you need that behaviour. CreateInstanceOf<Type>assigns identity but does not callCreateAsPredefinedNode.AddPredefinedNodeAsync,AddNodeAsync, and the synchronous predefined-node registration paths complete the lifecycle automatically.- Assignment only happens when the context carries an
ISystemContext.NodeIdFactory.AsyncCustomNodeManagersupplies one that allocates null IDs in the manager's default namespace. Registration selectively retries descendants when an asynchronously registered, manager-owned instance subtree still carries foreign declaration IDs, while preserving explicitly assigned IDs in an owned namespace and well-known namespace-zero roots. Synchronous creators must assign typed child IDs before registration. OverrideNewonly when the address space needs a different stable naming strategy, such as deriving IDs from the parent chain. - A node copy never assigns.
NodeState.Create(context, source)initialises each child from its source right after creating it, which overwrites any NodeId minted along the way — so minting one would only consume identifiers, and leak them for factories that track outstanding allocations. The copy therefore callsCreateChild(context, browseName, assignInstanceNodeIds: false).
Custom node types and assignment control
NodeState.FindChild and NodeState.CreateChild carry
assignInstanceNodeIds as their last parameter. It defaults to true, so
callers that state no intent keep materialising children with per-instance
NodeIds. A type that declares children overrides FindChild, resolves the
ones it declares, and passes the request on — both to its
CreateOrReplace<Child> helpers and to the base:
protected override BaseInstanceState? FindChild(
ISystemContext context,
QualifiedName browseName,
bool createOrReplace,
BaseInstanceState? replacement,
bool assignInstanceNodeIds = true)
{
if (browseName.Name == BrowseNames.EnumStrings)
{
return !createOrReplace
? EnumStrings
: CreateOrReplaceEnumStrings(context, replacement, assignInstanceNodeIds);
}
return base.FindChild(
context, browseName, createOrReplace, replacement, assignInstanceNodeIds);
}
Source generated types emit exactly this shape. Because the request is an
argument, every type — generated or hand-written — sees the real
ISystemContext during a copy; nothing wraps the context to hide the
NodeIdFactory.
NodeState.FindChild takes the request as an argument and CreateChild takes
the state NodeId; migrating an override written against 1.5.378 is covered by the
migration guide.
Current limitations
- Browse-path wildcards (
*,**) are not supported. Wire each path explicitly or resolve by NodeId / TypeDefinitionId. - Historical access advertisement. Servers reconcile
HistorizingandHistoryRead/HistoryWriteaccess-level bits at startup. Variables that do not resolve to anIHistorianProviderhave those bits masked before the server accepts clients; variables wired withHistorize()or another historian keep their history surface. See Server address-space metadata and Historical Access. - Reserved child names. A component/property whose BrowseName
matches a built-in
NodeStateattribute member (for exampleDescriptionorDisplayName) shadows that member on the generated*Stateclass and produces code that does not compile. Rename such children (the OPC UADescription/DisplayNameattributes are always available without a dedicated child).
Sample
samples/MinimalApi/MinimalBoilerServer/— a fully self-contained, NativeAOT single-file Boiler server. Read it top-to-bottom in under 200 lines.samples/MinimalApi/MinimalCalcServer/— a calculator server that exercises the typed methods-with-arguments OnCall overloads end-to-end (syncint+int → int, asyncdouble+double → double, syncstring+string → string).samples/Di/PumpDeviceIntegrationServer/— the full OPC 40223 Pumps companion server. Exercises every fluent extension above (engineering units, identification properties, FunctionalGroup wiring, instance creation, limit alarm with NAMUR-style boolean supervision, periodic simulation tick, and multi-model loader for DI + Machinery + Pumps), and additionally attaches the OPC 10000-100 software-update facet to a second declarative pump device.