mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 22:55:08 +08:00
15d0ce7bd5
# Description Implement OPC UA Part 10 Programs across `Opc.Ua.Core`/`Core.Types`, `Opc.Ua.Server`, and `Opc.Ua.Client`, reusing the existing state-machine, source-generation, node-management, auditing, and streaming-subscription infrastructure. - Add asynchronous provider-backed execution, standard controls, internal/autonomous transitions, generation checks, and nested-state activation/resumption. - Add checked creation/adoption, vendor Create adapters, deletion, recycling and instance limits, auto-delete, DI, and direct-construction paths. - Maintain ProgramDiagnostic2 and retained final results; publish captured intermediate results and concrete transition/audit events with access controls. - Add generated SDK models/proxies, typed client discovery and reads, event streams, and subscribe-before-call readiness handling. - Add unit, connected client/server, generated Annex A-style model, and NativeAOT coverage. Document usage and migration in `docs/Programs.md` and related guides. ### Compatibility and design decisions The linked Part 10 document is v1.05.00; namespace-zero definitions remain aligned with 1.05.07. Documentation distinguishes normative requirements from conflicting specification text and SDK conventions. - Correct state numbers to Halted=11, Ready=12, Running=13, Suspended=14. Generated subtypes retain inherited namespace-zero state/transition identifiers. - Managed Reset is Halted-to-Ready only. Preserve the existing low-level Reset-from-Suspended behavior for compatibility. - Preserve UInt32 limit types; use `UInt32.MaxValue` as a documented SDK unlimited convention. RecycleCount overflow reports BadOutOfRange without stopping unlimited execution. - In-memory default with injectable providers. Durable recovery, distributed execution/failover, and a production Annex A download engine are excluded. ## 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. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. Tests and focused coverage evidence are above; the combined tests/coverage checkbox remains open because a whole-repository coverage increase has not been established. CLA status, CI/CodeQL results, tracking-issue approval, and PR feedback are not asserted.
1123 lines
53 KiB
Markdown
1123 lines
53 KiB
Markdown
# Migration Guide
|
|
|
|
Use this guide to migrate an application between versions of the OPC UA
|
|
.NET Standard Stack. Detailed, version-specific instructions live in the
|
|
[`migrate/`](migrate/) subfolder. This page links to those guides and
|
|
covers cross-cutting changes.
|
|
|
|
## Contents
|
|
|
|
- [General principles](#general-principles)
|
|
- [Program state numbers and managed execution](#program-state-numbers-and-managed-execution)
|
|
- [Empty LocalizedText fields in 2.0](#empty-localizedtext-fields-in-20)
|
|
- [Per-version migration index](#per-version-migration-index)
|
|
- [Migrating code that used the exposed diagnostics locks](#migrating-code-that-used-the-exposed-diagnostics-locks)
|
|
- [Why there is no `[Obsolete]` shim](#why-there-is-no-obsolete-shim)
|
|
- [Migrating code that used ILocalNode.DataLock](#migrating-code-that-used-ilocalnodedatalock)
|
|
- [Migrating code that used BaseVariableValue.Lock](#migrating-code-that-used-basevariablevaluelock)
|
|
- [Migrating code that locked on a NodeState or a NodeBrowser](#migrating-code-that-locked-on-a-nodestate-or-a-nodebrowser)
|
|
- [Migrating code that used ApplicationConfiguration.PropertiesLock](#migrating-code-that-used-applicationconfigurationpropertieslock)
|
|
- [Migrating node types that override FindChild or CreateChild](#migrating-node-types-that-override-findchild-or-createchild)
|
|
- [Adopting replica-consistent NodeIds](#adopting-replica-consistent-nodeids)
|
|
- [Removed members on ISession](#removed-members-on-isession)
|
|
- [Awaiting custom node-manager cleanup](#awaiting-custom-node-manager-cleanup)
|
|
- [Migrating code that called IServerInternal.Set* mutators](#migrating-code-that-called-iserverinternalset-mutators)
|
|
- [Migrating IServerStartupTask implementations to IServerContext](#migrating-iserverstartuptask-implementations-to-iservercontext)
|
|
- [Removed members on IServerInternal](#removed-members-on-iserverinternal)
|
|
- [Migrating servers that relied on unserved history advertisement](#migrating-servers-that-relied-on-unserved-history-advertisement)
|
|
- [Migrating custom ISessionManager implementations to ShutdownAsync](#migrating-custom-isessionmanager-implementations-to-shutdownasync)
|
|
- [Configuring distributed address-space storage](#configuring-distributed-address-space-storage)
|
|
- [Migrating SamplingGroupManager create/modify overrides](#migrating-samplinggroupmanager-createmodify-overrides)
|
|
- [Migrating callers of the synchronous MonitoredNode2 notification wrappers](#migrating-callers-of-the-synchronous-monitorednode2-notification-wrappers)
|
|
- [Migrating callers of the SecurityPolicies lookup and cryptography statics](#migrating-callers-of-the-securitypolicies-lookup-and-cryptography-statics)
|
|
- [Migrating code that drove the server subscription publish pipeline](#migrating-code-that-drove-the-server-subscription-publish-pipeline)
|
|
- [Migrating channel subclasses that guarded state with DataLock](#migrating-channel-subclasses-that-guarded-state-with-datalock)
|
|
- [Transport resource limits](#transport-resource-limits)
|
|
- [Migrating channel subclasses that override HandleIncomingMessage](#migrating-channel-subclasses-that-override-handleincomingmessage)
|
|
- [Migrating custom IUserDatabase implementations](#migrating-custom-iuserdatabase-implementations)
|
|
- [Reading certificates from SecureChannelContext](#reading-certificates-from-securechannelcontext)
|
|
- [Write service Value semantics follow OPC 10000-3 and OPC 10000-4](#write-service-value-semantics-follow-opc-10000-3-and-opc-10000-4)
|
|
- [Address space permissions, Call, NodeManagement and locales follow OPC 10000-3](#address-space-permissions-call-nodemanagement-and-locales-follow-opc-10000-3)
|
|
- [Migrating from 1.05.377 to 1.05.378](#migrating-from-105377-to-105378)
|
|
- [Asynchronous as default](#asynchronous-as-default)
|
|
- [Observability](#observability)
|
|
- [Migrating from 1.04 to 1.05](#migrating-from-104-to-105)
|
|
- [Support](#support)
|
|
|
|
## Program state numbers and managed execution
|
|
|
|
`ProgramStateMachineState.CurrentState.Number` now agrees with the standard
|
|
model: Halted=11, Ready=12, Running=13 and Suspended=14. Earlier runtime values
|
|
were 4, 1, 2 and 3 respectively. Compare using `Opc.Ua.Programs.ProgramState`
|
|
or the standard state NodeIds instead of the old numeric values. State and
|
|
transition declarations in namespace zero are unchanged. Generated Program
|
|
subtypes now reference those inherited declarations in namespace zero instead
|
|
of incorrectly qualifying their numeric identifiers with the subtype namespace.
|
|
|
|
The opt-in [Program runtime](Programs.md) owns provider execution, lifecycle,
|
|
results and diagnostics. Its Reset follows the explicit Part 10 definition
|
|
(Halted-to-Ready); the existing low-level Reset-from-Suspended behavior remains.
|
|
Do not drive the same node through both an attached runtime and independent
|
|
legacy FSM callbacks. Existing synchronous methods are not sync-over-async
|
|
wrappers around the new runtime.
|
|
|
|
## Empty LocalizedText fields in 2.0
|
|
|
|
`LocalizedText.Locale` and `LocalizedText.Text` now expose empty strings as
|
|
`null`, matching the absent-field representation in OPC 10000-6, Table 24.
|
|
Plain values constructed with both fields null or empty are the null value,
|
|
including `new LocalizedText(string.Empty)`. Whitespace is not normalized.
|
|
Use null-aware checks rather than relying on `Text == string.Empty`.
|
|
|
|
Equality and hashing use these canonical fields, so empty inputs compare and
|
|
hash like null inputs before and after encoding. Translation keys, templates,
|
|
arguments, dictionaries and fallback metadata are retained. An explicitly
|
|
empty selected translation remains absent text rather than selecting fallback
|
|
text; its `Text` property returns `null`. Translation-backed values retain
|
|
their metadata and are not necessarily `IsNull`, even when both exposed fields
|
|
are null.
|
|
|
|
## General principles
|
|
|
|
1. All API that is replaced with newer API is marked `[Obsolete]` and
|
|
code should compile and work albeit of the warnings (which can be
|
|
suppressed). `[Obsolete]` API will be cleaned up in the next *minor*
|
|
version increment. We therefore recommend upgrading from minor
|
|
version to minor version and fixing all `[Obsolete]` warnings as you
|
|
go along.
|
|
2. API that cannot be supported anymore will be removed in a minor
|
|
version and migration steps documented in the version sub-folder.
|
|
We try to keep this to an absolute minimum.
|
|
3. Bugs or issues found in obsoleted API are not supported.
|
|
4. We follow semver, but do not use the major version indicator to
|
|
denote breaking changes like (1) or (2) as we should if we followed
|
|
related conventions. We are a small team and cannot afford to
|
|
maintain previous major versions, therefore we try to keep cases of
|
|
(2) to a minimum and expect you to upgrade to the next minor version
|
|
within 6 months of release.
|
|
|
|
> **Pro TIP.** Point your favourite coding agent at this guide and let
|
|
> it do the migration work for you. The
|
|
> [`opcua-v20-migration`](../.agents/skills/opcua-v20-migration/SKILL.md)
|
|
> agent skill knows when to load which sub-doc and runs the
|
|
> migration-analyzer codefixer end-to-end.
|
|
|
|
Copilot CLI discovers the skill automatically inside a clone. To install it
|
|
for use in any repository:
|
|
|
|
```bash
|
|
copilot plugin marketplace add OPCFoundation/UA-.NETStandard
|
|
copilot plugin install opcua-v20-migration@opcua-dotnet
|
|
```
|
|
|
|
## Per-version migration index
|
|
|
|
| From | To | Where to read |
|
|
| --- | --- | --- |
|
|
| `1.5.378` | `2.0.x` | [`migrate/2.0.x/`](migrate/2.0.x/README.md) — landing page and symptom-based thematic sub-doc index. |
|
|
| `1.05.377` | `1.05.378` | [§ inline below](#migrating-from-105377-to-105378) — small enough to keep on this page. |
|
|
| `1.04` | `1.05` | [§ inline below](#migrating-from-104-to-105) — small enough to keep on this page. |
|
|
|
|
Looking for the broader narrative (non-prescriptive overview of what
|
|
changed in a release)? See
|
|
[What's New in 2.0](WhatsNewIn2.0.md).
|
|
|
|
## Migrating code that used the exposed diagnostics locks
|
|
|
|
`IServerInternal`, `ISession` and `ISubscription` no longer expose their
|
|
synchronization primitives. The removed members are:
|
|
|
|
| Interface | Removed |
|
|
| --- | --- |
|
|
| `IServerInternal` | `DiagnosticsLock`, `DiagnosticsWriteLock` |
|
|
| `ISession` | `DiagnosticsLock` |
|
|
| `ISubscription` | `DiagnosticsLock`, `DiagnosticsWriteLock` |
|
|
|
|
A caller could not reason about these locks: it could not see what else took
|
|
them, in what order, or for how long, and holding one across a call back into
|
|
the stack could deadlock. Each owner now applies the mutation itself, so the
|
|
critical section stays inside the object that understands it.
|
|
|
|
```csharp
|
|
// was
|
|
lock (server.DiagnosticsLock)
|
|
{
|
|
server.ServerDiagnostics.RejectedSessionCount++;
|
|
}
|
|
|
|
// now
|
|
server.UpdateServerDiagnostics(diagnostics => diagnostics.RejectedSessionCount++);
|
|
```
|
|
|
|
The same shape applies to sessions and subscriptions:
|
|
|
|
```csharp
|
|
session.UpdateDiagnostics(diagnostics => diagnostics.ClientLastContactTime = now);
|
|
subscription.UpdateDiagnostics(diagnostics => diagnostics.NextSequenceNumber = next);
|
|
```
|
|
|
|
To read a value derived from the session or subscription diagnostics, use the
|
|
read counterpart, which holds the same lock for the duration of the projection:
|
|
|
|
```csharp
|
|
uint count = session.ReadDiagnostics(diagnostics => diagnostics.RepublishRequestCount);
|
|
```
|
|
|
|
**Do not let the diagnostics object escape the callback.** Once the callback
|
|
returns the lock is released, so any field read from a captured reference is
|
|
unsynchronized. Project the values you need inside the callback and return
|
|
those.
|
|
|
|
`IServerInternal.ServerDiagnostics` was removed for the same reason: it handed
|
|
out the mutable structure that the lock protects. `UpdateServerDiagnostics` and
|
|
the diagnostic node manager are the supported paths.
|
|
|
|
Analyzer `UA0024` flags each removed member and names its replacement. It
|
|
reports rather than auto-fixes: turning a `lock` statement body into a lambda is
|
|
not a transformation that can be applied safely without understanding what the
|
|
body captures and returns.
|
|
|
|
### Why there is no `[Obsolete]` shim
|
|
|
|
Every other removal in this guide keeps an `[Obsolete]` member for a release.
|
|
These do not, and deliberately so. A lock is only useful if it is *the* lock the
|
|
owner takes. A shim would have to hand back either a lock nobody else takes -
|
|
silently turning a working critical section into no synchronization at all - or
|
|
the real lock, which is exactly the coupling being removed. A missing member is
|
|
a compile error the analyzer explains; a shim would be a race that shows up in
|
|
production. `ISession` and `ISubscription` are also implemented by downstream
|
|
code, and re-adding an interface member would break every implementer.
|
|
|
|
## Migrating code that used ILocalNode.DataLock
|
|
|
|
`ILocalNode.DataLock` (implemented by `Node`) was removed. It returned the node
|
|
instance itself, so `lock (node.DataLock)` was `lock (node)`: one lock shared
|
|
between the stack, the node and every caller, taken in an order none of them
|
|
could see.
|
|
|
|
```csharp
|
|
// was
|
|
lock (node.DataLock)
|
|
{
|
|
value = node.Value;
|
|
}
|
|
|
|
// now - the node guards its own state
|
|
value = node.Value;
|
|
```
|
|
|
|
If the surrounding operation has to stay atomic across several calls, take a
|
|
lock the calling component owns. Do not reach for one that is reachable from a
|
|
shared node. Analyzer `UA0025` flags the removed member.
|
|
|
|
## Migrating code that used BaseVariableValue.Lock
|
|
|
|
`BaseVariableValue.Lock` was removed, and the constructor now takes a
|
|
`System.Threading.Lock` instead of an `object`.
|
|
|
|
A derived value class - which is what the source generator emits for every
|
|
structure variable - synchronizes through the protected `EnterLock()` /
|
|
`ExitLock()` pair:
|
|
|
|
```csharp
|
|
EnterLock();
|
|
try
|
|
{
|
|
// read or write the value fields
|
|
}
|
|
finally
|
|
{
|
|
ExitLock();
|
|
}
|
|
```
|
|
|
|
A component that has to make its own state atomic with the value passes a lock
|
|
it already owns to the constructor and takes that one directly. This is how the
|
|
server keeps its status and its diagnostics mutually exclusive:
|
|
|
|
```csharp
|
|
private readonly Lock m_diagnosticsLock = new();
|
|
|
|
// the value is constructed with the lock its owner already holds elsewhere
|
|
m_status = new ServerStatusValue(statusNode, status, m_diagnosticsLock);
|
|
|
|
// so the owner synchronizes against the value without the value handing anything out
|
|
lock (m_diagnosticsLock)
|
|
{
|
|
...
|
|
}
|
|
```
|
|
|
|
Analyzer `UA0026` flags the removed member. Note that regenerating the model
|
|
sources with the 2.0 generator produces the `EnterLock()` / `ExitLock()` form
|
|
already, so this only affects hand-written derived value classes and callers.
|
|
|
|
## Migrating code that locked on a NodeState or a NodeBrowser
|
|
|
|
Remove external `lock (node)` statements: `NodeState` synchronizes its own
|
|
attributes and collections. Replace reference check-then-add pairs with
|
|
`AddReferenceIfMissing`. Browsers are single-consumer, and `NodeBrowser.DataLock`
|
|
is removed (analyzer `UA0027`).
|
|
|
|
The [node-state migration guide](migrate/2.0.x/node-states.md#nodestate-guards-itself-nodebrowser-is-single-consumer-ua0027)
|
|
contains the before/after examples and the `PopulateBrowserSynchronized`
|
|
requirement for custom browsers. For I/O-backed iteration, see
|
|
[the async iteration seam](migrate/2.0.x/node-states.md#nodebrowser-gains-an-async-iteration-seam).
|
|
The current [threading contract](NodeManagers.md#threading-contract-for-nodes-and-browsers)
|
|
defines snapshot and synchronization boundaries.
|
|
|
|
## Migrating code that used ApplicationConfiguration.PropertiesLock
|
|
|
|
`ApplicationConfiguration.PropertiesLock` was removed. It returned the
|
|
properties dictionary itself, so `lock (configuration.PropertiesLock)` was
|
|
`lock (configuration.Properties)`: the lock was the data it guarded, shared
|
|
between the configuration and every caller, taken in an order none of them could
|
|
see.
|
|
|
|
`Properties` is now a concurrent dictionary, so each individual operation is
|
|
already atomic and most callers simply drop the `lock`:
|
|
|
|
```csharp
|
|
// was
|
|
lock (configuration.PropertiesLock)
|
|
{
|
|
configuration.Properties["MyKey"] = value;
|
|
}
|
|
|
|
// now
|
|
configuration.Properties["MyKey"] = value;
|
|
```
|
|
|
|
The one combination that needs more than a single operation is get-or-add, which
|
|
has its own member:
|
|
|
|
```csharp
|
|
// was
|
|
lock (configuration.PropertiesLock)
|
|
{
|
|
if (configuration.Properties.TryGetValue("MyKey", out object? existing))
|
|
{
|
|
return (MyType)existing;
|
|
}
|
|
var created = Build();
|
|
configuration.Properties["MyKey"] = created;
|
|
return created;
|
|
}
|
|
|
|
// now
|
|
return configuration.GetOrAddProperty("MyKey", Build);
|
|
```
|
|
|
|
`GetOrAddProperty` deliberately does **not** invoke the factory under a lock, so
|
|
a caller cannot hold a critical section across a callback. Under contention the
|
|
factory may run more than once; only one result is published and every caller
|
|
receives that same instance, so keep the factory free of side effects that would
|
|
matter if it ran twice.
|
|
|
|
One behaviour change is worth knowing: enumerating `Properties` while another
|
|
thread writes no longer throws `InvalidOperationException`. It yields a
|
|
moment-in-time view instead. Code that relied on the exception to detect
|
|
concurrent modification has to detect it some other way.
|
|
|
|
Analyzer `UA0028` flags the removed member.
|
|
|
|
## Migrating node types that override FindChild or CreateChild
|
|
`NodeState.FindChild` and `NodeState.CreateChild` take
|
|
`assignInstanceNodeIds` as their last parameter, and the four argument
|
|
`FindChild` / two argument `CreateChild` virtuals are gone. The parameter
|
|
defaults to `true`, so **call sites are unaffected**; an override fails to
|
|
compile (`CS0115`) until the parameter is added and passed on.
|
|
|
|
Behaviour note: a node copy — `NodeState.Create(context, source)` and the
|
|
`Initialize(ISystemContext, NodeState)` path behind it — now passes
|
|
`assignInstanceNodeIds: false`. It no longer asks
|
|
`ISystemContext.NodeIdFactory` for identifiers that the copy overwrites
|
|
from the source on the very next statement. If your `INodeIdFactory`
|
|
counts, reserves or audits every allocation, expect **fewer** calls than in
|
|
1.5.378 for the same address space; the resulting NodeIds are unchanged.
|
|
Any `NodeState` subclass you own must thread the argument into its
|
|
`CreateOrReplace<Child>` calls to get that benefit.
|
|
|
|
See
|
|
[Node states § FindChild and CreateChild](migrate/2.0.x/node-states.md#nodestate-findchild-and-createchild-state-nodeid-assignment)
|
|
for the before/after and
|
|
[Custom node types and assignment control](NodeManagers.md#custom-node-types-and-assignment-control)
|
|
for the runtime rules.
|
|
|
|
## Adopting replica-consistent NodeIds
|
|
|
|
When moving a custom 1.5.378 redundant deployment to the 2.0 shared address-space
|
|
modules, configure [`UseReplicaNodeIdentity`](ReplicaNodeIdentity.md) with the same
|
|
ordered model/instance namespace list on every replica. Namespace 1 remains local
|
|
to each ApplicationUri; shared namespaces start at index 2. Do not assume that
|
|
previously clock-seeded counters or namespace registration order form a portable
|
|
identity contract. Preserve existing agreed IDs explicitly where compatible, or
|
|
plan an offline namespace/identifier migration and client cutover.
|
|
|
|
Unbound legacy shared state is not automatically adopted or renumbered. The
|
|
identity module refuses unknown or conflicting stored contracts; see its
|
|
new-store provisioning and migration limits before attaching an existing store.
|
|
|
|
## Removed members on ISession
|
|
|
|
`ISession.SessionDiagnostics` is removed. It handed out the whole mutable
|
|
`SessionDiagnosticsDataType` — the structure the session's diagnostics lock
|
|
protects — so a caller could read a field while the owner was writing it.
|
|
|
|
Every server-side reader wanted one value out of it, and those two values are
|
|
now on the interface directly:
|
|
|
|
```csharp
|
|
// was
|
|
string? uri = session.SessionDiagnostics?.ClientDescription?.ApplicationUri;
|
|
string name = session.SessionDiagnostics?.SessionName ?? string.Empty;
|
|
|
|
// now
|
|
string? uri = session.ClientApplicationUri;
|
|
string name = session.SessionName;
|
|
```
|
|
|
|
For anything else in the structure, project it inside `ReadDiagnostics`, which
|
|
holds the lock for the duration of the projection:
|
|
|
|
```csharp
|
|
uint reads = session.ReadDiagnostics(diagnostics => diagnostics.ReadCount.TotalCount);
|
|
```
|
|
|
|
`SessionName` is read from the field it was always a copy of rather than from
|
|
the diagnostics, because it is assigned once during construction and a value
|
|
that cannot change should not cost a lock.
|
|
|
|
The concrete `Session` still exposes `SessionDiagnostics`; only the interface
|
|
loses it.
|
|
|
|
`ISession.ValidateBeforeActivate` — the synchronous overload with
|
|
`out IUserIdentityTokenHandler?` and `out UserTokenPolicy?` parameters — is
|
|
removed. It had no caller anywhere in the stack, its samples or its tests other
|
|
than tests written for it, and it had been `[Obsolete]` since 1.5.378.
|
|
|
|
Use `ValidateBeforeActivateAsync`, which returns the same two values as a tuple:
|
|
|
|
```csharp
|
|
(IUserIdentityTokenHandler identityToken, UserTokenPolicy? userTokenPolicy) =
|
|
await session.ValidateBeforeActivateAsync(
|
|
context, clientSignature, userIdentityToken, userTokenSignature, ct)
|
|
.ConfigureAwait(false);
|
|
```
|
|
|
|
The synchronous overload could not verify a user token that required
|
|
decryption, so on a secure endpoint it failed closed and directed callers to
|
|
the asynchronous path anyway.
|
|
|
|
The history continuation points moved off `ISession` onto
|
|
`ISession.ContinuationPoints`, and no longer pass `object`. `SaveHistory` and
|
|
`RestoreHistory` use `IHistoryContinuationPoint`, which carries the point's own
|
|
`Guid Id` and extends `IDisposable`:
|
|
|
|
```csharp
|
|
// was
|
|
session.SaveHistoryContinuationPoint(state.Id, state);
|
|
object? restored = session.RestoreHistoryContinuationPoint(bytes);
|
|
|
|
// now
|
|
session.ContinuationPoints.SaveHistory(state); // the point carries its Id
|
|
IHistoryContinuationPoint? restored = session.ContinuationPoints.RestoreHistory(bytes);
|
|
```
|
|
|
|
Implement `IHistoryContinuationPoint` on whatever type you store. The session
|
|
previously disposed only those points that happened to implement `IDisposable`
|
|
and silently leaked the rest; every point is now disposed.
|
|
|
|
## Awaiting custom node-manager cleanup
|
|
|
|
When directly owning a `CustomNodeManager2`, use `await using` or await
|
|
`DisposeAsync()` if subsequent work depends on its resources being released.
|
|
Unlike the synchronous cleanup in 1.5.x, `Dispose()` now closes admission and
|
|
can return while previously admitted operations finish. The monitored-item
|
|
manager and address-space nodes remain alive until those operations return.
|
|
New service and lifecycle calls after admission closes throw
|
|
`ObjectDisposedException`.
|
|
|
|
An admitted callback may call `Dispose()` to initiate shutdown, but must not
|
|
await its own drain with `DisposeAsync()`. Await completion outside the
|
|
callback. Server and master-node-manager asynchronous teardown already await
|
|
adapted synchronous managers, so server-owned managers need no additional
|
|
disposal call.
|
|
|
|
## Migrating code that called IServerInternal.Set* mutators
|
|
|
|
`IServerInternal` no longer exposes the twelve `Set*` binding methods or
|
|
`CreateServerObjectAsync`. They were startup plumbing: `StandardServer` calls
|
|
each exactly once, in one block, to carry a `Create*` factory result into the
|
|
datastore. Publishing them on the interface let any holder rewire a running
|
|
server, which would leave every component that had already resolved a subsystem
|
|
holding the previous instance.
|
|
|
|
The supported seam is the factory seam, which already existed for every
|
|
subsystem here:
|
|
|
|
| Instead of | Override | Or register in DI |
|
|
| --- | --- | --- |
|
|
| `SetRoleManager` | `StandardServer.CreateRoleManager` | `IRoleManager` |
|
|
| `SetUserManagement` | `StandardServer.CreateUserManagement` (new) | `IUserManagement` |
|
|
| `SetMonitoredItemQueueFactory` | `StandardServer.CreateMonitoredItemQueueFactory` | `IMonitoredItemQueueFactory` |
|
|
| `SetSubscriptionStore` | `StandardServer.CreateSubscriptionStore` | `ISubscriptionStore` |
|
|
| `SetMainNodeManagerFactory` | `StandardServer.CreateMainNodeManagerFactory` | — |
|
|
| `SetNodeManager` | `StandardServer.CreateMasterNodeManager` | — |
|
|
| `SetSessionManager` | `StandardServer.CreateSessionManager` / `CreateSubscriptionManager` | `ISessionManager`, `ISubscriptionManager` |
|
|
| `SetAggregateManager` | `StandardServer.CreateAggregateManagerAsync` | — |
|
|
| `SetModellingRulesManager` | `StandardServer.CreateModellingRulesManagerAsync` | — |
|
|
| `SetConformanceUnitsManager` | `StandardServer.CreateConformanceUnitsManagerAsync` | — |
|
|
|
|
`CreateUserManagement` is new in this release, because user management was the
|
|
one subsystem with no factory seam. Registering an `IUserManagement` in the
|
|
container also switches on the username/password authenticator; override
|
|
`CreateUserManagement` if you want the Part 18 §5 model without that.
|
|
|
|
`SetIdentityRegistry` is **removed with no replacement**. Nothing ever called
|
|
it: the supported route has always been
|
|
`ServerIdentityRegistryExtensions.RegisterDefaultAuthenticators`, which adds
|
|
authenticators to the default registry rather than replacing it.
|
|
|
|
The methods remain on the concrete `ServerInternalData`, so code that already
|
|
held that type keeps compiling. Binding is now refused once the server has
|
|
finished starting — a late `Set*` throws `ServiceResultException` with
|
|
`BadInvalidState` naming the operation.
|
|
|
|
## Migrating IServerStartupTask implementations to IServerContext
|
|
|
|
`IServerStartupTask.OnServerStartedAsync` now receives an `IServerContext`
|
|
instead of an `IServerInternal`. `IServerInternal` derives from
|
|
`IServerContext`, so the host still passes the same object; only the
|
|
declared parameter type changes and an implementation fails to compile
|
|
(`CS0535`) until its signature is updated.
|
|
|
|
`IServerContext` is the ambient view of a running server. It carries what
|
|
is genuinely server-wide and nothing else — it deliberately does not hand
|
|
out the server's subsystems. A startup task that needs a subsystem takes it
|
|
as a constructor dependency, which every implementation in this repository
|
|
already did for its other dependencies.
|
|
|
|
Rewrite the member reads that no longer resolve:
|
|
|
|
| Was | Now |
|
|
| --- | --- |
|
|
| `server.Telemetry` | `server.DefaultSystemContext.Telemetry` |
|
|
| `server.NamespaceUris` | `server.DefaultSystemContext.NamespaceUris` |
|
|
| `server.ServerUris` | `server.DefaultSystemContext.ServerUris` |
|
|
| `server.TypeTree` | `server.DefaultSystemContext.TypeTable` |
|
|
| `server.Factory` | `server.DefaultSystemContext.EncodeableFactory` |
|
|
| `server.DiagnosticsNodeManager.FindPredefinedNode<T>(id)` | `server.FindPredefinedNode<T>(id)` |
|
|
| `server.NodeManager.NodeManagers` + a type test | `server.FindNodeManagers<TCapability>()` |
|
|
| `server.SessionManager`, `server.SubscriptionManager`, `server.RequestManager`, `server.AggregateManager`, `server.RoleManager`, `server.IdentityRegistry`, … | constructor injection |
|
|
|
|
`server.MessageContext` is unchanged and remains on `IServerContext`. Do
|
|
**not** substitute `server.DefaultSystemContext.AsMessageContext()` for it:
|
|
that conversion produces a context with *default* decoding limits rather
|
|
than the server's configured `MaxStringLength`, `MaxArrayLength` and
|
|
`MaxByteStringLength`, which silently widens what your component accepts.
|
|
|
|
Tests that hand a `Mock<IServerInternal>` to a startup task keep compiling,
|
|
because the mock still satisfies `IServerContext`. Stub the members the task
|
|
actually reads now — typically `DefaultSystemContext` and any
|
|
`FindNodeManagers<T>()` — or the mock returns `null` and the task fails at
|
|
run time rather than at build time.
|
|
|
|
## Removed members on IServerInternal
|
|
|
|
The following members had no consumer anywhere in the stack, its samples or
|
|
its tests, and have been removed. Each has a direct replacement:
|
|
|
|
| Removed | Use instead |
|
|
| --- | --- |
|
|
| `CloseSession(OperationContext, NodeId, bool)` | `CloseSessionAsync(…)` |
|
|
| `Status` | `CurrentState` to read, `UpdateServerStatus` to write |
|
|
| `ServerDiagnostics` | `UpdateServerDiagnostics(Action<…>)` |
|
|
| `DiagnosticsEnabled` | `IDiagnosticsNodeManager.DiagnosticsEnabled` |
|
|
| `ModellingRulesManager`, `ConformanceUnitsManager` | the concrete `ServerInternalData`, which owns them |
|
|
|
|
`MessageContext`, `DefaultSystemContext`, `CurrentState`, `ServerObject`,
|
|
`ReportEventAsync`, `CloseSessionAsync`, `DeleteSubscriptionAsync` and
|
|
`UpdateServerDiagnostics` all moved *down* to `IServerContext`, the ambient
|
|
view of a running server that `IServerInternal` now derives from. They remain
|
|
reachable through `IServerInternal` unchanged, so no call site has to move.
|
|
`CurrentState` is read-only on the ambient interface; the server itself still
|
|
sets it.
|
|
|
|
## Migrating servers that relied on unserved history advertisement
|
|
|
|
Server startup now reconciles variables that advertise history with the
|
|
historian providers actually wired into the server. If a variable has
|
|
`Historizing=true` or `HistoryRead` / `HistoryWrite` access-level bits
|
|
from a NodeSet but no `IHistorianProvider` resolves for it, the server
|
|
clears the advertisement and masks the attribute read callbacks before
|
|
accepting clients. Variables with a provider keep their NodeSet-declared
|
|
history surface.
|
|
|
|
If a client or CTT setup expected `HistoryRead` solely because the
|
|
NodeSet declared it, wire a historian instead of relying on the static
|
|
flag: use `builder.UseHistorian()` and `.Historize(...)`, register a
|
|
provider through the server-wide historian registry, or override
|
|
`GetHistorianProvider(NodeState)` in the node manager. See
|
|
[Server address-space metadata](NodeManagers.md#server-address-space-metadata) and
|
|
[Historical Access](HistoricalAccess.md).
|
|
|
|
## Migrating custom ISessionManager implementations to ShutdownAsync
|
|
|
|
`ISessionManager.Shutdown()` is **gone**, replaced by
|
|
`ShutdownAsync(CancellationToken)`. `SessionManager` previously started its
|
|
session monitor loop with a discarded `Task.Factory.StartNew(...)`, so
|
|
`Shutdown()` only *signalled* the loop and returned: the server could
|
|
finish tearing down while the monitor was still closing expired sessions
|
|
and raising keep-alive events against half-disposed state. There is no
|
|
correct synchronous way to wait for that loop — blocking on it would be
|
|
sync-over-async — so the synchronous overload was removed rather than
|
|
kept as a trap. `ShutdownAsync` cancels the loop and awaits it before
|
|
disposing the sessions, matching `ISubscriptionManager.ShutdownAsync`.
|
|
|
|
**Callers** await instead of calling:
|
|
|
|
```csharp
|
|
// before
|
|
server.SessionManager.Shutdown();
|
|
|
|
// after
|
|
await server.SessionManager.ShutdownAsync(cancellationToken)
|
|
.ConfigureAwait(false);
|
|
```
|
|
|
|
**Implementers** of `ISessionManager` (for example a manager registered
|
|
through `services.AddSessionManager<T>()`) replace `Shutdown` with
|
|
`ShutdownAsync`. If your implementation has no background work, return a
|
|
completed task:
|
|
|
|
```csharp
|
|
public ValueTask ShutdownAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
CloseAllSessions();
|
|
return default;
|
|
}
|
|
```
|
|
|
|
Deriving from `SessionManager` requires no change beyond renaming any
|
|
`Shutdown` override: `ShutdownAsync` is `virtual` and the base
|
|
implementation already awaits the monitor loop.
|
|
|
|
## Configuring distributed address-space storage
|
|
|
|
Direct `InMemoryNodeStateStore` writers require their backend to implement
|
|
`ISharedKeyValueStoreConsistency` and provide linearizable operations for
|
|
`election/addressspace-sequence`. A bare CRDT store is not a valid writer
|
|
backend. Compose it with the shared Raft coordinator using
|
|
`HybridSharedKeyValueStore`, or use the fluent consistency registration before
|
|
`UseDistributedAddressSpace`.
|
|
|
|
Use strong state storage for authoritative bootstrap and compacted snapshots.
|
|
CRDT payload storage retains merge-only hydration and delta replay without
|
|
absence-based cleanup or snapshot compaction. These are configuration
|
|
requirements of the distributed-state extension, not a 1.5 persisted-format
|
|
migration guarantee. See
|
|
[address-space consistency](HighAvailability.md#activepassive-address-space-consistency).
|
|
|
|
## Migrating SamplingGroupManager create/modify overrides
|
|
|
|
The public virtual `SamplingGroupManager.CreateMonitoredItem` and
|
|
`ModifyMonitoredItem` entry points, and the protected virtual creation
|
|
factory, have been removed. They did not accept a separate server-revised
|
|
filter, so preserving their dispatch required request-local state merely to
|
|
carry that filter through existing overrides.
|
|
|
|
Use the node manager's `IMonitoredItemManager` pipeline for creation and
|
|
modification. For custom item construction, return
|
|
`MonitoredItemCreateDecision.Use(factory)` from `OnCreateMonitoredItem` or
|
|
`AsyncCustomNodeManager.OnCreatingMonitoredItemAsync`; the stack registers
|
|
and owns the returned item. See
|
|
[monitored-item creation and lifecycle](NodeManagers.md#monitored-item-creation-and-lifecycle).
|
|
|
|
Sampling-group creation/modification now receives the original request and
|
|
revised filter directly through internal, nonvirtual methods. The request is
|
|
not rewritten, and the unrelated monitoring/lifecycle hooks are unchanged.
|
|
This is an intentional API removal, not an obsolete forwarding shim.
|
|
|
|
## Migrating callers of the synchronous MonitoredNode2 notification wrappers
|
|
|
|
`MonitoredNode2.OnReportEvent` and `MonitoredNode2.OnMonitoredNodeChanged`
|
|
are `[Obsolete]`; use `OnReportEventAsync` and
|
|
`OnMonitoredNodeChangedAsync`. Nothing in the stack wires the synchronous
|
|
pair any more — notifiers are attached through
|
|
`NodeState.OnReportEventAsync` and `NodeState.OnStateChangedAsync` — and
|
|
both wrappers block the calling thread whenever the bounded notification
|
|
channel is full, or whenever the node has an asynchronous read handler.
|
|
Blocking there occupies a thread while waiting for a consumer that needs
|
|
a thread of its own, which starves the thread pool under load.
|
|
|
|
```csharp
|
|
// before
|
|
monitoredNode.OnReportEvent(context, node, e);
|
|
|
|
// after
|
|
await monitoredNode.OnReportEventAsync(context, node, e, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
```
|
|
|
|
The wrappers still work and are unchanged in behaviour, so this is a
|
|
warning to act on rather than a break.
|
|
|
|
<a id="ua0029"></a>
|
|
|
|
## Migrating callers of the SecurityPolicies lookup and cryptography statics
|
|
|
|
The static lookup and cryptography methods on `SecurityPolicies` have
|
|
**moved** to `ISecurityPolicyRegistry`. They read the set of registered
|
|
security policies, so they are members of the registry that owns that set
|
|
rather than free functions on a constants class.
|
|
|
|
Moved: `GetUri`, `GetDisplayName`, `GetDisplayNames`,
|
|
`IsValidSecurityPolicyUri`, `GetDefaultUris`, `GetDefaultEccUris`,
|
|
`GetDefaultDeprecatedUris`, `Encrypt` and `Decrypt`.
|
|
|
|
**The policy URI constants are unaffected.** `SecurityPolicies.None`,
|
|
`SecurityPolicies.Basic256Sha256` and the rest stay exactly where they are,
|
|
which is the overwhelming majority of references to this type.
|
|
|
|
Resolve an `ISecurityPolicyRegistry` where a container is in scope, so the
|
|
policies that application registered are the ones used:
|
|
|
|
```csharp
|
|
// before
|
|
string uri = SecurityPolicies.GetUri("Basic256Sha256");
|
|
|
|
// after - the application's own policy set
|
|
public sealed class MyService(ISecurityPolicyRegistry policies)
|
|
{
|
|
public string? Uri => policies.GetUri("Basic256Sha256");
|
|
}
|
|
```
|
|
|
|
Where there is no container — configuration loading, for instance — use
|
|
the fallback, which carries the built-in policies:
|
|
|
|
```csharp
|
|
string? uri = SecurityPolicies.Default.GetUri("Basic256Sha256");
|
|
```
|
|
|
|
`Encrypt` and `Decrypt` additionally **lose their `ILogger` argument**. The
|
|
registry is created with an `ITelemetryContext` and reports through the
|
|
logger it made from it:
|
|
|
|
```csharp
|
|
// before
|
|
EncryptedData data = SecurityPolicies.Encrypt(certificate, uri, plainText, logger);
|
|
|
|
// after
|
|
EncryptedData data = policies.Encrypt(certificate, uri, plainText);
|
|
```
|
|
|
|
Registering a policy through the container no longer changes what other
|
|
code in the same process sees. `AddSecurityPolicy` applies the policy to
|
|
the registry that container owns, so two applications hosted together keep
|
|
separate policy sets. Use `AddSecurityPolicyRegistry()` to resolve a
|
|
registry without contributing a policy of your own.
|
|
|
|
The `OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer` package restores
|
|
the removed members as `[Obsolete]` extension members that forward to
|
|
`SecurityPolicies.Default`, so a 1.05.378 application compiles with
|
|
a warning rather than an error. The `ILogger` argument on the `Encrypt` and
|
|
`Decrypt` shims is accepted and **ignored**.
|
|
|
|
<a id="ua0030"></a>
|
|
|
|
## Migrating code that drove the server subscription publish pipeline
|
|
|
|
The server publish pipeline is internal. Analyzer `UA0030` identifies removed
|
|
`Opc.Ua.Server.ISubscription` members: remove no-op calls and use the service
|
|
operations for publishing, acknowledgements, transfers, and session teardown.
|
|
Custom server subscriptions must derive from `Subscription`.
|
|
|
|
See the [server subscription migration](migrate/2.0.x/sessions-subscriptions.md#opcuaserverisubscription-the-publish-pipeline-is-server-internal)
|
|
for the complete removed-member list, replacement service paths, and custom
|
|
implementation requirements. `SessionPublishQueue` is internal too; applications
|
|
must not drive it directly.
|
|
|
|
The [no-obsolete-shim rule](#why-there-is-no-obsolete-shim) applies because
|
|
restoring members to a public interface would break downstream implementers.
|
|
|
|
## Migrating channel subclasses that guarded state with DataLock
|
|
|
|
`UaSCBinaryChannel.DataLock` has been **removed**. The channel no longer
|
|
serialises any of its own state on it, so taking it excluded nothing.
|
|
|
|
A monitor cannot be held across an `await`, and the secure channel open
|
|
path has to be able to await once a private key may be served over a
|
|
network — see [Crypto provider](CryptoProvider.md). The channel now uses
|
|
an internal gate that can be entered from a synchronous or an
|
|
asynchronous path. Unlike a monitor the gate is **not re-entrant**: every
|
|
channel path that used to take the lock while already holding it now
|
|
calls a lock-free `Core` variant instead.
|
|
|
|
That has one consequence for subclasses. The channel calls
|
|
`HandleSocketError`, `NotifyMonitors` and `CompleteReverseHello` from
|
|
paths that may already hold the gate, so an override of any of them must
|
|
not call back into a channel method that takes it — `ForceChannelFault`
|
|
and `SendResponse` in particular. Such an override would have silently
|
|
nested before and will now block.
|
|
|
|
For the same reason `SaveIntermediateChunk`, `GetSavedChunks` and
|
|
`DoMessageLimitsExceeded` take an additional `gateHeld` argument. It says
|
|
whether the calling frame already holds the gate, so that an override
|
|
which tears the channel down can pick the locking or the lock-free path:
|
|
|
|
```csharp
|
|
// before
|
|
protected override void DoMessageLimitsExceeded()
|
|
{
|
|
base.DoMessageLimitsExceeded();
|
|
Shutdown(new ServiceResult(StatusCodes.BadResponseTooLarge));
|
|
}
|
|
|
|
// after
|
|
protected override void DoMessageLimitsExceeded(bool gateHeld)
|
|
{
|
|
base.DoMessageLimitsExceeded(gateHeld);
|
|
|
|
if (gateHeld)
|
|
{
|
|
ShutdownCore(new ServiceResult(StatusCodes.BadResponseTooLarge));
|
|
return;
|
|
}
|
|
|
|
Shutdown(new ServiceResult(StatusCodes.BadResponseTooLarge));
|
|
}
|
|
```
|
|
|
|
There is no drop-in replacement to offer across an assembly boundary,
|
|
because the gate has to be entered asynchronously on the open path and
|
|
its correctness depends on rules that only hold inside the channel
|
|
implementation. A subclass outside this stack that guarded **its own**
|
|
state with `DataLock` should introduce its own synchronisation:
|
|
|
|
```csharp
|
|
// before
|
|
lock (DataLock)
|
|
{
|
|
m_myState = value;
|
|
}
|
|
|
|
// after
|
|
private readonly System.Threading.Lock m_myLock = new();
|
|
|
|
using (m_myLock.EnterScope())
|
|
{
|
|
m_myState = value;
|
|
}
|
|
```
|
|
|
|
A subclass that took `DataLock` in order to be mutually exclusive with
|
|
the **channel's** state transitions was already relying on an
|
|
implementation detail, and can no longer do so.
|
|
|
|
## Transport resource limits
|
|
|
|
Applications migrating from 1.5.x have a server-wide budget for retained
|
|
intermediate-message buffers. With the reference server's 4 MiB maximum message
|
|
size, the default budget is **64 MiB**. Under SharedOnly, channels without an
|
|
activated Session may retain chunks only while total usage remains within the
|
|
lower **32 MiB**. Balanced instead divides the same total between shared memory
|
|
and memory reserved for specific kinds of work. With a 65,536-byte buffer limit,
|
|
16.25 MiB is reserved for verified startup traffic and 16.25 MiB for continuity
|
|
or reconnect traffic, leaving 31.5 MiB shared. Other traffic cannot borrow these
|
|
reserves. Channels already carrying an activated Session can use the continuity
|
|
reserve for incomplete messages. A chunk that does not fit discards its partial
|
|
message and closes the channel with `BadTcpNotEnoughResources`. Final chunks,
|
|
single-chunk requests, response buffers, and client buffers are not charged to
|
|
this reassembly budget. Once a complete request has been decoded, separate
|
|
limits control how many requests may wait, execute, or remain parked, and how
|
|
much request data they may retain. A request refused at that stage receives
|
|
`BadServerTooBusy`, even if it fitted in a single transport chunk.
|
|
|
|
`MaxQueuedRequestCount` keeps its 1.5.x meaning: established Sessions can have
|
|
that many decoded requests waiting. Balanced and TrustedReservations add their
|
|
reserved queue entries on top of it. Earlier 2.0 previews carved them out of it,
|
|
so a client that kept exactly `MaxQueuedRequestCount` requests outstanding could
|
|
receive `BadServerTooBusy`. A server that relied on the smaller queue should
|
|
lower `MaxQueuedRequestCount` or set `ResourceIsolationStageOptions.Capacity` for
|
|
the RequestQueue stage. See [request and caller limits](ResourceIsolation.md#configuring-request-and-caller-limits).
|
|
|
|
For workloads with many simultaneous large requests, set
|
|
`WithChunkReassemblyBudget(maxBytes)` on the Dependency Injection (DI) server builder or assign
|
|
`ServerBase.ChunkReassemblyBudget` before startup. A host opening listeners
|
|
directly can share a budget through `TransportListenerSettings.ChunkReassemblyBudget`.
|
|
See [incomplete messages](RateLimiting.md#incomplete-messages) for sizing and
|
|
sessionless configuration. General buffer-manager limits remain opt-in.
|
|
|
|
Server-channel `ChannelLifetime` also bounds an unfinished message from its
|
|
first retained chunk, even if more chunks keep arriving. Size this lifetime
|
|
for legitimate large transfers without relying on continuation chunks to
|
|
extend it indefinitely. A zero or negative value uses the 30-second default
|
|
for message assembly. It does not disable assembly cleanup. See
|
|
[incomplete-message limits](Transports.md#incomplete-message-resource-limits).
|
|
|
|
## Migrating channel subclasses that override HandleIncomingMessage
|
|
|
|
`UaSCBinaryChannel.HandleIncomingMessage` and `OnChunkReceived` have been
|
|
**removed**, as has the `protected WriteAsymmetricMessage` overload that
|
|
returned the signature through an `out` parameter. The receive loop calls
|
|
`HandleIncomingMessageAsync` and `OnChunkReceivedAsync`, so that the
|
|
secure channel open path can await a private key served over a network —
|
|
see [Crypto provider](CryptoProvider.md).
|
|
|
|
A synchronous override cannot be kept working underneath the
|
|
asynchronous path without defeating the point of it, so **an existing
|
|
override must be moved**. The signature gains a `CancellationToken` and
|
|
returns `ValueTask<bool>`:
|
|
|
|
```csharp
|
|
// before
|
|
protected override bool HandleIncomingMessage(
|
|
uint messageType, ArraySegment<byte> messageChunk)
|
|
{
|
|
...
|
|
}
|
|
|
|
// after
|
|
protected override async ValueTask<bool> HandleIncomingMessageAsync(
|
|
uint messageType, ArraySegment<byte> messageChunk, CancellationToken ct)
|
|
{
|
|
...
|
|
}
|
|
```
|
|
|
|
An override that has nothing to await can return a completed value
|
|
without going asynchronous at all:
|
|
|
|
```csharp
|
|
protected override ValueTask<bool> HandleIncomingMessageAsync(
|
|
uint messageType, ArraySegment<byte> messageChunk, CancellationToken ct)
|
|
{
|
|
return new ValueTask<bool>(HandleSynchronously(messageType, messageChunk));
|
|
}
|
|
```
|
|
|
|
The buffer-ownership contract is unchanged: return `true` when the
|
|
implementor takes ownership of the chunk, and it will not be returned to
|
|
the buffer manager for you.
|
|
|
|
`ReadAsymmetricMessageAsync` and `WriteAsymmetricMessageAsync` return
|
|
`AsymmetricMessage` and `AsymmetricWriteResult` rather than using `out`
|
|
parameters, which an asynchronous method cannot have. Use
|
|
`WriteAsymmetricMessageAsync` in place of the removed synchronous
|
|
overload.
|
|
|
|
## Migrating custom IUserDatabase implementations
|
|
|
|
`IUserDatabase` gained four members. A store now persists user metadata
|
|
alongside credentials, so disabled and `MustChangePassword` decisions survive a
|
|
restart and an administrative password reset is a single transaction rather than
|
|
a delete followed by a create:
|
|
|
|
```csharp
|
|
IReadOnlyList<UserManagementDataType> GetUsers();
|
|
|
|
bool CreateUser(
|
|
string userName,
|
|
ReadOnlySpan<byte> password,
|
|
ArrayOf<Role> roles,
|
|
UserConfigurationMask userConfiguration,
|
|
string description);
|
|
|
|
bool ResetPassword(
|
|
string userName,
|
|
ReadOnlySpan<byte> newPassword,
|
|
UserConfigurationMask userConfiguration,
|
|
string description);
|
|
|
|
bool UpdateUserMetadata(
|
|
string userName,
|
|
UserConfigurationMask userConfiguration,
|
|
string description);
|
|
```
|
|
|
|
The five 1.5.378 members are unchanged. Implement the new ones on your store, or
|
|
derive from `LinqUserDatabase` / `JsonUserDatabase`, which provide in-memory and
|
|
atomic file-backed transactions respectively.
|
|
|
|
Each mutation must commit as one transaction, and a rejected or failed write must
|
|
leave both the live and the persisted record unchanged. `ChangePassword` must
|
|
clear `UserConfigurationMask.MustChangePassword` in the same transaction as the
|
|
password it commits. `ResetPassword` must preserve the user's identity and roles
|
|
and must not delete and recreate the user.
|
|
|
|
There is no optional-capability fallback: `UserManagement` requires these members
|
|
and no longer keeps metadata only in memory, so a store that cannot persist
|
|
metadata should reject the write by returning `false` rather than silently
|
|
accepting it.
|
|
|
|
## Reading certificates from SecureChannelContext
|
|
|
|
`SecureChannelContext.ClientChannelCertificate`, `ServerChannelCertificate` and
|
|
`ChannelThumbprint` are `ByteString` instead of `byte[]`. The transport now shares
|
|
one buffer of a channel's certificates with every request on that channel instead
|
|
of copying the DER encoding per request, and a `ByteString` is a read-only view of
|
|
it, so a request handler or an `IResourceIsolationClassifier` cannot alter the
|
|
evidence later requests are checked against.
|
|
|
|
The constructor still takes `byte[]` and wraps it without copying. A missing
|
|
value is `IsNull`; an empty array stays empty, not null.
|
|
|
|
| Before | After |
|
|
|---|---|
|
|
| `context.ClientChannelCertificate == null` | `context.ClientChannelCertificate.IsNull` |
|
|
| `context.ClientChannelCertificate?.Length > 0` | `context.ClientChannelCertificate.Length > 0` |
|
|
| `context.ClientChannelCertificate.AsSpan()` | `context.ClientChannelCertificate.Span` |
|
|
| `Certificate.FromRawData(context.ClientChannelCertificate)` | `Certificate.FromRawData(context.ClientChannelCertificate.Memory)` |
|
|
| `context.ClientChannelCertificate.ToByteString()` | `context.ClientChannelCertificate` |
|
|
| passing the array to an API that takes `byte[]?` | `value.IsNull ? null : value.ToArray()` |
|
|
|
|
Keep a missing certificate null when converting: the session signature helpers
|
|
hash an empty array but skip a null one, so substituting `ToArray()` for a null
|
|
value changes the signature data.
|
|
|
|
## ContentFilter NULL semantics follow OPC 10000-4 1.05.07
|
|
|
|
`FilterEvaluator` applies the NULL rules of
|
|
[OPC 10000-4 §7.7.3](https://reference.opcfoundation.org/Core/Part4/v105/docs/7.7.3)
|
|
to event where-clauses and every other `ContentFilter`:
|
|
|
|
- An element with a null operand evaluates to NULL (except `IsNull`), and a
|
|
filter that ends as NULL is FALSE. `Equals(field, 0)` no longer matches an
|
|
event without that field, and `Not(Equals(field, 5))` no longer matches it
|
|
either.
|
|
- `IsNull` is TRUE for the null value of a nullable built-in type (a null
|
|
String, ByteString, NodeId, the all-zero Guid, `DateTime.MinValue`, …) and for
|
|
a null or empty array, which
|
|
[OPC 10000-6 §5.1.11](https://reference.opcfoundation.org/Core/Part6/v105/docs/5.1.11)
|
|
treats as the same. A zero, `false` or a Good StatusCode is a value.
|
|
- Operands that cannot be converted to a common type make `Between` FALSE
|
|
instead of NULL.
|
|
|
|
Clients whose where-clauses relied on the old matching of missing fields should
|
|
test them explicitly with `IsNull`, for example `Or(IsNull(field), Equals(field, 0))`.
|
|
|
|
## Write service Value semantics follow OPC 10000-3 and OPC 10000-4
|
|
|
|
A Value written through the Write service is now checked the same way on every path of
|
|
`BaseVariableState` ([OPC 10000-4 §5.11.4](https://reference.opcfoundation.org/Core/Part4/v105/docs/5.11.4)):
|
|
|
|
- **StatusWrite / TimestampWrite.** Without the `StatusWrite` bit of the AccessLevel only the
|
|
StatusCode Good may be written, and without the `TimestampWrite` bit only a null
|
|
SourceTimestamp ([OPC 10000-3 §8.57](https://reference.opcfoundation.org/Core/Part3/v105/docs/8.57)).
|
|
Other combinations return `Bad_WriteNotSupported`. The bits must also be present in the
|
|
effective UserAccessLevel of the caller (including `OnReadUserAccessLevel`); otherwise the
|
|
write returns `Bad_UserAccessDenied`. "Good" is compared by the code bits only, so Good
|
|
with info bits (for example SemanticsChanged or LimitBits) needs no StatusWrite, while a
|
|
Good SubCode such as `Good_Clamped` does. Server code that assigns `Value`,
|
|
`StatusCode` or `Timestamp` directly is not affected. Set
|
|
`AccessLevels.StatusWrite | AccessLevels.TimestampWrite` on both the AccessLevel and the
|
|
UserAccessLevel of Variables whose clients legitimately write status codes or source
|
|
timestamps.
|
|
- **Type check before `OnWriteValue`.** The DataType and ValueRank are verified before the
|
|
synchronous `OnWriteValue` handler (fluent `OnWrite`) runs, so a handler no longer
|
|
receives a value of the wrong type; the write returns `Bad_TypeMismatch`.
|
|
- **IndexRange with `OnWriteValue`.** After the handler accepts an IndexRange write, the slice
|
|
is merged into the cached value instead of replacing it.
|
|
- **Enumerations.** Writing an Int32 that is not a defined value of an Enumeration DataType
|
|
registered with the encodeable factory returns `Bad_OutOfRange` (also for every element of
|
|
an array or matrix).
|
|
- **OptionSet.** A written OptionSet structure must have Value and ValidBits of the same size
|
|
as the stored value and may only select valid bits, otherwise `Bad_OutOfRange`. The valid
|
|
bits are the ValidBits of the stored value (an all-zero mask means no bit is valid), or the
|
|
bits of the OptionSet DataType definition when the stored value has no ValidBits. The selected
|
|
bits are merged into the stored value
|
|
([OPC 10000-3 §8.40](https://reference.opcfoundation.org/Core/Part3/v105/docs/8.40)).
|
|
The same applies to each element of an OptionSet array or matrix, including elements
|
|
written through an IndexRange.
|
|
|
|
Server code that changes a Property with semantic meaning (for example `EURange` or
|
|
`EngineeringUnits`) directly calls `ReportPropertyValueChanged(context, property, previousValue)`
|
|
or `ReportSemanticChange(context, property)` on its `AsyncCustomNodeManager` or
|
|
`CustomNodeManager2`, so that a SemanticChangeEvent is raised and the next value notification
|
|
carries the SemanticsChanged bit ([OPC 10000-3 §5.6.2](https://reference.opcfoundation.org/Core/Part3/v105/docs/5.6.2)).
|
|
|
|
The PubSub subscriber that writes TargetVariables of an external server retries a write that
|
|
fails with `Bad_WriteNotSupported` without the received timestamps and remembers the target
|
|
([OPC 10000-14 §6.2.11.1](https://reference.opcfoundation.org/Core/Part14/v105/docs/6.2.11.1)).
|
|
|
|
## Address space permissions, Call, NodeManagement and locales follow OPC 10000-3
|
|
|
|
- **Role permissions on type nodes.** ObjectTypes, VariableTypes and their children stay
|
|
browsable and readable for every user, but all other permission bits are now checked on
|
|
them ([OPC 10000-3 §8.55](https://reference.opcfoundation.org/Core/Part3/v105/docs/8.55)).
|
|
In particular `ReceiveEvents` is verified on the EventType: the standard NodeSet grants it on
|
|
`AuditEventType` and its subtypes only to `SecurityAdmin`, so Anonymous and
|
|
AuthenticatedUser sessions no longer receive audit events. Give the users that monitor
|
|
audit events the SecurityAdmin role (or change the RolePermissions of the audit event types).
|
|
`MasterNodeManager.ValidateAccessRestrictions` has an overload that takes the requested
|
|
permission; the existing two-argument overload behaves as a Browse/Read request.
|
|
- **Call.** The `Call` permission is required on the Object passed as `objectId` as well as on
|
|
the Method, and the Object's AccessRestrictions apply. When the `methodId` is the Method
|
|
declaration of the ObjectType, the Method of the Object with the same BrowseName is
|
|
permission-checked and invoked ([OPC 10000-4 §5.12.2.2](https://reference.opcfoundation.org/Core/Part4/v105/docs/5.12.2.2));
|
|
bind method handlers on the instance Methods. Trailing input arguments described by
|
|
`HasOptionalInputArgumentDescription` may be omitted by the client and reach the handler as
|
|
`Variant.Null`; override `MethodState.GetOptionalInputArgumentCount` to customize this.
|
|
- **NodeManagement.** AddNodes rejects abstract TypeDefinitions (including `BaseVariableType`,
|
|
`BaseEventType` and Interfaces) with `Bad_TypeDefinitionInvalid` and creates the Mandatory
|
|
InstanceDeclarations of the type below the new node. AddReferences, DeleteReferences and
|
|
DeleteNodes bump the `NodeVersion` and raise `GeneralModelChangeEvent`s.
|
|
- **Locales.** Read (DisplayName, Description, InverseName, LocalizedText values), Browse and
|
|
data change notifications return the translation that best matches the session's LocaleIds,
|
|
or all translations for `mul`
|
|
([OPC 10000-4 §5.4](https://reference.opcfoundation.org/Core/Part4/v105/docs/5.4)). The
|
|
selection uses `ResourceManager.TranslateValue`, which calls the overridable
|
|
`ResourceManager.Translate`. Locale ids are matched case-insensitively.
|
|
- **NodeSet import.** `UANodeSet.Import` completes the `StructureDefinition` of structure
|
|
subtypes with the fields of their base types (base fields first,
|
|
[OPC 10000-3 §8.48](https://reference.opcfoundation.org/Core/Part3/v105/docs/8.48)), creates a
|
|
definition for structures without fields, and sets the `DefaultEncodingId` of concrete
|
|
structures. `StructureDefinition.FirstExplicitFieldIndex` marks the first field of the
|
|
subtype; NodeSet export writes only those fields.
|
|
|
|
## Migrating from 1.05.377 to 1.05.378
|
|
|
|
### Asynchronous as default
|
|
|
|
The server now supports `AsyncNodeManagers`; see
|
|
[Server Async (TAP) Support](AsyncServerSupport.md). The client APIs are
|
|
async by default and all synchronous and APM-based API has been
|
|
deprecated. To migrate, update your code to use the `Async` version of
|
|
every API where possible. Not recommended but for expedience you can
|
|
call the `Async` version synchronously with
|
|
`GetAwaiter().GetResult()`.
|
|
|
|
### Observability
|
|
|
|
Observability is now plumbed through `ITelemetryContext`. The legacy
|
|
static `Utils.SetLogger` / `Utils.Trace*` model has been removed in
|
|
2.0. See
|
|
[`migrate/2.0.x/telemetry.md`](migrate/2.0.x/telemetry.md) for OLD
|
|
vs NEW snippets, the per-type constructor matrix, and the full
|
|
inventory of removed / `[Obsolete]` `Utils` APIs.
|
|
|
|
Configuration-level trace apply APIs were removed as well: `TraceConfiguration.ApplySettings()` and `ApplicationConfigurationBuilder` trace setters (`SetOutputFilePath`, `SetDeleteOnLoad`, `SetTraceMasks`). Configure logging through `ITelemetryContext` instead.
|
|
## Migrating from 1.04 to 1.05
|
|
|
|
A few features are still missing to fully comply with 1.05, but
|
|
certification for v1.04 is still possible with the 1.05 release.
|
|
|
|
## Support
|
|
|
|
For additional migration support:
|
|
|
|
- Review sample applications in the repository.
|
|
- Check unit tests for usage patterns.
|
|
- Use the
|
|
[`OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer`](https://www.nuget.org/packages/OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer)
|
|
package — 26 implemented analyzer rules through `UA0030` (excluding
|
|
`UA0013`, `UA0016`, `UA0017`, and the shim-only `UA0029`) map across the
|
|
[`migrate/2.0.x/`](migrate/2.0.x/README.md) guides and the cross-cutting
|
|
notes on this page; 14 rules apply safe edits via a code-fixer. `UA0029`
|
|
is currently a runtime-shim/manual marker surfaced through `CS0618`, not an
|
|
analyzer diagnostic.
|
|
- Open an issue on
|
|
[OPCFoundation/UA-.NETStandard](https://github.com/OPCFoundation/UA-.NETStandard/issues).
|