## Summary
### Documentation
- Stage the documentation index as a learning path: a first application,
independent client, server, and PubSub paths, then data and services,
companion models, production, advanced integration, and draft or
experimental capabilities.
- Add Getting started (repository samples or NuGet packages), OPC UA
concepts, Your first information model, and a production readiness
checklist.
- Add Native AOT application publishing, a first PubSub publisher and
subscriber exchange, and the UDP transport options.
- Add or expand contents navigation throughout `docs/`, and keep
contributor documentation separate.
- Correct outdated SDK examples and capability guidance, consolidate
repeated explanations into canonical guides, and repair local links and
heading structure.
- Rewrite What's New in 2.0 as a concise feature overview with short
linked details; keep migration instructions in the Migration Guide.
- Expand the feature catalogue with specification IDs, GDS, LDS, the OPC
10000-21 onboarding registrar, ISA-95, xRegistry, OpenUSD, and
sample-local models; distinguish draft status, explain crypto
pluggability, and bring performance guidance forward.
- Address review feedback on wording, active monitored items during
shadow reload, and the `Samples.md` filename and references.
- Regenerate the `opcua-v20-migration` plugin's bundled copy of the
migration guides so it matches the edited `docs/migrate/2.0.x` guides.
- Merge the latest `master`, preserving upstream resource-isolation
guidance within the task-based documentation index.
- Retain deferred Lingity findings where appropriate; this PR does not
claim that every document meets the Lingity score thresholds.
### Fixes found while verifying the guides
Each fix has regression tests that fail without it.
- **Client:** V2 subscriptions compared their lifetime with the
operation timeout instead of the session timeout, and logged a spurious
warning. `ISubscriptionEngineContext`, which is new in 2.0, gains a
`SessionTimeout` property; implementations must add it.
- **PubSub:** a subscriber now registers the `DataSetMetaData`
configured on its readers (OPC 10000-14 6.2.9.4), and the UADP decoder
decodes DataSetMessages that carry no ConfigurationVersion. Before, a
secured subscriber of the reference sample received no DataSets.
- **Source generator:** a ModelDesign method that declares its arguments
inline, without a method type, produced code that did not compile; it
now uses `MethodState`. The generated code of existing models is
unchanged.
- **MinimalClient sample:** waits for its first data-change notification
before it disconnects.
### Known issues, not addressed here
- UDP multicast: the publisher does not send on the interface that the
subscriber joins, and `MulticastLoopback` defaults to false. The PubSub
guide uses unicast for the exchange on one computer.
- The UDP metadata announcement is sent unsecured on secured writer
groups; a secured subscriber drops it with a warning.
- A server does not start in globalization-invariant mode, because
`ResourceManager` creates `CultureInfo("en-US")`. The Native AOT guide
cautions against the mode.
## Validation
- `git diff --check` and
`./.azurepipelines/validate-migration-plugin.ps1` (15 bundled migration
docs validated, plugin manifests synchronized).
- Local Markdown links, anchors, file-link casing, and contents lists
checked across all 87 documents under `docs/`. The index links every
top-level guide and every target of the previous index.
- Both Getting started routes, Your first information model, the PubSub
exchange, and the Native AOT publish of the Getting started client and
server were run as documented. The NuGet route and the tutorial use the
public `2.0.0-preview.5` packages.
- The changed libraries build for all target frameworks in Release
without warnings.
- Tests on .NET 10: Opc.Ua.SourceGeneration.Tests 199 passed;
Opc.Ua.SourceGeneration.Core.Tests 3,977 passed, 8 skipped;
Opc.Ua.Server.Tests `Fluent` category 621 passed; Opc.Ua.Client.Tests
subscription-engine tests 70 passed; Opc.Ua.PubSub.Tests 1,374 passed, 2
skipped; PubSub fuzz replay 145 passed. The new and affected tests also
pass on .NET Framework 4.8.
- Earlier merge validation: 174 resource-isolation tests passed on .NET
10.
- The full solution suite was not run locally.
Review and validation artifacts are maintained outside the repository.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
22 KiB
Node States and INodeCache
When to read this: Read this when migrating custom NodeManagers,
NodeStateclone / read / write helpers (Clone->CreateCopy, removedBaseVariableStatehelpers), the newINodeManager3role-permission hooks,OnAfterCreate(CancellationToken), predefined-node processing, generics onBaseVariableState/BaseVariableTypeState, code that tooklock (node)on aNodeStateor usedNodeBrowser.DataLock, orINodeCache.InvalidateNode.
Contents
Node States
Generics and Typed BaseVariableState and BaseVariableTypeState
With the changes to Variant, the generic node state classes reflecting the inner value of the variant "value" have been changed to not rely on "casting" from object to T. The conversion is "baked in" when creating an instance of a typed state using a "builder" struct. Whether the value is scalar, array or matrix is irrelevant to which builder to use. There are 3 situations and the respective builder struct to use:
- T is a built in type -> use
VariantBuilder - T is a instance of
IEncodeable(a complex structure) -> UseStructureBuilder<T>where T is the name of the structure. - T is an instance of Enum (an enumeration) -> Use
EnumBuilder<T>where T is the name of the enumeration type.
E.g. to create an instance of a PropertyState<T> where T is ArrayOf<ExtensionObject> use
var state = new PropertyState<ArrayOf<ExtensionObject>>.Implementation<VariantBuilder>(parent)
// or
var state = PropertyState<ArrayOf<ExtensionObject>>.With<VariantBuilder>(parent)
To create an instance of a PropertyState<T> where T is Argument (an IEncodeable type) use
var state = new PropertyState<Argument>.Implementation<StructureBuilder<Argument>>(parent)
// or
var state = PropertyState<Argument>.With<StructureBuilder<Argument>>(parent)
To create an instance of a PropertyState<T> where T is MatrixOf<ComplexType> (an IEncodeable type) use
var state = new PropertyState<MatrixOf<ComplexType>>.Implementation<StructureBuilder<ComplexType>>(parent)
// or
var state = PropertyState<MatrixOf<ComplexType>>.With<StructureBuilder<ComplexType>>(parent)
Note: While this looks clunky, it does not use reflection and comes with 0 allocation including any allocations for Func or Action delegates and works around .net limitations regarding overload resolution for generic arguments (which also required the use of FromStructure or FromEnumeration on the Variant type instead of using From). In future versions it is possible the source generator could generate away some of the redundancies in the above expressions.
Predefined node processing
Filling the predefined node state list is now generated as source code. This means the predefined Variable and Object instance states are the generated classes, not the root node states. This has an impact on the AddBehaviorToPredefinedNode implementations which should use the received node state as "activeNode" and attach functionality to it instead of creating a active node.
Example guidance (mirrors BoilerNodeManager): the node passed to AddBehaviorToPredefinedNode is already the generated instance state, so attach behavior directly to it instead of creating a new state. This ensures the predefined list stays consistent and the generated type-specific fields are available.
protected override void AddBehaviorToPredefinedNode(
ISystemContext context,
NodeState node)
{
if (node is BoilerTypeState boiler)
{
var activeNode = boiler;
activeNode.Temperature.OnSimpleWriteValue = OnTemperatureWrite;
activeNode.FlowRate.OnSimpleWriteValue = OnFlowRateWrite;
}
// Add callbacks to the node here if necessary
// If not needed you do not need to implement this call at all.
}
See NodeStates document for more information.
NodeState Cloning and Lifecycle
Node state does not implement IDisposable anymore.
Node states do not manage resources, they access resources. Therefore the management of resources must be done in a node manager. If you are overriding Dispose() on a NodeState to manage the node state, make the method public instead of protected, and maintain a list of node states on which you must call the Dispose() method when the Node Manager is disposed. Better, associated node states only via an identifier with a backend "system" that manages all state centrally and in your control.
Clone() replaced with CreateCopy()
NodeState.Clone() is now a concrete method that calls CreateCopy() + CopyTo(). The new protected abstract NodeState CreateCopy() must be overridden by all direct NodeState subclasses.
// Before
public override object Clone()
{
var clone = new MyNodeState(Parent);
CopyTo(clone);
return clone;
}
// After
protected override NodeState CreateCopy()
{
return new MyNodeState(Parent);
}
If you had custom deep-copy logic beyond what CopyTo() does, override CopyTo() instead.
BaseVariableState Read/Write helpers removed
The protected ServiceResult Read(object, ref object) and protected object Write(object) methods were removed.
Use the CopyPolicy property or the new CopyOnWrite bool directly with CoreUtils.Clone() for copy-on-read/write semantics.
OnAfterCreate gains CancellationToken
OnAfterCreate(ISystemContext, NodeState) now has an optional CancellationToken ct = default parameter.
⚠ Silent regression. Source-compatible, but binary-incompatible. Pre-compiled assemblies whose overrides still target the old
OnAfterCreate(ISystemContext, NodeState)signature will silently no-op at runtime against 2.0 - the CLR resolves virtual overrides by exact signature, finds no match, and falls back to the base implementation. No runtime exception is thrown to alert the developer. The only fix is to recompile the consuming assembly against 2.0 so the override binds to the new three-argument signature.
protected override void OnAfterCreate(ISystemContext context, NodeState node, CancellationToken ct = default)
{
base.OnAfterCreate(context, node, ct);
}
Generated instance factories and the create lifecycle
Source-generated CreateInstanceOf<Type> factories materialise the typed node
graph and assign per-instance NodeIds, but intentionally leave the create
lifecycle open so callers can finish assembling and configuring the subtree.
In 2.0, registering the graph through AddPredefinedNodeAsync,
AddNodeAsync, or the synchronous predefined-node registration path
automatically runs OnBeforeCreate/OnAfterCreate and clears change masks
before indexing. Asynchronous predefined-node registration repairs typed
instance subtrees which still carry null, foreign-namespace, or
type-declaration-colliding NodeIds before the callbacks, so they see the
identifiers which enter the address space. Explicit NodeIds in a namespace
owned by the node manager are preserved. Synchronous registration keeps the
caller's identifiers; fluent helpers which materialise typed subtrees assign
their instance child NodeIds before registration. NodeState.IsCreated
exposes whether an individual node has completed that lifecycle.
AddBehaviourToPredefinedNode and its asynchronous equivalent receive the
created node. An override which replaces a passive node may therefore observe
the passive node's lifecycle before returning the active replacement; the
replacement is also completed before indexing. Keep external lifecycle side
effects idempotent when using that legacy replacement pattern.
If pre-registration code depends on values established by OnAfterCreate, or
sets state or handlers which an OnAfterCreate override would replace, call
CreateAsPredefinedNode first:
MyMachineState machine =
context.CreateInstanceOfMyMachineType(parent, browseName);
machine.CreateAsPredefinedNode(context);
SeedInitialState(machine);
await AddPredefinedNodeAsync(context, machine, cancellationToken);
CreateAsPredefinedNode is idempotent per node and still completes newly
added children. It does not re-run OnAfterCreate on an ancestor which was
already created, so add children whose handlers are wired by a parent callback
before the parent's first completion, or wire those late children explicitly.
This prevents duplicate callback execution when an explicitly completed
subtree is later registered.
Use the overload accepting a CancellationToken when completing a large
subtree outside registration. Cancellation is checked between nodes and the
same token is passed to each OnAfterCreate override. A later call resumes
completion for nodes whose callback did not finish.
Removing a node from a node manager is not the same as deleting the
NodeState object. DeleteNodeAsync removes the node from the address-space
index and disconnects monitoring and references, but does not invoke
OnBeforeDelete/OnAfterDelete and does not reset IsCreated.
NodeState.Delete performs that object lifecycle recursively. Full
address-space teardown invokes Delete for each root.
NodeState FindChild and CreateChild state NodeId assignment
NodeState.FindChild and NodeState.CreateChild now take
assignInstanceNodeIds as their last parameter, and the old four argument
FindChild / two argument CreateChild virtuals are gone. The parameter
defaults to true, so call sites keep compiling and keep the 1.5.378
behaviour — only overrides have to change:
// Before
protected override BaseInstanceState FindChild(
ISystemContext context,
QualifiedName browseName,
bool createOrReplace,
BaseInstanceState replacement)
{
if (browseName.Name == BrowseNames.MyChild)
{
return createOrReplace
? CreateOrReplaceMyChild(context, replacement)
: MyChild;
}
return base.FindChild(context, browseName, createOrReplace, replacement);
}
// After — add the parameter and pass it on
protected override BaseInstanceState FindChild(
ISystemContext context,
QualifiedName browseName,
bool createOrReplace,
BaseInstanceState replacement,
bool assignInstanceNodeIds = true)
{
if (browseName.Name == BrowseNames.MyChild)
{
return createOrReplace
? CreateOrReplaceMyChild(context, replacement, assignInstanceNodeIds)
: MyChild;
}
return base.FindChild(
context, browseName, createOrReplace, replacement, assignInstanceNodeIds);
}
A missing override raises CS0115 (no suitable method found to override), so the compiler points at every site that needs the parameter.
Repeat the = true default in the override so callers bound to your derived
type keep the same behaviour.
Why: a node copy creates each child and then initialises it from its source,
which overwrites any NodeId minted along the way. Passing
assignInstanceNodeIds: false — what NodeState.Create(context, source)
now does — stops the ISystemContext.NodeIdFactory from being asked for
identifiers that are immediately discarded, and leaked by factories that
track outstanding allocations. Thread the argument into every
CreateOrReplace<Child> call your override makes; source generated types
already do. See
Custom node types and assignment control.
INodeManager3 - new role-permission and method-resolution hooks
2.0 introduces INodeManager3, an extension of INodeManager2 that surfaces explicit hooks for per-role permission evaluation and for resolving the target of a Call request. CustomNodeManager2 implements the new members with safe defaults that mirror the previous behavior, so node managers that already derive from CustomNodeManager2 need no changes.
Custom node managers that implement INodeManager / INodeManager2 directly (not via CustomNodeManager2) silently lose the new behavior: the server probes for INodeManager3 at the call site, and node managers that do not implement it fall through to the legacy code path. This is not a build break - it is a silent feature-availability regression. Either derive from CustomNodeManager2 or implement INodeManager3 explicitly to participate in role-permission evaluation and the new method-resolution contract.
NodeState guards itself; NodeBrowser is single-consumer (UA0027)
In 1.5.378 a caller wanting a consistent view of a node took a lock on the node
instance itself — lock (source) — and NodeBrowser handed its own lock to
derived browsers through protected object DataLock. Both are gone in 2.0.
NodeState guards its own state. Attributes, children, notifiers and
references each have a private lock inside the node, and
NodeState.CreateBrowser holds a browse lock while it assembles the browser.
No code in the stack locks a node instance, and no caller should:
// was
lock (source)
{
browser = source.CreateBrowser(context, view, referenceType, includeSubtypes,
browseDirection, default, null, false);
}
// now
INodeBrowser browser = source.CreateBrowser(context, view, referenceType,
includeSubtypes, browseDirection, default, null, false);
Locking the node was also the only way to make a check-then-act pair atomic.
ReferenceExists and AddReference each guard themselves, but the pair does
not, so use AddReferenceIfMissing:
// was
lock (node)
{
if (!node.ReferenceExists(ReferenceTypeIds.HasNotifier, true, ObjectIds.Server))
{
node.AddReference(ReferenceTypeIds.HasNotifier, true, ObjectIds.Server);
}
}
// now
node.AddReferenceIfMissing(ReferenceTypeIds.HasNotifier, true, ObjectIds.Server);
NodeBrowser.DataLock is removed. A browser is single-consumer: it belongs
to whoever created it and performs no synchronization of its own. The exposed
lock was an inheritance-level locking contract that a derived browser could not
reason about — nothing said how long it could be held or what else took it —
while both server browse paths already serialize on the owning side. A derived
browser drops the lock statement and keeps the body:
// was
public override IReference Next()
{
lock (DataLock)
{
IReference reference = base.Next();
...
}
}
// now
public override IReference Next()
{
IReference reference = base.Next();
...
}
If a browser really is shared between threads — the instance parked in a
continuation point for BrowseNext is the canonical case — serialize it where
it is owned, not inside the browser.
Overriding CreateBrowser. A node type that builds its own browser instead
of delegating to base.CreateBrowser must fill it through the new
protected NodeState.PopulateBrowserSynchronized, not by calling
PopulateBrowser directly. PopulateBrowser is invoked with the node's browse
lock held, so an override must not block on external work such as I/O; defer
that to the browser's own Next().
Analyzer UA0027 reports every remaining NodeBrowser.DataLock reference.
NodeBrowser gains an async iteration seam
INodeBrowser and NodeBrowser gain
ValueTask<IReference?> NextAsync(CancellationToken cancellationToken = default).
AsyncCustomNodeManager.BrowseAsync and TranslateBrowsePathAsync iterate a
browser through it, so a browser whose references depend on I/O — the
aggregation case, where the references come from another server — can await
the fetch instead of blocking a request worker on it.
Nothing changes for an existing browser. The default NextAsync completes
synchronously with the result of Next(), so a browser that only overrides
Next() is iterated exactly as before. Only a type that implements
INodeBrowser directly, without deriving from NodeBrowser, has to add the
member.
A browser that was bridging sync-over-async moves the real work into
NextAsync and keeps Next() as the bridge for the remaining synchronous
consumers (UANodeSetHelpers export, CustomNodeManager2):
// was — the fetch ran under GetResult() on every browse
public override IReference Next()
{
return NextAsync().GetAwaiter().GetResult();
}
private async Task<IReference> NextAsync() { ... }
// now — the async server awaits the fetch; Next() is only reached by sync callers
public override async ValueTask<IReference?> NextAsync(CancellationToken cancellationToken)
{
IReference? reference = base.Next();
if (reference != null)
{
return reference;
}
...
}
public override IReference? Next()
{
return NextAsync(CancellationToken.None).AsTask().GetAwaiter().GetResult();
}
Browser creation stays synchronous: OnCreateBrowser runs under the node's
browse lock, so a browser does its first fetch lazily in NextAsync, not in its
constructor.
INodeCache changes
Version 2.0 collapses the two parallel node-cache contracts into a single public interface and removes the remaining synchronous wrappers from the cache surface.
Key changes:
-
ILruNodeCacheis removed.LruNodeCachenow implements onlyINodeCache. All members previously onILruNodeCache(the NodeId-keyedGet*family andLoadTypeHierarchyAsync) are now members ofINodeCache. -
All async methods on
INodeCachereturnValueTask/ValueTask<T>(wasTask<T>forFindAsync,FetchNodeAsync,FetchNodesAsync,FetchSuperTypesAsync,FindReferencesAsync). Callers that simplyawaitthese methods need no change. Callers that store the result in aTaskvariable, return the bare task, or re-await the same task must wrap with.AsTask()once. -
void INodeCache.LoadUaDefinedTypes(ISystemContext)is removed. The LRU implementation populates lazily and the prior method body was a no-op. Drop the call from your code; the cache is ready to use. -
bool ILruNodeCache.IsTypeOf(NodeId, NodeId)is removed. UseIAsyncTypeTable.IsTypeOfAsync(NodeId, NodeId, CancellationToken)instead —INodeCacheinherits fromIAsyncTypeTableso the method is reachable on the same instance. -
NodeCacheObsoletesynchronous extensions are removed. The blocking wrappersFind,FetchNode,FetchNodes,FetchSuperTypes,FindReferences,GetDisplayText,IsKnown,FindSuperType, andExistswere obsoleted in 1.5.378 and now no longer compile. Switch to the matching async methods (FindAsync,FetchNodeAsync, …). -
** Moving of several methods to extension classes**: The following members were moved to extension methods on
NodeCacheExtensions(in the sameOpc.Uanamespace, so nousingchanges needed). These methods are thin wrappers around the coreINodeCachesurface and preserve the old signatures where possible.Removed from interface Replacement GetSuperTypeAsync(NodeId, ct)inherited IAsyncTypeTable.FindSuperTypeAsync(NodeId, ct)(identical semantics — the interface methods returned the sameNodeId.Null-on-miss value)FindReferencesAsync(ExpandedNodeId, NodeId, bool, bool, ct)inherited IAsyncNodeTable.FindAsync(source, refType, isInverse, includeSubtypes, ct)(identical signature). A thin extension method preserves the old name for callers that prefer it.FindReferencesAsync(ArrayOf<ExpandedNodeId>, ArrayOf<NodeId>, …)extension method on NodeCacheExtensions(same signature).FindAsync(ArrayOf<ExpandedNodeId>, ct)extension method on NodeCacheExtensionsthat loops over the inheritedFindAsync(ExpandedNodeId).FetchSuperTypesAsync(ExpandedNodeId, ct)extension method that loops FindSuperTypeAsync.GetNodeWithBrowsePathAsync(NodeId, ArrayOf<QualifiedName>, ct)extension method on NodeCacheExtensions.GetBuiltInTypeAsync(NodeId, ct)extension method on NodeCacheExtensions.GetDisplayTextAsync(INode | ExpandedNodeId | ReferenceDescription, ct)three extension methods on NodeCacheExtensions.External implementations of
INodeCacheno longer need to implement these members. Call sites that already usedusing Opc.Ua;keep compiling unchanged because the extensions live in the same namespace.
The new INodeCache deliberately keeps two name conventions side by side. The XML doc on INodeCache spells this out as well:
| Family | Identity | Result | Behavior |
|---|---|---|---|
Find* / Fetch* |
ExpandedNodeId |
nullable | Find* consults the cache, then the server; Fetch* always re-reads from the server. |
Get* |
NodeId |
non-nullable / throws | LRU-style direct hit; cheaper for in-process callers that already have a local NodeId. |
Migration:
// Before — Task-returning + sync helpers
INodeCache cache = session.NodeCache;
cache.LoadUaDefinedTypes(session.SystemContext); // removed
ArrayOf<INode?> nodes = await cache.FindAsync(nodeIds);
Task<Node?> tn = cache.FetchNodeAsync(nodeId); // returned Task<T>
bool isType = cache.IsTypeOf(sub, super); // sync, was on ILruNodeCache
// After — single INodeCache surface, all async, no sync IsTypeOf
INodeCache cache = session.NodeCache;
ArrayOf<INode?> nodes = await cache.FindAsync(nodeIds);
ValueTask<Node?> tn = cache.FetchNodeAsync(nodeId);
bool isType = await cache.IsTypeOfAsync(sub, super);
See also
- Related: types.md, alarms-model-change.md, sessions-subscriptions.md.
- 2.0 migration index — analyzer quick-start + symptom → sub-doc table.
- Migration Guide — landing page across versions.