Files
UA-.NETStandard/docs/migrate/2.0.x/sessions-subscriptions.md
efad0ddeff Server subscriptions: fix spec defects in Subscription/MonitoredItem handling found by audit (#4629)
# Description

Fixes defects found in a spec-grounded audit of the server
**Subscription / MonitoredItem** implementation
(`src/Opc.Ua.Server/Subscription/**`, `NodeManager/MonitoredItem/**`,
`EventManager`, monitored-item service dispatch and filter validation).
Each behaviour change was checked against OPC 10000-4 (§5.13, §5.14,
§7.21, §7.22), Part 5 (subscription diagnostics) and Part 9
(ConditionRefresh). Every fix has a regression test.

The audit found 56 defects (1 Critical, 3 High, 20 Medium, 32 Low). A
follow-up max-effort code review of the fix branch found 27 more issues,
mostly introduced by the first round of fixes. All but one are fixed
here.

### Most important fixes
- **Critical — remote hang:** an `AggregateFilter` with
`processingInterval = NaN` was not revised, and `AggregateCalculator`
looped forever while holding the node-manager lock, so one
CreateMonitoredItems request blocked the node manager. Non-finite, ≤0
and sub-tick intervals are now revised (Part 4 §5.13.2.1), and the
calculator rejects them as a backstop.
- **Monitored-item limits:** new
`ServerConfiguration.MaxMonitoredItemCount` /
`MaxMonitoredItemsPerSubscription`; Bad_TooManyMonitoredItems is
returned when a limit is hit, and ServerCapabilities now report the
limits. Both default to `0` (no limit), so existing behaviour is
unchanged.
- **Sampling groups:** a client could get one dedicated thread per
distinct sampling interval above 1 h; intervals above the last rate
group are now rounded, and the loop is async. An item could also end up
in two sampling groups and keep being sampled after it was deleted.
- **Subscription state machine** (Part 4 §5.14.1.2):
- the lifetime counter resets while a Publish is queued and counts
otherwise;
  - the lifetime and keep-alive stay within `MaxSubscriptionLifetime`;
  - LatePublishRequestCount is counted once per entry into LATE;
- messages held while publishing is disabled are no longer sent, and are
not offered by Transfer/Republish either;
- ConditionRefresh returns Bad_RefreshInProgress for any refresh already
queued or running on the subscription.
- **Triggering** (Part 4 §5.13.1.6):
- linked items are triggered by an item still draining a backlog, by the
first value after re-enable, and by an item created Disabled;
  - links are persisted for durable subscriptions;
- Disabled/Sampling items follow the publish rules in the shared
pipeline, so custom `IMonitoredItem` implementations follow them too.
- **Queues** (Part 4 §5.13.1.5, §7.21):
  - no duplicate value after a queue-size increase;
- EventQueueOverflow events are placed where the events were lost,
including on queue shrink;
  - the duplicate-event check scans the newest entries;
- EventQueueOverflowCount and MonitoringQueueOverflowCount are
maintained correctly;
- changing select clauses rebuilds queued events in the new layout, and
is safe on durable queues and on failure.
- **Filters:**
- per-clause EventFilterResult instead of rejecting the whole item (Part
4 §7.22.3);
- corrected status codes: Bad_DeadbandFilterInvalid,
Bad_MonitoredItemFilterInvalid, Bad_EventFilterInvalid,
Bad_MonitoringModeInvalid, Bad_TimestampsToReturnInvalid;
- aggregate filter in subscriptions: processing interval at least 2× the
revised sampling interval, revised StartTime on the requested grid,
ServerTimestamp = end of interval (Part 4 §7.22.4).
- **Lifecycle and races:**
- expired subscriptions are always cleaned up, even when the session
closes during cleanup;
  - Create/Transfer racing CloseSession returns Bad_SessionClosed;
  - session diagnostics counts no longer drift;
  - a large but valid Publish TimeoutHint no longer fails the request;
- a ready subscription is served by queued Publish requests instead of
going Late;
  - restored persisted state is validated.
- **Access:** a value change notification is checked against each
subscriber's UserAccessLevel, not the writer's.
- **Redundancy / durable store:**
  - the first-unsent sequence number is mirrored;
  - sequence numbers are no longer reused after a second failover;
- triggering links are stored (definition format 6, written only when
links exist, so older replicas can still read link-less subscriptions).

### Behaviour changes reviewers should know
- With the default config, publishing intervals above
`MaxSubscriptionLifetime / 3` (1,200,000 ms) are revised down, so
lifetime ≥ 3×keep-alive fits the configured maximum.
- The revised keep-alive is capped at a third of the max lifetime.
- Idle subscriptions expire after exactly MaxLifetimeCount cycles
without a queued Publish.
- A partly invalid EventFilter select list is accepted with per-clause
errors. The public `EventFilter.Validate` keeps Status Good if at least
one clause is valid; callers that cannot return per-clause results must
also check the new `EventFilter.Result.HasSelectClauseErrors`. There is
a migration note in `docs/migrate/2.0.x/sessions-subscriptions.md`.
- Aggregate processing intervals may be revised higher (≥ 2× sampling
interval); aggregate ServerTimestamp is now the interval end.
- Sampling intervals above 3,600,000 ms are rounded to multiples of
900,000 ms (default rate groups).

### Public API
Additive only:
- `ServerConfiguration.MaxMonitoredItemCount` and
`MaxMonitoredItemsPerSubscription`;
- `EventFilter.Result.HasSelectClauseErrors`;
- `IStoredSubscriptionTriggering`,
`ISubscriptionRetransmissionSendStateStore`,
`SubscriptionRetransmissionState.FirstUnsentSequenceNumber` (optional
store capabilities, detected with `as`);
- new `EventQueueHandler` constructor overloads.

### Known open items (not in this PR)
- **Part 4 §5.13.2.1:** CreateMonitoredItems should create an item even
when the user lacks Read permission and report the error in Publish.
This is not done: the create-time RolePermissions check is the only role
check on the initial value, so following the spec would leak values
until every node manager re-checks per sample.
- **Sampling loop:** it runs on thread-pool threads; node managers with
blocking synchronous reads should not use sampling groups heavily. The
alternative, a dedicated thread per group, was the thread-exhaustion
issue fixed here.
- **Restarts:** a pending QueueSize-1 value and a pending overflow flag
are not kept across a restart; that needs persistence interface
extensions.
- **Limit defaults:** decide whether the new limits should get finite
defaults (currently 0 = unlimited).

## Related Issues

- No tracking issue; the findings come from an internal audit of the
server subscription code.

## Checklist

- [ ] 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.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [ ] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

### Local verification (net10.0, all green)
| Suite | Passed | Failed |
|---|---|---|
| Opc.Ua.Server.Tests | 7963 | 0 |
| Opc.Ua.Subscriptions.Tests | 643 | 0 |
| Opc.Ua.Subscriptions.Classic.Tests | 57 | 0 |
| Opc.Ua.Subscriptions.Durable.Tests | 36 | 0 |
| Opc.Ua.Redundancy.Server.Tests | 847 | 0 |
| Opc.Ua.Core.Tests (filter tests) | 283 | 0 |

net48 build of `Opc.Ua.Server.Tests`: 0 errors. net48 tests and the full
UA.slnx were not run locally.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
2026-10-03 05:43:16 +02:00

41 KiB
Raw Permalink Blame History

Sessions, GDS Client, and Subscriptions

When to read this: Read this for the move from caller-driven Session reconnect to ManagedSession, the V2 subscription engine, the GDS-client Task -> ValueTask API modernisation, removed obsolete GDS APIs, durable subscriptions, removed ReverseConnectClientCollection, and transport abstraction changes.

Contents

GDS Client API modernization

The Opc.Ua.Gds.Client package has undergone a significant cleanup. Two breaking changes affect almost every consumer of the GDS / LDS / Server-Push client APIs.

Task → ValueTask on GDS client interfaces

Breaking Change: All asynchronous methods on IGlobalDiscoveryServerClient, ILocalDiscoveryServerClient, and IServerPushConfigurationClient (and their concrete implementations) now return ValueTask / ValueTask<T> instead of Task / Task<T>.

Rationale: Many GDS operations complete synchronously when a session is already established. Returning ValueTask avoids the per-call Task allocation on those fast paths and keeps the surface consistent with the rest of the modernized client stack.

Impact: Pure await callers require no change — await works identically on Task and ValueTask. However, two patterns require a small adjustment.

Pattern Old (Task) New (ValueTask)
await on the return value works works (no change)
Block synchronously via .Result / .Wait() works use .AsTask().Result / .AsTask().Wait()
Combine results with Task.WhenAll / Task.WhenAny works call .AsTask() first
Await the same return value more than once works not supported — call .AsTask() first

Important

: A ValueTask may be awaited only once and the underlying value source must not be observed after the operation has completed. If you need to await a result more than once, fan it out across multiple consumers, or pass it to anything other than a single await, materialize it via .AsTask() first.

// Before
Task<NodeId> registration = gds.RegisterApplicationAsync(application, ct);
NodeId id = await registration;
await Task.WhenAll(registration, otherTask);          // worked

// After
ValueTask<NodeId> registration = gds.RegisterApplicationAsync(application, ct);
NodeId id = await registration;                       // unchanged

// Multi-await / Task.WhenAll: materialize first
Task<NodeId> asTask = gds.RegisterApplicationAsync(application, ct).AsTask();
await Task.WhenAll(asTask, otherTask);

Removal of obsolete GDS APIs

Breaking Change: All [Obsolete] synchronous wrappers, APM (Begin*/End*) methods, and other deprecated members have been removed from the GDS client surface.

Affected APIs (non-exhaustive):

  • All synchronous wrappers on GlobalDiscoveryServerClient (~25 methods such as FindApplication, RegisterApplication, StartNewKeyPairRequest, …) — use the corresponding *Async overload returning ValueTask/ValueTask<T>.
  • All synchronous wrappers on ServerPushConfigurationClient (~14 methods such as UpdateCertificate, ReadTrustList, ApplyChanges, …) — use the *Async overload.
  • APM (Begin* / End*) overloads on LocalDiscoveryServerClient (e.g. BeginFindServers / EndFindServers) — use the *Async overload.
  • The capability identifier constants are now source-generated as Opc.Ua.ServerCapability (singular, e.g. ServerCapability.GDS, ServerCapability.LDS, ServerCapability.DA). The [Obsolete] public const string shims previously exposed on the value-type ServerCapability class (now ServerCapabilityInfo in Opc.Ua.Gds.Client) have been removed. The runtime ServerCapabilities.csv parsing path (which never actually loaded — the resource was not embedded) has been replaced by the generated dictionary ServerCapability.All. The instance enumerable previously named ServerCapabilityCatalog is now Opc.Ua.Gds.Client.ServerCapabilities and its Find returns ServerCapabilityInfo.
  • RegisteredApplication is now a sealed record; the obsolete extension methods that wrapped its property access have been removed — use the record properties directly.
  • CertificateWrapper is now sealed and no longer implements IEncodeable; remove any code that treated it as an encodeable.

Migration:

The ServerCapability identifiers are source-generated from tools/Opc.Ua.SourceGeneration.Core/Design/ServerCapabilities.csv; each capability emits a public const string field. The instance type carrying Id / Description is ServerCapabilityInfo, and the registry exposing IEnumerable<ServerCapabilityInfo> plus Find(string?) : ServerCapabilityInfo? is the static ServerCapabilities class in Opc.Ua.Gds.Client.

// Before
var apps = gds.FindApplication(uri);                       // sync wrapper
var caps = ServerCapability.GlobalDiscoveryServer;         // obsolete shim

// After
var apps = await gds.FindApplicationAsync(uri, ct);
string id = ServerCapability.GDS;                          // const string "GDS"
ServerCapabilityInfo? info = ServerCapabilities.Find(id);  // null if not registered

If you currently rely on a [Obsolete] member, switch to the Async equivalent and apply the ValueTask migration notes above. If a particular API has no direct replacement, the migration is described inline in the XML doc comment of the replacement member.

ManagedSession and Automatic Reconnection

Version 2.0 introduces ManagedSession, a wrapper around Session that automatically handles connection lifecycle including reconnection and server redundancy failover.

Key Changes:

  • ManagedSessionFactory is a new factory that creates ManagedSession instances which handle reconnection and failover automatically. Use this when you want managed-session behavior.
  • DefaultSessionFactory is unchanged — it continues to create raw Session instances. Existing code that constructs DefaultSessionFactory directly keeps the same behavior in 2.0.
  • SessionReconnectHandler is retained as a supported legacy entry point for callers that already manage raw Session instances. The type itself is not removed. Its parameterless legacy constructor remains marked [Obsolete("Use SessionReconnectHandler(ITelemetryContext, bool, int) instead.")] in 2.0 (the same attribute was already present in 1.5.378); pass an ITelemetryContext to the new ctor when adopting it. It now also requires the wrapped ISession to be a Session (or a derived type) — passing a ManagedSession (or any other ISession facade) throws NotSupportedException, since those facades drive their own reconnect / failover state machine. New code should still prefer ManagedSessionFactory / ManagedSession.CreateAsync.

For a deeper architectural picture of how Session, ManagedSession, SessionReconnectHandler, and the subscription engines fit together, see Sessions, Reconnection, and Subscription Engines.

Migration:

If you use DefaultSessionFactory: No code changes are required — DefaultSessionFactory still returns raw Session. To opt into automatic reconnection and redundancy failover, switch to ManagedSessionFactory:

// Still supported in 2.0 — DefaultSessionFactory creates raw Session:
var defaultFactory = new DefaultSessionFactory(telemetry);
ISession rawSession = await defaultFactory.CreateAsync(...);

// Opt in to managed reconnect/failover — ManagedSessionFactory creates ManagedSession:
var managedFactory = new ManagedSessionFactory(telemetry);
ISession managedSession = await managedFactory.CreateAsync(...);

Both factories implement ISessionFactory. ManagedSessionFactory internally uses a DefaultSessionFactory to create the raw Session and then wraps it in a ManagedSession; the public surface is unchanged.

If you implement ISessionFactory yourself: The interface gained two members. SubscriptionEngineFactory returns the engine your sessions use (null for the classic engine), and WithSubscriptionEngine(engineFactory, timeProvider) returns a factory that is identical except for that engine and, when given, the time provider. ManagedSession calls it when your factory has no engine configured, so return a copy rather than modifying the instance. DefaultSessionFactory.SubscriptionEngineFactory is init-only again; use WithSubscriptionEngine to change it after construction. A Moq mock of ISessionFactory passed to ManagedSession must set up either member, since a loose mock returns null from both.

If you use SessionReconnectHandler:

SessionReconnectHandler continues to work in 2.0 against Session instances. The pattern below is unchanged, but the legacy parameterless ctor remains [Obsolete] - prefer the (ITelemetryContext, bool, int) overload:

ISession session = await new DefaultSessionFactory(telemetry).CreateAsync(...);
using var reconnectHandler = new SessionReconnectHandler(telemetry);
session.KeepAlive += (s, e) =>
{
    if (e.Status != null && ServiceResult.IsNotGood(e.Status))
    {
        reconnectHandler.BeginReconnect(session, 1000, OnReconnectComplete);
    }
};

SessionReconnectHandler.BeginReconnect only supports the legacy Session class (or types derived from it). Passing a ManagedSession throws NotSupportedException. If you have already migrated to ManagedSession, do not wrap it with a SessionReconnectHandler — ManagedSession already runs its own reconnect state machine. Use the StateChanged event to observe transitions:

ISession session = await ManagedSession.CreateAsync(
    configuration, endpoint,
    reconnectPolicy: new ReconnectPolicy
    {
        Strategy = BackoffStrategy.Exponential,
        InitialDelay = TimeSpan.FromSeconds(1),
        MaxDelay = TimeSpan.FromSeconds(30)
    });
// Reconnection is automatic — no manual handler needed
((ManagedSession)session).StateMachine.StateChanged += (s, e) =>
{
    Console.WriteLine($"Session state: {e.NewState}");
};

Or, equivalently, via the factory:

var factory = new ManagedSessionFactory(telemetry);
ISession session = await factory.CreateAsync(...);

Configuring Reconnection Policy

Two related types ship side-by-side and are not interchangeable. ReconnectPolicyOptions is a public sealed record with init-only properties - the DTO consumed by dependency injection / ManagedSessionOptions. ReconnectPolicy is a public class (implementing IReconnectPolicy) - the runtime policy passed to ManagedSession.CreateAsync and SessionReconnectHandler. Construct the runtime policy from the options snapshot with new ReconnectPolicy(options); ManagedSessionBuilder.ConnectAsync performs this conversion internally.

var policy = new ReconnectPolicy
{
    Strategy = BackoffStrategy.Exponential,  // or Linear, Constant
    InitialDelay = TimeSpan.FromSeconds(1),
    MaxDelay = TimeSpan.FromSeconds(30),
    MaxRetries = 0,         // 0 = unlimited
    JitterFactor = 0.1      // ±10% jitter
};

Server Redundancy

ManagedSession automatically reads server redundancy information and can failover to backup servers:

var session = await ManagedSession.CreateAsync(
    configuration, endpoint,
    redundancyHandler: new DefaultServerRedundancyHandler());

Service Call Behavior During Reconnect

When the session is reconnecting, service calls (Read, Write, Browse, etc.) automatically wait until the session is reconnected. This is transparent to the caller — no special handling needed. If reconnection fails permanently, calls will throw ServiceResultException.

Fluent Builder, V2 Subscriptions, and Dependency Injection

Version 2.0 introduces a fluent builder for ManagedSession, exposes the new options-based subscription API on the managed session, and adds Microsoft.Extensions.DependencyInjection integration for Azure / ASP.NET Core / generic-host scenarios.

Fluent builder:

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()
    .ConnectAsync(ct);

Build() returns an immutable ManagedSessionOptions snapshot; ConnectAsync() wraps Build() and ManagedSession.CreateAsync(...) so most callers can use the builder directly.

New subscription API on ISession:

The V2 options-based subscription manager (ISubscriptionManager) is exposed on ISession through bool TryGetSubscriptionManager(out ISubscriptionManager? manager) — it returns true and the manager for V2-engine sessions (the default for ManagedSession) and false for classic-engine sessions. The classic Subscriptions property remains available alongside it. Use UseSubscriptionEngine(ClassicSubscriptionEngineFactory.Instance) on the builder if you need the legacy classic engine instead.

Source-breaking (2.0 preview): the earlier throwing ManagedSession.SubscriptionManager property has been removed in favor of ISession.TryGetSubscriptionManager. Replace var manager = session.SubscriptionManager; (which threw InvalidOperationException on classic-engine sessions) with:

if (session.TryGetSubscriptionManager(out ISubscriptionManager? manager))
{
    // use manager (V2 engine)
}

The session.AddSubscription(...) fluent extensions are unchanged.

using Opc.Ua.Client;
using Opc.Ua.Client.Subscriptions;

var handler = new MyNotificationHandler();   // : ISubscriptionNotificationHandler

ISubscription subscription = session.AddSubscription(handler,
    new 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 _);

The SubscriptionOptions and MonitoredItemOptions records used by this API live in Opc.Ua.Client.Subscriptions and Opc.Ua.Client.Subscriptions.MonitoredItems. They are distinct from the classic types of the same names in the Opc.Ua.Client namespace; use namespace aliases (or fully-qualified names) when both are visible in the same file. Both records ship in the same assembly (Opc.Ua.Client.dll), so a using-alias is sufficient - extern alias is not required:

using ClassicSubscriptionOptions = Opc.Ua.Client.SubscriptionOptions;
using V2SubscriptionOptions      = Opc.Ua.Client.Subscriptions.SubscriptionOptions;

The classic ManagedSession.Subscriptions collection (V1 Subscription objects) remains supported. Mixing classic subscriptions with the V2 manager on the same session is allowed for the time being, but this will change in future releases; classic subscriptions still receive notifications via the internal SubscriptionBridge when the V2 engine is active.

Opt-in V2 notification pooling (WithPoolNotifications):

The V2 subscription engine supports activator-level pooling of decoded notification payload instances (DataChangeNotification, MonitoredItemNotification, EventNotificationList, EventFieldList) to reduce GC pressure on high-throughput publish loops. Pooling is opt-in and disabled by default. Enable it on the builder, in ManagedSessionOptions, or directly on the V2 manager:

ManagedSession session = await new ManagedSessionBuilder(configuration, telemetry)
    .UseEndpoint(endpoint)
    .WithPoolNotifications()        // opt in
    .ConnectAsync(ct);

When pooling is enabled, the V2 dispatcher walks each decoded notification after the handler await completes and calls IPooledEncodeable.Reuse() on every payload item, returning instances to their static activator pools. The recorded benchmarks show ~315× fewer allocations per MonitoredItemNotification and a corresponding drop in gen-0 GC pressure (see docs/Benchmarks.md → Pooled encodeable).

Handler contract change (only when WithPoolNotifications is enabled): Handlers must not retain references to notification objects 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 of the dispatched struct before returning. The DataValueChange / EventNotification projection structs are designed not to surface pooled instances directly — copy-by-value of the struct itself is safe and is the recommended pattern. See docs/Sessions.md for full detail and a code example.

// UNSAFE - captures a pooled instance across await
handler.OnDataChange = async (notif, ct) =>
{
    log.Add(notif);     // notif may be re-rented on the next publish
    await Task.Yield();
};

// SAFE - value-copy the projection struct before suspending
handler.OnDataChange = async (notif, ct) =>
{
    var snapshot = notif;
    log.Add(snapshot);
    await Task.Yield();
};

This affects only the V2 engine; the classic subscription engine is unaffected. There is no breaking change to IEncodeable, IDecoder, IServiceMessageContext, or ISubscriptionNotificationHandler — pooling is opt-in via the new IPooledEncodeable sub-interface, which only the source-generated publish-payload types implement today.

Dependency Injection:

services.AddOpcUa().AddClient(...) registers a ManagedSession factory delegate that lazily connects on first use:

using Microsoft.Extensions.DependencyInjection;
using Opc.Ua.Client;

services.AddOpcUa().AddClient(opt =>
{
    opt.Configuration = applicationConfiguration;
    opt.Session = new ManagedSessionOptions
    {
        Endpoint = endpoint,
        ReconnectPolicy = new ReconnectPolicyOptions
        {
            Strategy = BackoffStrategy.Exponential
        }
    };
});

// Resolve and connect on first use:
var sessionFactory = serviceProvider
    .GetRequiredService<Func<CancellationToken, Task<ManagedSession>>>();
ManagedSession session = await sessionFactory(ct);

The factory caches the connected session — subsequent awaits return the same instance. The registered delegate type is Func<CancellationToken, Task<ManagedSession>> (the OPC UA client APIs use Task here, not ValueTask), so resolving it from dependency injection and await-ing the result returns the connected ManagedSession. The dependency injection registration also exposes ITelemetryContext, ISessionFactory (a DefaultSessionFactory configured with the V2 engine), ManagedSessionFactory, and the top-level OpcUaClientOptions.

This iteration uses single-instance options (no named/keyed registrations); the underlying V2 manager consumes options via IOptionsMonitor<T> unfiltered. For one-off use, the AddSubscription/TryAddMonitoredItem extensions adapt plain options snapshots into the required IOptionsMonitor<T> automatically. Named-options dependency injection is deferred to a future iteration.

Server Session Activation and Subscription Transfer

ISession.ValidateBeforeActivateAsync(...) is now the direct activation-validation contract. Custom server ISession implementations must validate the application signature and user identity token asynchronously and return the validated token handler and matching UserTokenPolicy. The synchronous ValidateBeforeActivate(...) member remains available but is obsolete; migrate callers and implementations to the asynchronous member.

ISubscription now exposes IsTransferIdentityCompatible(ISession targetSession). Custom server subscription implementations must compare the authenticated owner of the target Session with the identity that owns the subscription and return false when the transfer would cross ClientUserIds.

Identity continuity across a SecureChannel transfer or a Subscription transfer is compared using an encoded continuity key rather than the human-readable ClientUserId that OPC 10000-5 reports through SessionSecurityDiagnostics. The key includes the UserTokenType and delimits the issuer from the subject, so a UserNameIdentityToken whose user name equals an X.509 certificate subject — or two issued tokens whose issuer and subject concatenate to the same string — are no longer treated as the same owner. Transfers that previously succeeded only because of such a collision are now rejected with Bad_IdentityChangeNotSupported (activation) or Bad_UserAccessDenied (subscription transfer). The reported ClientUserIdOfSession diagnostic value is unchanged.

SessionManager.OnSessionActivatedAsync takes an additional long activationSequence parameter. The value increases with every successful activation of a Session and is stamped while the per-Session activation gate is still held. Because the callback itself runs after that gate is released, implementations that persist activation state outside the gate must use the sequence to discard writes that a newer concurrent activation has already superseded.

Subscriptions and Transports

Durable subscriptions and reshaped Subscription tree

Source-breaking. Durable subscription support reshapes the subscription tree on both the client and the server. On the client side, the new public surface in src/Opc.Ua.Client/Subscription/ includes ISubscription, ISubscriptionManager, SubscriptionOptions, and MonitoredItemOptions - these are the V2 options-based shapes; the classic Opc.Ua.Client.Subscription continues to ship alongside them. On the server side, the new public surface in src/Opc.Ua.Server/Subscription/... includes DataChangeMonitoredItemQueue, EventMonitoredItemQueue, IDataChangeMonitoredItemQueue, IMonitoredItemQueueFactory, ISubscriptionStore, IStoredSubscription, StoredSubscription, and StoredMonitoredItem.

Consumers adopting the new shape may need to add a using Opc.Ua.Client.Subscriptions; import alongside the existing using Opc.Ua.Client;. Because the V2 records share their type names with the classic records, namespace aliases are required when both are visible in the same file - see Fluent Builder, V2 Subscriptions, and Dependency Injection for the canonical alias snippet.

The server-side ISubscriptionStore definition-persistence methods are asynchronous: StoreSubscriptions is now ValueTask<bool> StoreSubscriptionsAsync(IEnumerable<IStoredSubscription>, CancellationToken), RestoreSubscriptions is now ValueTask<RestoreSubscriptionResult> RestoreSubscriptionsAsync(CancellationToken), and OnSubscriptionRestoreComplete is now ValueTask OnSubscriptionRestoreCompleteAsync(Dictionary<uint, ArrayOf<uint>>, CancellationToken). This lets subscription definitions be persisted to an async network backend without a sync-over-async wrapper. Custom ISubscriptionStore implementations must adopt the async signatures; a synchronous body can simply return new ValueTask<T>(result).

ISubscriptionStore also adds asynchronous per-monitored-item queue-restore hooks - ValueTask<IDataChangeMonitoredItemQueue?> RestoreDataChangeMonitoredItemQueueAsync(uint, CancellationToken) and ValueTask<IEventMonitoredItemQueue?> RestoreEventMonitoredItemQueueAsync(uint, CancellationToken) - so a networked store can re-hydrate a monitored-item queue without blocking. The master node manager pre-fetches these queues asynchronously at the start of subscription restore and hands the pre-hydrated queue to the (still synchronous) MonitoredItem constructor via new runtime-only IStoredMonitoredItem.RestoredDataChangeQueue/RestoredEventQueue properties. Custom ISubscriptionStore implementations must add the two async members; a store that keeps queues locally can simply delegate to the existing synchronous RestoreDataChangeMonitoredItemQueue/RestoreEventMonitoredItemQueue, which remain as the fallback used when no pre-hydrated queue is supplied. Custom IStoredMonitoredItem implementations must add the two new properties (the built-in StoredMonitoredItem already does). The high-availability SharedKeyValueMonitoredItemQueueFactory uses these hooks to restore mirrored queue contents on failover (see High Availability).

Request completion is owned by OperationContext

Source-breaking for custom servers. OperationContext is now IDisposable and owns the scope that tracks the request while it executes. Service handlers obtain it with using OperationContext context = await ValidateRequestAsync(...), and disposing it reports the request as completed. RequestManager.RequestCompleted(OperationContext) is [Obsolete]: a context that is disposed completes its own request, and completing it twice is a no-op. Code that creates an OperationContext directly must dispose it; leaving one undisposed keeps the request registered and blocks NodeManager lifecycle operations until they time out.

StandardServer.ValidateRequestAsync is no longer virtual. A subclass that rejected a request after calling base left it registered forever, because the caller never received the context and nothing disposed it. Override protected virtual ValueTask OnRequestValidatedAsync(OperationContext) instead; throwing from it rejects the request and completes it.

Opc.Ua.Server.ISession: IsClosing and InvalidateContinuationPoints

Source-breaking for custom implementations. This is the server-side Opc.Ua.Server.ISession, implemented by the server's Session — not the client-side Opc.Ua.Client.ISession discussed above. It gains bool IsClosing and void InvalidateContinuationPoints(IAsyncNodeManager).

IsClosing reports that the Session is being torn down, so work that would create new state for it is rejected instead of started. It is one way: a Session that started closing never serves new work again, even if the close itself fails. InvalidateContinuationPoints drops the saved Browse and history continuation points that would otherwise keep a retired NodeManager reachable.

Custom implementations must add both. Returning false from IsClosing and doing nothing in InvalidateContinuationPoints preserves the previous behaviour; the built-in server Session already implements them.

Opc.Ua.Server.ISubscription: the publish pipeline is server-internal

Source-breaking for custom implementations and for callers. This is the server-side Opc.Ua.Server.ISubscription — not the client-side Opc.Ua.Client.Subscriptions.ISubscription introduced by the V2 subscription shape above. Twelve members leave the interface (analyzer UA0030):

  • ItemReadyToPublish and ItemNotificationsAvailable are deleted. Their implementation bodies had been commented out since 1.5.x, so every call was a no-op — delete the calls and the implementations.
  • The parameterless obsolete SessionClosed() is deleted; session teardown goes through ISubscriptionManager.SessionClosingAsync.
  • TransferSessionAsync is deleted; the server transfers subscriptions through its internal claim/prepare/commit protocol, reached via the TransferSubscriptions service (ISubscriptionManager.TransferSubscriptionsAsync).
  • PublishTimerExpired, Acknowledge, PublishTimeout, SubscriptionTransferred, AvailableSequenceNumbersForRetransmission, QueueOverflowHandler, SessionClosed(ISession) and Publish move to an internal contract implemented only by the built-in Subscription. They mutate the publishing state machine and are called only by SubscriptionManager and the (now likewise internal) SessionPublishQueue; on the public interface, any holder of an ISubscription could call them and corrupt publishing.

Custom subscription implementations must derive from Subscription. A SubscriptionManager.CreateSubscription override that returns an ISubscription implementation not derived from Subscription previously worked partially (the pipeline drove it through the public members, but transfer already required the concrete type); it now fails at creation with Bad_InternalError. Derive from Subscription instead — the interface members that remain (ResendData, GetMonitoredItems, the monitored-item operations, Modify, SetPublishingMode, Republish, IsTransferIdentityCompatible and the durable members) keep their meaning, and the pipeline members are explicit implementations the runtime drives for you. Implementations that had added SessionClosed(ISession) per the earlier 2.0 guidance delete it together with the rest of the pipeline members.

SessionPublishQueue is internal for the same reason (SentMessageQueue already was): the queue is the other half of the publish protocol. Code that constructed or drove one should use the Publish service path via ISubscriptionManager.PublishAsync.

For the public operations that remain, obtain a subscription with ISubscriptionManager.TryGetSubscription before calling ResendData, GetMonitoredItems, or the monitored-item operations. Acknowledgements belong in the Publish request, not in direct calls into the subscription's internal publishing state machine.

EventFilter.Validate accepts partially valid select clauses

Behaviour change. EventFilter.Validate(IFilterContext) no longer sets Result.Status to Bad_EventFilterInvalid when only some select clauses are invalid. Following Part 4 §7.22.3, the server creates (or modifies) an event monitored item as long as at least one select clause is valid: each rejected clause is reported in EventFilterResult.SelectClauseResults and its event field is returned as null. Result.Status stays Bad when there is no select clause, no valid select clause, or an invalid where clause.

The new EventFilter.Result.HasSelectClauseErrors reports whether any select clause was rejected. Code that validates an EventFilter itself and cannot return per-clause results (a custom HistoryRead or HistoryUpdate of events, an event store, a custom node manager) must check it alongside the status to keep the 1.5.x behaviour:

EventFilter.Result result = filter.Validate(filterContext);
if (ServiceResult.IsBad(result.Status) || result.HasSelectClauseErrors)
{
    return StatusCodes.BadEventFilterInvalid;
}

The built-in CustomNodeManager2, AsyncCustomNodeManager and the historian event update validator already do this, so HistoryRead and HistoryUpdate of events still reject such filters.

PubSub

PubSub has source-breaking package and API changes. See the PubSub migration guide for removed application/connection types, builder replacements, JSON encoding changes, and wire-compatibility limits. Dependency changes are maintained in the package guide.

Reverse connect

ReverseConnectManager, ReverseConnectProperty, and ReverseConnectServer retain the same public shape in 2.0. The ReverseConnectClientCollection wrapper is removed, which is source-breaking for callers using that collection. See Configuration collection types removed.

IMessageSocket abstraction removed

The runtime transport boundary moved from IMessageSocket to the new public IUaSCByteTransport (Opc.Ua.Bindings). This let us share one UASC pipeline across raw TCP and WebSocket connections and let JSON profiles bypass UASC entirely. As part of this change the entire IMessageSocket family was removed from the public API surface — this is a breaking change versus 1.5.378.

Removed type Replacement
IMessageSocket, IMessageSocketAsyncEventArgs, IMessageSink, IMessageSocketChannel IUaSCByteTransport (chunk-level send / receive); typical consumers use ITransportChannel instead
IMessageSocketFactory IUaSCByteTransportFactory
MessageSocketExtensions (e.g. BeginConnect) IUaSCByteTransport.ConnectAsync
TcpMessageSocket, TcpMessageSocketFactory, TcpMessageSocketAsyncEventArgs TcpByteTransport (sealed; TcpByteTransportFactory for client-side construction). The public TcpTransportChannel / TcpTransportChannelFactory shapes are unchanged.
UaSCUaBinaryTransportChannel.Socket (IMessageSocket?) UaSCUaBinaryTransportChannel.Transport (IUaSCByteTransport?)
UaSCUaBinaryClientChannel(..., IMessageSocketFactory, ...) ctor UaSCUaBinaryClientChannel(..., IUaSCByteTransportFactory, ...) ctor
ITcpChannelListener.ReconnectToExistingChannel(IMessageSocket, ...) ITcpChannelListener.ReconnectToExistingChannel(IUaSCByteTransport, ...)

If you previously implemented a custom IMessageSocket (rare in practice — almost no consumer subclasses TcpMessageSocket): the recommended migration path is to implement IUaSCByteTransport directly. See docs/Transports.md § "Implementing a custom byte transport" for the contract, an implementation checklist, and a worked example (the public InProcessTransport reference implementation that consumes only the public surface). The new abstraction is chunk-oriented (one Send / Receive per UASC MessageChunk) and exposes only ValueTask-based async; it is intentionally narrower than the old SAEA-based IMessageSocket and most legacy implementations collapse to ~150 lines.

Transport binding registry — TransportBindings static API removed

Source-breaking. The process-wide TransportBindings static class and the Utils.DefaultBindings reflection-based assembly auto-load helper have been removed. The new ITransportBindingRegistry interface (in Opc.Ua.Bindings) is the only public registry surface; it resolves out of the host's IServiceProvider so two hosts (e.g. parallel test fixtures or multi-tenant applications) can install different factories without racing on shared global state.

Removed Replacement
TransportBindings.Channels (static) ITransportBindingRegistry (resolved from DI or constructed via DefaultTransportBindingRegistry.WithDefaultTcp())
TransportBindings.Listeners (static) Same ITransportBindingRegistry — listeners and channels share one keyed registry per scheme
TransportBindings.Channels.SetBinding(...) ITransportBindingRegistry.RegisterChannelFactory(...) or the DI extension AddCustomTransport<,>()
TransportBindings.Listeners.SetBinding(...) ITransportBindingRegistry.RegisterListenerFactory(...) or the DI extension AddKestrelOpcTcpTransport()
TransportBindings.Channels.GetBinding(scheme, telemetry) ITransportBindingRegistry.GetChannelFactory(scheme)
TransportBindings.Listeners.GetBinding(scheme, telemetry) ITransportBindingRegistry.GetListenerFactory(scheme)
TransportBindings.AddBindings(assembly) (reflection) Explicit DI registration via AddOpcTcpTransport() / AddHttpsTransport() / AddWssTransport() / AddKestrelOpcTcpTransport() / AddCustomTransport<,>()
Utils.DefaultBindings dictionary Removed; no replacement needed
ITransportBindings<T> interface Removed (folded into ITransportBindingRegistry per-facet methods)
TransportBindingsBase (reflection helper) Removed

Microsoft.Extensions.DependencyInjection consumers (recommended path):

// Before (1.5.378):
//   TransportBindings.Listeners.SetBinding(new KestrelTcpTransportListenerFactory());

// After (2.0):
services
    .AddOpcUa()
    .AddOpcTcpTransport()              // raw-socket opc.tcp default
    .AddHttpsTransport()               // HTTPS + HTTPS-JSON
    .AddWssTransport()                 // WSS + WSS-JSON
    .AddKestrelOpcTcpTransport();      // override opc.tcp with Kestrel (last-writer-wins)

AddHttpsTransport(), AddWssTransport(), and AddKestrelOpcTcpTransport() all ship in the OPCFoundation.NetStandard.Opc.Ua.Bindings.Https package. Reference that package directly; Server and GDS packages do not pull the optional binding transitively. The Kestrel-hosted opc.tcp:// listener (AddKestrelOpcTcpTransport()) is available on net8.0+ only; on the .NET Framework targets keep the default raw-socket opc.tcp listener that ships in Opc.Ua.Core.

Every Add*Transport() extension installs an ITransportBindingConfigurator instance into the IServiceCollection. The DefaultTransportBindingRegistry singleton runs every registered configurator in registration order at first resolution time, so the last registration for a given URI scheme wins — exactly the same semantics SetBinding had, but scoped per IServiceProvider.

Non-DI consumers:

// Before (1.5.378):
//   TransportBindings.Listeners.SetBinding(new KestrelTcpTransportListenerFactory());
//   var server = new MyServer();
//   server.Start(config); // pre-2.0 sync path

// After (2.0):
DefaultTransportBindingRegistry registry = DefaultTransportBindingRegistry.WithDefaultTcp();
registry.RegisterListenerFactory(new KestrelTcpTransportListenerFactory());
var server = new MyServer(telemetry);
server.TransportBindings = registry;   // public setter; rejected after StartAsync
await server.StartAsync(config, ct);

ServerBase exposes a new constructor overload (ServerBase(ITelemetryContext, ITransportBindingRegistry?)) and a publicly-settable TransportBindings property (with a started-server guard). Non-DI callers that pass nothing get a DefaultTransportBindingRegistry.WithDefaultTcp() on first use — exactly the raw-socket TCP listener the 1.5.378 stack defaulted to.

Custom transport authors:

// Before (1.5.378):
//   TransportBindings.Listeners.SetBinding(new MyCustomListenerFactory());
//   TransportBindings.Channels.SetBinding(new MyCustomChannelFactory());

// After (2.0):
services
    .AddOpcUa()
    .AddCustomTransport<MyCustomListenerFactory, MyCustomChannelFactory>();

The DI extension resolves both factory types out of the container (so they may have constructor-injected dependencies), and both are registered into the registry under the UriScheme exposed by MyCustomListenerFactory.


See also