Prepares the repository for a 2.0 release: bumps every version reference from 1.6 to 2.0, and lands the migration guide describing the 1.5.378 → 2.0 upgrade path. The 1.6 line never shipped, so this is a straight rename with no historical 1.6 migration content to preserve. The PR is split into two commits: 1. **`Bump version to 2.0 (next release is a major version change)`** — version metadata, NuGet metadata, the side documentation, and the migration agent file. 2. **`[Docs] Close 19 audit gaps in 1.5.378 to 2.0 migration guide`** — adds and reorganizes `Docs/MigrationGuide.md` to close every gap from a rigorous code-review audit of the 1.5.378 → 2.0 breaking-change surface. ### Version bump (commit 1) - `version.json`: `1.6-preview` → `2.0-preview` - `common.props`: `<VersionPrefix>1.6</VersionPrefix>` → `<VersionPrefix>2.0</VersionPrefix>` - `nuget/Opc.Ua.nuspec` and `nuget/Opc.Ua.Symbols.nuspec`: `1.6.0` → `2.0.0` - `README.md`: "New in 1.6" → "New in 2.0" - `Docs/ComplexTypes.md`, `Docs/Observability.md`, `Docs/Sessions.md`: 1.6 → 2.0 mentions - Renames `.github/agents/opcua-v16-migration.agent.md` → `.github/agents/opcua-v20-migration.agent.md` and updates the agent metadata (name, description, trigger phrases, version table) - `UA.slnx`: solution-items reference updated to the new agent filename - `Tools/Opc.Ua.SourceGeneration.Core/Generators/ObjectTypeProxyGenerator.cs`: two v16 → v20 comment fixes ### Migration guide (commit 2) Closes 19 gaps from an audit comparing `master378` (1.5.378) with `master` (2.0). Adds a new top-level **Package, Target Framework and Dependency Changes** section covering the new `Opc.Ua.Core.Types` and `Opc.Ua.Security.Certificates` packages, the `LibTypesTargetFrameworks` netstandard2.0 drop, the NuGet dependency additions/removals, and the residual Newtonsoft.Json dependency in `Opc.Ua.PubSub`. In-place additions: - new H4 for `INodeManager3` under Node States - `Session.Call` `Variant.From` worked example - new H3 `Subscriptions and Transports` with H4 verdicts for Durable subscriptions (source-breaking), PubSub (no source break) and Reverse connect (no source break) - new H3 `Telemetry and Logging` covering `ITelemetryContext` constructor-injection patterns - `in DataValue` async copy-to-local pattern - UNSAFE / SAFE pair for `WithPoolNotifications` pooling - using-alias example for classic vs V2 `SubscriptionOptions` - "Silent regression" callout for `OnAfterCreate` - numbered lifecycle ordering for `ApplicationConfiguration.CertificateManager` Corrections: - clarifies that the `SessionReconnectHandler` `[Obsolete]` is still present in 2.0 (not withdrawn); - clarifies that the source-generated `ServerCapability` constants vs the `ServerCapabilityInfo` registry type are distinct; - disambiguates `ReconnectPolicyOptions` (DTO) from `ReconnectPolicy` (runtime). Adds an "Automate the migration" pointer to the forthcoming `Opc.Ua.CodeFixers` analyzer package, which mechanizes 17 of the patterns covered in this guide. The analyzer is published in a follow-up PR.
22 KiB
Sessions, Reconnection, and Subscription Engines
This document describes how the OPC UA client stack composes the building
blocks that turn a configured endpoint into a running, fault-tolerant OPC
UA session: the raw Session and its reconnect helper, the
ManagedSession facade, the pluggable subscription engines, and how to
choose between them.
Quick reference
| Type | Creates | Reconnect | Subscription engine | Recommended for |
|---|---|---|---|---|
Session |
itself (via constructor or Session.Create...Async) |
none — caller drives via Session.ReconnectAsync / SessionReconnectHandler |
ClassicSubscriptionEngine by default; pass ISubscriptionEngineFactory to opt in to V2 |
callers that own session lifecycle and existing reconnect code |
DefaultSessionFactory |
raw Session |
none — caller drives reconnect | configurable via SubscriptionEngineFactory init property |
drop-in ISessionFactory for the legacy flow |
SessionReconnectHandler |
nothing (drives an existing session) | yes, against a Session (only) |
inherited from the wrapped Session |
legacy callers that already own a Session |
ManagedSession |
itself (via CreateAsync) |
yes — built-in ConnectionStateMachine + ReconnectPolicy |
DefaultSubscriptionEngine (V2) by default |
new code; long-lived clients that need reconnect / failover |
ManagedSessionFactory |
ManagedSession |
inherited from ManagedSession |
inherited from ManagedSession |
drop-in ISessionFactory that yields managed sessions |
ManagedSessionBuilder |
ManagedSession |
inherited from ManagedSession |
configurable via UseSubscriptionEngine |
fluent / DI scenarios |
Everything below explains how these pieces fit together.
1. Session — the OPC UA session primitive
Session (Libraries/Opc.Ua.Client/Session/Session.cs) is the lowest-level
client object that maps directly to a UA secure-channel + session pair on
the server. It implements ISession and exposes the full surface of the
OPC UA service set (Read, Write, Browse, Call, AddNodes, etc.) plus
session-level concerns: keep-alive, namespace tables, the type tree, the
node cache, and a publish pipeline.
A Session is bound to:
- An
ApplicationConfiguration(security policies, certificates, transport quotas, telemetry). - A
ConfiguredEndpoint(URL, security mode, security policy URI). - An
ITransportChannelcreated against that endpoint.
Lifecycle transitions on a Session are explicit and synchronous-looking
from the caller's perspective:
Session.ReconnectAsync()re-uses the existing channel where possible and re-establishes the activated session.ISessionFactory.RecreateAsync(...)builds a freshSessionthat inherits the previous session's identity, subscriptions, and node cache, swapping in a new channel.
Session does not run a background reconnect state machine. If the
keep-alive callback raises a bad status, the caller decides whether to
reconnect, recreate, or abandon the session.
Plugging in a subscription engine
Session accepts an optional ISubscriptionEngineFactory at
construction. The factory creates an ISubscriptionEngine that owns the
publish loop and the live subscription set. Two implementations ship in
the box:
ClassicSubscriptionEngineFactory.Instance(default when no factory is supplied) — the historical fire-and-forget publish engine that drivesSubscriptionandMonitoredItemdirectly fromSession.DefaultSubscriptionEngineFactory.Instance— wraps the new options-basedSubscriptionManager(the V2 subscription pipeline) inside anISubscriptionEngineadapter so even a rawSessioncan expose theISubscriptionManagerAPI.
The engine is selected once, at construction. It is then reused across reconnect / recreate cycles. See section 4 for the engine architecture.
DefaultSessionFactory
DefaultSessionFactory (Libraries/Opc.Ua.Client/Session/DefaultSessionFactory.cs)
is the drop-in ISessionFactory that builds raw Session instances. It
exposes a single tunable knob beyond ITelemetryContext:
var factory = new DefaultSessionFactory(telemetry)
{
SubscriptionEngineFactory = DefaultSubscriptionEngineFactory.Instance, // optional; null => classic
ReturnDiagnostics = DiagnosticsMasks.SymbolicId,
};
ISession session = await factory.CreateAsync(...);
When SubscriptionEngineFactory is null, Session falls back to
ClassicSubscriptionEngineFactory — preserving the historical default.
2. SessionReconnectHandler — legacy reconnect driver
SessionReconnectHandler (Libraries/Opc.Ua.Client/Session/SessionReconnectHandler.cs)
is the original reconnect helper that ships with Session. It implements
the timer-driven retry loop, picks between
Session.ReconnectAsync and ISessionFactory.RecreateAsync, handles
endpoint refreshes when the server certificate changes, and cooperates
with ReverseConnectManager.
using var handler = new SessionReconnectHandler(
telemetry,
reconnectAbort: true,
maxReconnectPeriod: 30000);
session.KeepAlive += (_, e) =>
{
if (e.Status != null && ServiceResult.IsNotGood(e.Status))
{
handler.BeginReconnect(session, 1000, OnReconnectComplete);
}
};
Supported session types
SessionReconnectHandler drives Session.ReconnectAsync and
ISessionFactory.RecreateAsync directly, and replaces the channel on the
underlying object. It therefore only supports the concrete Session
class (or types derived from it). Passing any other ISession
implementation — most notably ManagedSession — throws
NotSupportedException, because those facades already run their own
reconnect state machine and exposing two reconnect drivers on the same
session would race.
// OK: raw Session
ISession raw = await new DefaultSessionFactory(telemetry).CreateAsync(...);
handler.BeginReconnect(raw, 1000, callback);
// Throws NotSupportedException
ISession managed = await new ManagedSessionFactory(telemetry).CreateAsync(...);
handler.BeginReconnect(managed, 1000, callback);
SessionReconnectHandler is not marked [Obsolete] — it is a
supported entry point for callers that already own raw Session
instances. New code should still prefer ManagedSession.
3. ManagedSession — the connection-state-machine facade
ManagedSession (Libraries/Opc.Ua.Client/Session/ManagedSession.cs) is
an ISession facade that wraps a raw Session and adds:
- A connection state machine (
ConnectionStateMachine) that owns transitions betweenDisconnected,Connecting,Connected,Reconnecting,Closing,Closed. - A reconnect policy (
ReconnectPolicy/ReconnectPolicyOptions) with pluggable backoff strategies (Exponential,Linear,Constant), jitter, max-retry caps, and bounded delays. - A server-redundancy handler (
IServerRedundancyHandler, default:DefaultServerRedundancyHandler) that reads the server'sServerRedundancyobject and can fail over to a backup endpoint. - A service gate (
m_serviceLock) that pauses caller-issued service calls (Read/Write/Browse/Call/...) for the duration of a reconnect or failover, so consumers see one transparent retry rather than a torn state. - A default V2 subscription engine so the new options-based
subscription API (
ISubscriptionManager) is available onManagedSession.SubscriptionManagerout of the box.
ManagedSession session = await ManagedSession.CreateAsync(
configuration,
endpoint,
sessionFactory: new DefaultSessionFactory(telemetry),
reconnectPolicy: new ReconnectPolicy(new ReconnectPolicyOptions
{
Strategy = BackoffStrategy.Exponential,
InitialDelay = TimeSpan.FromSeconds(1),
MaxDelay = TimeSpan.FromSeconds(30),
MaxRetries = 0, // unlimited
JitterFactor = 0.1
}),
redundancyHandler: new DefaultServerRedundancyHandler(),
telemetry: telemetry);
ManagedSessionFactory
ManagedSessionFactory (Libraries/Opc.Ua.Client/Session/ManagedSessionFactory.cs)
is the ISessionFactory that produces ManagedSession (cast as
ISession to satisfy the interface). Internally it composes a
DefaultSessionFactory to build the raw inner Session, then wraps it.
The factory is the drop-in replacement for code that currently calls
DefaultSessionFactory.CreateAsync(...) and wants managed reconnect for
free:
ISessionFactory factory = new ManagedSessionFactory(telemetry);
ISession session = await factory.CreateAsync(...); // actually a ManagedSession
Naming note.
ManagedSessionFactorycreatesManagedSessioninstances.DefaultSessionFactorycreates rawSessioninstances. The two factories are siblings underISessionFactory;ManagedSessionFactoryuses an innerDefaultSessionFactoryonly as an implementation detail for building the underlying transport+session pair.
ManagedSessionBuilder
ManagedSessionBuilder (Libraries/Opc.Ua.Client/ClientBuilder/ManagedSessionBuilder.cs)
is the fluent / DI-friendly entry point. Build() returns an immutable
ManagedSessionOptions snapshot; ConnectAsync() wraps Build() plus
ManagedSession.CreateAsync(...):
ManagedSession session = await new ManagedSessionBuilder(configuration, telemetry)
.UseEndpoint(endpoint)
.WithSessionName("MyClient")
.WithSessionTimeout(TimeSpan.FromSeconds(60))
.WithReconnectPolicy(p => p with
{
Strategy = BackoffStrategy.Exponential,
InitialDelay = TimeSpan.FromSeconds(1),
MaxDelay = TimeSpan.FromSeconds(30)
})
.WithServerRedundancy()
.UseSubscriptionEngine(DefaultSubscriptionEngineFactory.Instance) // optional; V2 by default
.ConnectAsync(ct);
The builder is also the integration point for
OpcUaClientBuilderExtensions.AddClient — DI consumers
inject a Func<CancellationToken, Task<ManagedSession>> that lazily
connects on first use and caches the resulting session.
Reconnect semantics on ManagedSession
When the underlying session's keep-alive fires a bad status, the
connection state machine transitions Connected → Reconnecting and:
- Pauses the service gate so all in-flight and incoming service calls wait.
- Drives the configured
ReconnectPolicyto compute the next delay (with jitter), bounded byMinDelay/MaxDelayand capped byMaxRetries(where0means unlimited). - Calls
Session.ReconnectAsync(channel reuse path) and on failureISessionFactory.RecreateAsync(channel rebuild path), updating the inner session reference atomically. - If the redundancy handler is enabled and the active server is
unreachable, it consults
IServerRedundancyHandler.GetNextEndpointAsyncand recreates the session against the failover endpoint. - On success, transitions
Reconnecting → Connectedand releases the service gate, replaying any deferred calls. - On exhaustion (
MaxRetriesreached), transitions toClosedand surfaces aServiceResultExceptionto outstanding callers.
Because all of this is driven internally, callers must not wrap a
ManagedSession with SessionReconnectHandler; doing so throws
NotSupportedException.
4. Subscription engines
The ISubscriptionEngine abstraction
(Libraries/Opc.Ua.Client/Session/ISubscriptionEngine.cs) decouples the
publish pipeline from Session. Each Session owns exactly one engine
chosen at construction; the engine manages the publish workers, ack
queues, keep-alive timers, and dispatch to subscriptions.
ClassicSubscriptionEngine
The historical engine. Owns:
- The
SubscriptionandMonitoredItemclasses inLibraries/Opc.Ua.Client/Subscription/Classic. - A fire-and-forget publish loop driven from
Session.BeginPublish/OnPublishComplete. - The
Subscriptionscollection onISessionandManagedSession.
This is the default when no SubscriptionEngineFactory is supplied to
Session directly. It is also what ManagedSession exposes via the
classic Subscriptions property regardless of which engine is active —
an internal SubscriptionBridge forwards classic subscriptions onto the
V2 engine when one is in use, so existing classic-API code keeps
working.
DefaultSubscriptionEngine (V2)
The new engine wraps the options-based SubscriptionManager
(Libraries/Opc.Ua.Client/Subscription/SubscriptionManager.cs) inside
the ISubscriptionEngine contract. It owns:
- The
Subscriptions.Subscription/Subscriptions.MonitoredItems.MonitoredItemrecords consumed viaIOptionsMonitor<T>. - A worker-pool publish loop with adaptive worker count.
- The full subscription-service-set lifecycle:
CreateSubscription,ModifySubscription,SetPublishingMode,Republish, plusCreateMonitoredItems,ModifyMonitoredItems,DeleteMonitoredItems,SetMonitoringMode,Call,SetTriggering— all routed back through theSession's service-set client methods. - An
ISubscriptionNotificationHandlercallback contract forOnDataChangeNotificationAsync/OnEventDataNotificationAsync/OnKeepAliveNotificationAsync.
DefaultSubscriptionEngine.SubscriptionManager exposes the
ISubscriptionManager API; on ManagedSession it surfaces directly as
SubscriptionManager.
// V2 engine is the default for ManagedSession
ManagedSession session = await new ManagedSessionBuilder(configuration, telemetry)
.UseEndpoint(endpoint)
.ConnectAsync(ct);
var handler = new MyNotificationHandler(); // : ISubscriptionNotificationHandler
ISubscription subscription = session.AddSubscription(handler,
new Opc.Ua.Client.Subscriptions.SubscriptionOptions
{
PublishingInterval = TimeSpan.FromMilliseconds(500),
KeepAliveCount = 10,
LifetimeCount = 100
});
subscription.TryAddMonitoredItem(
"ServerStatus_CurrentTime",
VariableIds.Server_ServerStatus_CurrentTime,
o => o with
{
SamplingInterval = TimeSpan.FromMilliseconds(250),
QueueSize = 10
},
out IMonitoredItem _);
V2 notification pooling (opt-in)
The V2 subscription engine supports activator-level pooling of
notification payload instances to reduce GC pressure on
high-throughput publish loops. Pooling is opt-in and disabled by
default. Enable it via ManagedSessionBuilder.WithPoolNotifications(),
the ManagedSessionOptions.PoolNotifications init property, or by
setting ISubscriptionManager.PoolNotifications = true directly on
the V2 manager:
ManagedSession session = await new ManagedSessionBuilder(configuration, telemetry)
.UseEndpoint(endpoint)
.WithPoolNotifications() // opt in
.ConnectAsync(ct);
How it works:
- Types that opt in to pooling implement
IPooledEncodeable(a subinterface ofIEncodeable) and provide aReuse()method that resets fields and returns the instance to its activator's pool. The activator derives fromPooledEncodeableType<T>instead ofEncodeableType<T>and owns a bounded, lock-free pool. - When
PoolNotificationsistrue, the V2 subscription dispatcher walks eachDataChangeNotification/EventNotificationListin afinallyblock after the handler await completes, callingReuse()on every payload item that implementsIPooledEncodeable. Types that do not implement the interface are skipped silently — the walk is universally safe. - The walk runs on the subscription's single-reader
ProcessMessageAsyncloop, so it never races the channel decode that produced the payload.
Handler contract — retain by copy
When PoolNotifications is enabled, handlers must not retain a
reference to a DataChangeNotification / EventNotificationList /
MonitoredItemNotification / EventFieldList past the await of
the dispatch call. The pool may re-rent those instances to the next
publish immediately after Reuse() runs. Handlers that need to
keep values must copy them out before returning:
public ValueTask OnDataChangeNotificationAsync(
ISubscription subscription, uint sequenceNumber,
DateTime publishTime,
ReadOnlyMemory<DataValueChange> notification,
PublishState publishStateMask, IReadOnlyList<string> stringTable)
{
foreach (DataValueChange change in notification.Span)
{
// OK: DataValueChange is a struct, captured by value.
// Value (DataValue) and DiagnosticInfo are not themselves
// pooled in this design, so storing them past the call is
// safe.
m_history.Add(new MyHistoryEntry(
change.MonitoredItem?.NodeId,
change.Value, // safe to retain
change.DiagnosticInfo));
}
return default;
}
The DataValueChange and EventNotification projection structs
are designed not to surface a reference to a pooled instance —
they project the inner DataValue / ArrayOf<Variant> directly,
which are not themselves pooled. Handlers can safely copy
DataValueChange / EventNotification by value and continue to
use them after the await.
What is not safe under pooled mode:
- Retaining the outer
DataChangeNotification/EventNotificationListreference past the await. - Retaining individual
MonitoredItemNotification/EventFieldListreferences past the await. - Calling
Reuse()on a retained reference after the framework has already done so —Reuse()is idempotent against accidental double-call (it uses a per-instance sentinel), but the pool may have already re-rented the instance, in which case the lateReuse()steals it from its current consumer.
The dispatcher is the only framework-initiated Reuse() caller.
Handler code that follows the retain-by-copy rule never reaches
the unsafe pattern.
Server-side request/response pooling
On the server side, decoded IServiceRequest and IServiceResponse
objects are automatically returned to their activator pools after the
service handler completes and the response is encoded to the wire.
This is unconditional — no opt-in flag is required — because the
server framework owns the full lifecycle of both objects:
- The request is decoded by the channel, consumed by the service handler, and never exposed to application code by reference.
- The response is constructed by the handler, encoded by the channel's
WriteSymmetricMessage/BinaryEncoder.EncodeMessagepath, and then has no further consumers.
The reuse calls are placed in finally blocks in
TcpTransportListener.OnRequestReceivedAsync (UA-TCP transport) and
EndpointBase.InvokeServiceAsync (HTTPS transport), ensuring both
objects are released regardless of success or failure.
Server-side node managers and service handlers do not need any code changes to benefit from this pooling — it is transparent at the channel/transport layer.
Choosing an engine
| Scenario | Engine |
|---|---|
Existing code using session.AddSubscription(new Subscription(...)) and the classic event-driven API |
ClassicSubscriptionEngine (the default for raw Session) |
| New code, options-based subscriptions, DI / Microsoft.Extensions.Options friendly | DefaultSubscriptionEngine (the default for ManagedSession) |
| Migrating a managed session but you still need a classic subscription you cannot rewrite | ManagedSession with UseSubscriptionEngine(ClassicSubscriptionEngineFactory.Instance); SubscriptionManager then throws InvalidOperationException and you keep using Subscriptions |
The two engines can co-exist on a ManagedSession:
ManagedSession.Subscriptions (classic API) keeps working alongside
ManagedSession.SubscriptionManager (V2 API) when the V2 engine is
active. Internally, classic subscriptions are bridged onto the V2
publish pipeline.
5. The INodeCache surface
Session.NodeCache (and ManagedSession.NodeCache) returns
INodeCache, the unified client-side cache contract. As of 2.0 it is
the single contract — the previous ILruNodeCache parallel interface
has been merged into it and removed.
The interface deliberately exposes two complementary lookup families:
Find*/Fetch*— take anExpandedNodeId, return a nullable result, may fetch from the server.Find*consults the cache first;Fetch*always re-reads from the server and updates the cache.Get*— take a localNodeId, return a non-nullable result (throws when the node cannot be resolved). LRU-style direct hit; cheaper for in-process callers that already hold a localNodeIdand a known namespace index.
Both families coexist on INodeCache because the lifecycle and error
semantics differ. All async methods return ValueTask /
ValueTask<T>; only void Clear() is synchronous (pure local-state
mutation).
For migration details see
Migration Guide — INodeCache consolidation.
6. Putting it all together
Pick the entry point that best matches your call site:
- Already have a
Sessionand an existing reconnect helper. KeepSession+SessionReconnectHandler. They are first-class APIs in 2.0 and not deprecated. You may opt into the V2 subscription engine by passingSubscriptionEngineFactory = DefaultSubscriptionEngineFactory.InstancetoDefaultSessionFactory. - New service / ASP.NET Core / generic-host application. Use
services.AddOpcUa().AddClient(...)and resolveFunc<CancellationToken, Task<ManagedSession>>from DI. - New code without DI. Use
ManagedSessionBuilder.ConnectAsync(...). - Drop-in replacement for
DefaultSessionFactorythat wants managed reconnect. Switch toManagedSessionFactory— the public surface (ISessionFactory) is identical. - Need to share a single underlying
Sessionacross two reconnect drivers, or wrap aManagedSessionwithSessionReconnectHandler. Don't. The reconnect drivers race, andSessionReconnectHandlerwill throwNotSupportedExceptionto enforce this.
See also
- Migration Guide — ManagedSession and Automatic Reconnection
- Migration Guide — Fluent Builder, V2 Subscriptions, and DI
- TransferSubscription — server-driven session-handoff support.
- Observability — telemetry plumbed through
ITelemetryContexton every factory and session type. - Reverse Connect — works with both
SessionandManagedSession.