Files
UA-.NETStandard/docs/ServerScalability.md
T
romanett 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

8.9 KiB

Server session scalability

This document describes how the reference server scales to large numbers of concurrent client sessions on a single node, what bounds that scale, the built-in controls for degrading gracefully under load, and how to configure the server and scale out. For measured numbers see Performance Benchmarks — Server session scalability.

Contents

How a session is established

A client session is brought up in four stages:

  1. TCP accept — the operating system accepts the socket and the listener creates a channel object.
  2. Secure channel — OpenSecureChannel performs the asymmetric (RSA) handshake: decrypt and signature-verify with the server certificate's private key, and generate nonces.
  3. CreateSession — admission checks (session and channel limits), server-nonce generation and signing, and per-session diagnostics node creation.
  4. ActivateSession — verification of the client signature and the user identity token, and activation of the session.

After activation the session enters steady state: it keeps one long-polled Publish request outstanding and receives notifications as its monitored items change.

What bounds single-node scale

Session establishment is CPU-bound. The dominant cost of bringing up a session is the asymmetric secure-channel handshake — the RSA decrypt, the certificate-chain validation, and the server- and client-signature operations. Handshakes run per channel on the thread pool and parallelize across cores, so establishment throughput scales with the core count, but a large simultaneous connect burst can saturate every core. On a typical multi-core developer machine the server establishes on the order of a thousand or more concurrent sessions cleanly; beyond that, connect throughput is bounded by handshake CPU. This is the fundamental single-node ceiling.

Connect storms amplify overload. If the server is saturated and a handshake is slow enough to time out, a client may retry; the retry can create a fresh server session while the original is left orphaned, adding load rather than relieving it. Unchecked, this is a positive-feedback loop. The server's admission controls break the loop by returning a fast, deterministic busy response that a cooperating client honors, instead of failing deep in the handshake and inviting a retry.

Steady-state delivery is not the bottleneck. Once sessions are up, the asynchronous publish path continues to deliver notifications. A held Publish is asynchronously parked while it waits and, with request decoupling enabled (the default), does not tie up a request-processing worker.

Admission control and rate limiting

The server can shed excess load deterministically rather than aborting work mid-handshake. The full configuration surface — algorithms, limits, and dependency-injection hooks — is described in Rate Limiting and Admission Control. In summary, the server provides:

  • Connection-admission rate limiting at the TCP listener, bounding the rate of new secure-channel handshakes to what the host can absorb.
  • Session-establishment admission: CreateSession and ActivateSession requests beyond the configured concurrency are rejected with BadServerTooBusy, carrying a machine-readable retry-after hint, instead of queuing unboundedly.
  • Incomplete-message capacity: a shared reassembly budget bounds retained chunks across listeners, leaves headroom for activated sessions, and releases abandoned messages at a fixed assembly deadline.
  • HTTPS/Kestrel rate limiting: the HTTPS binding can attach an ASP.NET Core rate limiter through dependency injection.
  • Client-side adaptive backoff: the client honors a server's busy signal — and any retry-after hint — with bounded exponential backoff, so a well-behaved client ramps its connects instead of hammering.

Session establishment keeps the CPU-bound signature work outside the session-table lock, so concurrent CreateSession calls are not serialized behind one signing operation.

Held Publishes and the request-thread budget

A steady-state session keeps one or more long-poll Publish requests outstanding. Each waits for the next notification. The number depends on the client's publish-pipelining strategy; the classic subscription engine queues several per session to smooth delivery.

The operating-system thread is released while a Publish waits. By default, DecoupleHeldPublishRequests also releases the request-processing worker when the request parks. Each parked request releases its worker, so a session with several outstanding Publishes releases several workers. A small pool can therefore support many thousands of parked Publishes without scaling MaxRequestThreadCount to the number of sessions or Publishes.

Set DecoupleHeldPublishRequests to false to restore the legacy behavior. Each held Publish then occupies a worker for the duration of its wait. A server serving N sessions needs MaxRequestThreadCount well above N to avoid starving other requests.

Session diagnostics cost

Creating a session or subscription registers a diagnostics node and marks the live SessionDiagnostics and SubscriptionDiagnostics arrays for refresh. The arrays are rebuilt on demand when they are read or monitored, and the rebuild is throttled so that a burst of session creates does not trigger a rebuild per create. Servers that do not need the live session-diagnostics arrays can turn them off with the DiagnosticsEnabled server setting to remove the cost entirely.

Configuration

The following settings size a single node for its hardware (see also the sizing notes in Benchmarks.md and the admission-control settings in RateLimiting.md):

  • MaxSessionCount caps concurrently open sessions. Size MaxChannelCount (one channel per session) and MaxSubscriptionCount at or above the target session count.
  • MaxRequestThreadCount caps concurrent request processing. With DecoupleHeldPublishRequests enabled it can be sized for the active (non-parked) request concurrency rather than the session count. MinRequestThreadCount pre-warms the pool so a connect burst is not throttled by thread-pool cold-start.
  • DecoupleHeldPublishRequests (default true) releases a held Publish's request-processing worker while it waits, so a small worker pool can hold many outstanding long-polls.
  • MaxMonitoredItemCount and MaxMonitoredItemsPerSubscription (default 0, no limit) cap the monitored items in the server and in one subscription. CreateMonitoredItems answers Bad_TooManyMonitoredItems for the items over the limit, and the values are published in ServerCapabilities.MaxMonitoredItems and MaxMonitoredItemsPerSubscription. Set them on servers reachable by untrusted clients; MaxMonitoredItemsPerCall only bounds a single request.
  • MaxFailedAuthenticationAttempts (default 5; 0 disables) is the per-certificate brute-force lockout. A single-certificate client that opens many sessions can trip it on transient handshake failures and then be rejected with BadUserAccessDenied; raise or disable it for bulk-connect clients.
  • Stagger client connects. Because establishment is CPU-bound but parallelizes across cores, throttling and staggering concurrent connects — rather than bursting them all at once — avoids self-inflicting a connect storm and is the most effective client-side measure.

Scaling out

A single node's handshake throughput is bounded by its cores. To scale beyond one node, distribute connections across several server nodes behind a connection-distributing front end. The stack provides distributed mirroring of the address space, sessions, and subscriptions for exactly this high-availability and distributed topology.

See also