Files
Marc Schier c4e0721a29 Add a documentation learning path and fix issues found while verifying it (#4555)
## Summary

### Documentation
- Stage the documentation index as a learning path: a first application,
independent client, server, and PubSub paths, then data and services,
companion models, production, advanced integration, and draft or
experimental capabilities.
- Add Getting started (repository samples or NuGet packages), OPC UA
concepts, Your first information model, and a production readiness
checklist.
- Add Native AOT application publishing, a first PubSub publisher and
subscriber exchange, and the UDP transport options.
- Add or expand contents navigation throughout `docs/`, and keep
contributor documentation separate.
- Correct outdated SDK examples and capability guidance, consolidate
repeated explanations into canonical guides, and repair local links and
heading structure.
- Rewrite What's New in 2.0 as a concise feature overview with short
linked details; keep migration instructions in the Migration Guide.
- Expand the feature catalogue with specification IDs, GDS, LDS, the OPC
10000-21 onboarding registrar, ISA-95, xRegistry, OpenUSD, and
sample-local models; distinguish draft status, explain crypto
pluggability, and bring performance guidance forward.
- Address review feedback on wording, active monitored items during
shadow reload, and the `Samples.md` filename and references.
- Regenerate the `opcua-v20-migration` plugin's bundled copy of the
migration guides so it matches the edited `docs/migrate/2.0.x` guides.
- Merge the latest `master`, preserving upstream resource-isolation
guidance within the task-based documentation index.
- Retain deferred Lingity findings where appropriate; this PR does not
claim that every document meets the Lingity score thresholds.

### Fixes found while verifying the guides
Each fix has regression tests that fail without it.
- **Client:** V2 subscriptions compared their lifetime with the
operation timeout instead of the session timeout, and logged a spurious
warning. `ISubscriptionEngineContext`, which is new in 2.0, gains a
`SessionTimeout` property; implementations must add it.
- **PubSub:** a subscriber now registers the `DataSetMetaData`
configured on its readers (OPC 10000-14 6.2.9.4), and the UADP decoder
decodes DataSetMessages that carry no ConfigurationVersion. Before, a
secured subscriber of the reference sample received no DataSets.
- **Source generator:** a ModelDesign method that declares its arguments
inline, without a method type, produced code that did not compile; it
now uses `MethodState`. The generated code of existing models is
unchanged.
- **MinimalClient sample:** waits for its first data-change notification
before it disconnects.

### Known issues, not addressed here
- UDP multicast: the publisher does not send on the interface that the
subscriber joins, and `MulticastLoopback` defaults to false. The PubSub
guide uses unicast for the exchange on one computer.
- The UDP metadata announcement is sent unsecured on secured writer
groups; a secured subscriber drops it with a warning.
- A server does not start in globalization-invariant mode, because
`ResourceManager` creates `CultureInfo("en-US")`. The Native AOT guide
cautions against the mode.

## Validation
- `git diff --check` and
`./.azurepipelines/validate-migration-plugin.ps1` (15 bundled migration
docs validated, plugin manifests synchronized).
- Local Markdown links, anchors, file-link casing, and contents lists
checked across all 87 documents under `docs/`. The index links every
top-level guide and every target of the previous index.
- Both Getting started routes, Your first information model, the PubSub
exchange, and the Native AOT publish of the Getting started client and
server were run as documented. The NuGet route and the tutorial use the
public `2.0.0-preview.5` packages.
- The changed libraries build for all target frameworks in Release
without warnings.
- Tests on .NET 10: Opc.Ua.SourceGeneration.Tests 199 passed;
Opc.Ua.SourceGeneration.Core.Tests 3,977 passed, 8 skipped;
Opc.Ua.Server.Tests `Fluent` category 621 passed; Opc.Ua.Client.Tests
subscription-engine tests 70 passed; Opc.Ua.PubSub.Tests 1,374 passed, 2
skipped; PubSub fuzz replay 145 passed. The new and affected tests also
pass on .NET Framework 4.8.
- Earlier merge validation: 174 resource-isolation tests passed on .NET
10.
- The full solution suite was not run locally.

Review and validation artifacts are maintained outside the repository.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-09-28 19:19:39 +00:00

16 KiB

Rate limiting and admission control

Admission control means checking capacity before accepting more work. The server applies configurable limits to reject excess connections or Session-establishment requests early, reducing the work caused by a connection storm (see Server Session Scalability for the underlying analysis). Clients respond to busy signals by backing off rather than retrying blindly. The standard rate-limiting primitives use System.Threading.RateLimiting; the provider can be replaced through dependency injection.

Rate limiting is on by default with conservative limits. Tune them for the deployment: even legitimate clients can be refused during a sufficiently large burst. Disabling rate limiting does not disable resource isolation or the incomplete-message budget.

Contents

Server side

What is limited

  • Inbound connections (opc.tcp listener): a per-second token bucket (with a burst) admits new connections; beyond the burst a connection is shed cheaply so the CPU-bound secure-channel handshake is protected. The listener socket backlog is configurable and defaults to 512 (raised from the previous hard-coded 10) so a burst of simultaneous connects is absorbed rather than dropped by the OS.
  • Session establishment (CreateSession / ActivateSession): a concurrency limiter bounds the number of in-flight establishment operations so a connect storm cannot saturate every core and starve steady-state publish delivery. When at capacity the server returns BadServerTooBusy with a retry-after hint in the fault, before doing the expensive certificate validation / signing.
  • Incomplete messages: one ChunkReassemblyBudget bounds retained intermediate-message buffers across the server's UA-TCP, Kestrel TCP, and UA Secure Conversation WebSocket listeners. This capacity limit is independent of ServerRateLimitOptions.Enabled.

Status codes

Situation Status code
Session establishment at capacity BadServerTooBusy (transient — the client should back off and retry)
Hard session cap reached (MaxSessionCount) BadTooManySessions
Connection shed at the listener the connection is dropped; the client sees a transport error and, together with any subsequent BadServerTooBusy, backs off
Incomplete-message budget exhausted BadTcpNotEnoughResources followed by channel closure; if the error cannot be sent, the client observes closure
Resource isolation refuses common HTTPS body processing HTTP 503 (Service Unavailable), distinct from a rate limiter's HTTP 429

Configuration

The deterministic limits live in ServerRateLimitOptions:

Option Default Meaning
Enabled true Switch for this provider's connection and Session-establishment rate limits, not resource isolation or message budgets.
ListenBacklog 512 Listener socket pending-connection backlog.
ConnectionRateLimitEnabled true Whether inbound connections are rate limited.
ConnectionsPerSecond 500 Sustained connection admission rate.
ConnectionBurst 1000 Connection burst capacity (token-bucket size).
SessionRateLimitEnabled true Whether session establishment is limited.
MaxConcurrentSessionEstablishment max(ProcessorCount * 8, 128) Concurrent in-flight CreateSession/ActivateSession.
SessionEstablishmentQueueLimit 0 Waiters before rejecting with BadServerTooBusy (0 = reject immediately).

Configure it through the DI hosting options:

services.AddOpcUa()
    .AddServer(options =>
    {
        options.ApplicationName = "MyServer";
        options.ConfigureRateLimits = limits =>
        {
            limits.ConnectionsPerSecond = 200;
            limits.MaxConcurrentSessionEstablishment = 64;
        };
    });

To plug in a completely different algorithm, register a custom IServerRateLimiterProvider in DI (it takes precedence over ConfigureRateLimits):

services.AddSingleton<IServerRateLimiterProvider>(sp =>
    new MyCustomRateLimiterProvider(...));

Without dependency injection, set the options or provider directly on the server before it starts:

var server = new StandardServer(telemetry);
server.RateLimitOptions = new ServerRateLimitOptions { ConnectionsPerSecond = 200 };
// or: server.RateLimiterProvider = new DefaultServerRateLimiterProvider(options);

Raw TCP, Kestrel TCP, and UACP WebSocket listeners consume the IConnectionRateLimiter supplied through TransportListenerSettings, injected by StandardServer.ConfigureTransportListenerSettings. A custom server can override that hook to supply its own limiter. The ordinary shared TokenBucketConnectionRateLimiter wraps a System.Threading.RateLimiting.TokenBucketRateLimiter. With the default isolation provider in Balanced or TrustedReservations, the default rate provider instead uses ResourceIsolationConnectionRateLimiter to divide the same totals between protected and shared traffic, as described below. Kestrel and UACP WebSocket listeners also enforce their configured channel cap across concurrent admissions and release capacity with the physical connection, including reverse handoff. The raw listener retains its existing admission and unused-channel reclamation behavior.

Incomplete messages

ChunkReassemblyBudget (Opc.Ua.Bindings) counts the backing-array length of each retained intermediate chunk, including pool rounding and metadata. All listeners of a server share the same budget. Completing, replacing, aborting, faulting, or closing a partial message releases its reservation.

The budget applies only while messages await further chunks, not to the whole process heap, socket buffers, final chunks, decoded requests, or outgoing responses. A request that fits in one chunk can still be served while the reassembly budget is full. A chunk that exceeds available capacity is rejected without waiting; the partial message is discarded and its channel is closed.

The budget's default is sixteen times TransportQuotas.MaxMessageSize, clamped to 64 MiB through 1 GiB, then raised if necessary to accommodate four maximum-sized messages. A configuration with unlimited message size receives a 1 GiB budget. Both the stack's 2 MiB and the reference server's 4 MiB message limits select 64 MiB. ChunkReassemblyBudget.GetDefaultMaxBytes(maxMessageSize) returns this value, and ChunkReassemblyBudget.CreateDefault(configuration) creates a budget from an EndpointConfiguration using the same sizing policy. The budget can be used with unlimited message sizes in SharedOnly; the default Balanced isolation provider separately requires finite message and buffer limits.

In SharedOnly, channels without an activated session may reserve only while total usage stays at or below MaxBytesWithoutSession, which defaults to half of MaxBytes. Channels with activated sessions can use the remaining headroom. This is a capacity policy, not an authentication boundary: an activated anonymous session also qualifies. The two-argument constructor sets the sessionless threshold explicitly, including the entire budget for sessionless workloads.

The threshold is not reserved space for new clients. For example, with a 100 MiB total and 50 MiB sessionless threshold, 60 MiB retained by activated sessions prevents further sessionless intermediate chunks. Single-chunk requests are still processed, but a large multi-chunk OpenSecureChannel, CreateSession, or ActivateSession may be refused until occupancy drops. The shared budget alone provides neither request scheduling by caller nor reserved startup capacity.

Server resource isolation limits usage by caller and can keep capacity reserved for verified startup/recovery work inside the same totals. Direct and hosted StandardServer instances use Balanced by default. With the default isolation provider active, its byte-class reservations replace the sessionless occupancy rule above; the shared budget still enforces MaxBytes. With a 4 MiB message limit and 65,536-byte buffer limit, Balanced reserves 16.25 MiB each for startup and reconnect from a 64 MiB budget, leaving 31.5 MiB shared. These are conservative retained-array bounds, not payload sizes.

A reserve does not make an unknown caller eligible to use it. A deployment needs trusted ingress classification for protected access before OPC UA authentication. Implement IResourceIsolationClassifier using a network entry path whose access is controlled by your deployment, as shown in the dedicated trusted-ingress example. An IP address or previous successful connection is not proof of identity. Unknown callers can be refused while a reserve is unused because that memory is available only to its eligible callers, not to every caller. SharedOnly retains the shared occupancy rule without separate startup reserves.

These policies prevent a busy caller population from taking resources set aside for eligible recovery or control traffic. They cannot guarantee access to every unknown client during a distributed flood. They also do not limit unrelated application allocations. See the limits of this protection when sizing a deployment.

Balanced startup rejects insufficient capacities rather than silently removing reserves or increasing totals. See the startup configuration choices and plan inspection example for the actual fields and checks. Isolation remains independent of ServerRateLimitOptions.Enabled.

When the default connection rate limiter is enabled alongside Balanced or TrustedReservations, it also reserves one burst token and one token per second for startup, reconnect, and each provisioned trusted owner, inside the existing ConnectionBurst and ConnectionsPerSecond totals. At least one shared token must remain in each total. Startup rejects totals that cannot fit. Protected traffic can also use available shared tokens, but ordinary traffic cannot use the reserved tokens. Closing a connection does not refund a token. FairShare and SharedOnly use the ordinary shared token bucket. If you supply your own rate-limiter provider, the server calls that provider instead. It may reject a connection even when the isolation policy has reserved space for it, so configure both policies consistently.

Configure the hosted server through its fluent builder:

services.AddOpcUa()
    .AddServer(options => options.ApplicationName = "MyServer")
    .WithChunkReassemblyBudget(256L * 1024 * 1024);

For direct construction, assign the budget before startup:

var server = new StandardServer(telemetry)
{
    ChunkReassemblyBudget = new ChunkReassemblyBudget(256L * 1024 * 1024)
};

The same instance can be shared by several servers. A host that opens listeners itself passes it through TransportListenerSettings.ChunkReassemblyBudget; otherwise each standalone listener creates its own appropriately sized budget. For Dependency Injection (DI), the two-argument builder overload sets the sessionless threshold and validates both limits immediately:

services.AddOpcUa()
    .AddServer(options => options.ApplicationName = "MyServer")
    .WithChunkReassemblyBudget(256L * 1024 * 1024, 64L * 1024 * 1024);

A directly constructed ChunkReassemblyBudget(maxBytes, maxBytesWithoutSession) can also be registered as a singleton when several servers should share it.

Server channels also enforce a fixed assembly deadline using ChannelLifetime. Continuation traffic cannot restart it. A shared quota/clock timer schedules asynchronous cleanup independently of listener inactivity sweeps. Non-positive lifetimes use the 30-second default for assembly, and expiration reports BadTimeout before closure when possible; see incomplete-message resource limits.

HTTPS / Kestrel transport

The HTTPS/WSS transport is Kestrel-hosted and can use the ASP.NET Core rate limiter middleware. Because that listener builds its own isolated web host, attach the limiter through the stack's DI with AddHttpsRateLimiter (net8+):

services.AddOpcUa()
    .AddHttpsTransport()
    .AddHttpsRateLimiter(); // default: global 100 req/s fixed window, rejects with HTTP 429

Pass an Action<RateLimiterOptions> to fully configure the limiter with any System.Threading.RateLimiting policy. This registers a contributor that calls services.AddRateLimiter(...) and app.UseRateLimiter() on the listener's isolated Kestrel host; rejected requests return HTTP 429.

Client side

A client that receives a "server busy" signal must not hammer the server with retries; that would amplify a connect storm. The default ReconnectPolicy used by ManagedSession is server-signal-aware. When an attempt fails with an overload signal—BadServerTooBusy, BadTcpServerTooBusy, BadTooManySessions, BadTooManyOperations, BadTooManyPublishRequests, or a transient timeout—it backs off more aggressively. It multiplies the computed delay by four, caps it at MaxDelay, and honors any server-provided retry-after hint as a lower bound.

This is exposed through IReconnectPolicy.TryGetNextDelay, which adapts the backoff to the previous attempt's status code and any server-provided retry-after hint. The connection state machine calls it automatically; a policy that returns false opts out of adaptive behavior and the state machine falls back to the basic attempt-based GetNextDelay, so a minimal custom policy only has to implement the plain delay. Because a failed initial connect funnels into the same reconnect loop, the adaptive backoff applies to both initial connects and reconnects.

ReconnectPolicy.IsServerBusySignal(StatusCode) classifies whether a status code is an overload signal, for use in custom policies.

To keep a bulk connect from bursting, a client-wide connect admission gate ramps concurrent initial connects: ManagedSessionBuilder.WithConnectRateLimiter(maxConcurrency) (or a shared RateLimiter / IClientConnectGate) bounds how many ManagedSession.CreateAsync calls establish at once; the excess wait in an unbounded queue rather than being rejected, so many sessions to one server ramp up smoothly instead of stampeding. It is off by default; through DI use the ConnectRateLimiterMaxConcurrency client option.

Server backpressure signals

  • Structured retry-after: the server carries a machine-readable retry-after to the client independently of diagnostics — as a RetryAfterMs value in ResponseHeader.additionalHeader on a BadServerTooBusy ServiceFault, and as the standard HTTP Retry-After header on the HTTPS 429. The client honors both via IReconnectPolicy.TryGetNextDelay. The UA-TCP client also honors a RetryAfterMs=N token in a transient server-busy Error message reason as a lower bound on channel-reconnect backoff. The legacy RetryAfterMs=N AdditionalInfo token remains a best-effort compatibility hint. See Server retry-after backpressure for how the client honors these signals during reconnect.
  • Load-based Server.ServiceLevel: the reference server computes Server.ServiceLevel from session-establishment headroom (255 at low load, scaling toward a floor as sessions approach MaxSessionCount, with hysteresis), so a client can read/subscribe to it as a proactive capacity signal.

See also