Files
UA-.NETStandard/Docs/Sessions.md
Marc Schier 8d2402c995 Bump version to 2.0 and land 1.5.378 to 2.0 migration guide (#3819)
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.
2026-05-29 08:18:41 +02:00

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 ITransportChannel created 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 fresh Session that 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 drives Subscription and MonitoredItem directly from Session.
  • DefaultSubscriptionEngineFactory.Instance — wraps the new options-based SubscriptionManager (the V2 subscription pipeline) inside an ISubscriptionEngine adapter so even a raw Session can expose the ISubscriptionManager API.

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 between Disconnected, 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's ServerRedundancy object 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 on ManagedSession.SubscriptionManager out 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. ManagedSessionFactory creates ManagedSession instances. DefaultSessionFactory creates raw Session instances. The two factories are siblings under ISessionFactory; ManagedSessionFactory uses an inner DefaultSessionFactory only 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:

  1. Pauses the service gate so all in-flight and incoming service calls wait.
  2. Drives the configured ReconnectPolicy to compute the next delay (with jitter), bounded by MinDelay / MaxDelay and capped by MaxRetries (where 0 means unlimited).
  3. Calls Session.ReconnectAsync (channel reuse path) and on failure ISessionFactory.RecreateAsync (channel rebuild path), updating the inner session reference atomically.
  4. If the redundancy handler is enabled and the active server is unreachable, it consults IServerRedundancyHandler.GetNextEndpointAsync and recreates the session against the failover endpoint.
  5. On success, transitions Reconnecting → Connected and releases the service gate, replaying any deferred calls.
  6. On exhaustion (MaxRetries reached), transitions to Closed and surfaces a ServiceResultException to 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 Subscription and MonitoredItem classes in Libraries/Opc.Ua.Client/Subscription/Classic.
  • A fire-and-forget publish loop driven from Session.BeginPublish / OnPublishComplete.
  • The Subscriptions collection on ISession and ManagedSession.

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.MonitoredItem records consumed via IOptionsMonitor<T>.
  • A worker-pool publish loop with adaptive worker count.
  • The full subscription-service-set lifecycle: CreateSubscription, ModifySubscription, SetPublishingMode, Republish, plus CreateMonitoredItems, ModifyMonitoredItems, DeleteMonitoredItems, SetMonitoringMode, Call, SetTriggering — all routed back through the Session's service-set client methods.
  • An ISubscriptionNotificationHandler callback contract for OnDataChangeNotificationAsync / 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 of IEncodeable) and provide a Reuse() method that resets fields and returns the instance to its activator's pool. The activator derives from PooledEncodeableType<T> instead of EncodeableType<T> and owns a bounded, lock-free pool.
  • When PoolNotifications is true, the V2 subscription dispatcher walks each DataChangeNotification / EventNotificationList in a finally block after the handler await completes, calling Reuse() on every payload item that implements IPooledEncodeable. Types that do not implement the interface are skipped silently — the walk is universally safe.
  • The walk runs on the subscription's single-reader ProcessMessageAsync loop, 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 / EventNotificationList reference past the await.
  • Retaining individual MonitoredItemNotification / EventFieldList references 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 late Reuse() 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.EncodeMessage path, 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 an ExpandedNodeId, 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 local NodeId, 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 local NodeId and 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 Session and an existing reconnect helper. Keep Session + SessionReconnectHandler. They are first-class APIs in 2.0 and not deprecated. You may opt into the V2 subscription engine by passing SubscriptionEngineFactory = DefaultSubscriptionEngineFactory.Instance to DefaultSessionFactory.
  • New service / ASP.NET Core / generic-host application. Use services.AddOpcUa().AddClient(...) and resolve Func<CancellationToken, Task<ManagedSession>> from DI.
  • New code without DI. Use ManagedSessionBuilder.ConnectAsync(...).
  • Drop-in replacement for DefaultSessionFactory that wants managed reconnect. Switch to ManagedSessionFactory — the public surface (ISessionFactory) is identical.
  • Need to share a single underlying Session across two reconnect drivers, or wrap a ManagedSession with SessionReconnectHandler. Don't. The reconnect drivers race, and SessionReconnectHandler will throw NotSupportedException to enforce this.

See also