# Description Nullable reference types are now enabled once, in `common.props`, and every project in the repository builds warning-clean with them. **Configuration** - `<Nullable>enable</Nullable>` lives in `common.props`. All per-project `<Nullable>` settings are removed: 178 project files (enable, annotations, and the `disable` in the three Aot test projects). - 419 per-file `#nullable enable`/`restore` directives and the 4 `#nullable disable` directives are removed. - Kept on purpose: - The 5 `<auto-generated>` schema files (`UANodeSet.cs`, `UA Type Dictionary.cs`, `OPCBinarySchema.cs`, `SecuredApplication.cs`, `PInvokeHelper.cs`) keep `#nullable enable`. The compiler ignores the project setting in generated code, so the directive is required there. - `tests/Opc.Ua.Interop.LegacyPeer` keeps `disable`. It builds against the 1.5.378 packages and does not import the repository props. - `docs/DeveloperGuide.md` and `.github/copilot-instructions.md` now describe the central setting. **Migration** (the 43 unset and 13 annotations-only projects, roughly 10k diagnostics) - **Production code: annotated at the declarations.** Covers `tools/Opc.Ua.SourceGeneration.Core`, `src/Opc.Ua.Lds.Server` and the LDS sample. - Members, parameters and returns are nullable where null actually flows. - Null-check helpers carry `[NotNullWhen]` (`IsNull`, `IsValidXmlName`, `IsMethodTypeDesign`). - `CoreUtils.Format`, `Utils.Format`, `ITemplateWriter.WriteLine` take `params object?[]`. `Template.AddReplacement` takes `string?` (rendering already skips null replacements). - `IRegisteredServerStore.RegisterAsync` returns `Task<RegistrationEntry?>`: it returns null when a registration is removed. - Dereferences backed by an invariant (for example, the validated model) use `!`, so runtime behaviour is unchanged. - **Behavioural changes in production code** (small, deliberate): - `NodeSetToModelDesign` throws `InvalidOperationException` when the file is not a NodeSet. Before, it threw a NullReferenceException on the next line. - `ModelDesignValidator` starts its lookup tables empty instead of null. A redundant `if (m_nodes == null) return;` guard is gone. - **Test code: minimal annotations.** - `?` on values that can be null, `!` where the test asserts non-null. - `= null!` for fixture members assigned in SetUp. - `TryGetStructure<T>(out T? x)` where the inferred `T?` broke the `IEncodeable` constraint. - A few redundant `== null` checks on structs (`NodeId`, `ArrayOf<T>`) folded into the adjacent `IsNull`/`Count` checks. **Notes for reviewers** - The diff is large (~1,500 files) but almost entirely mechanical: `?`, `!` and directive removal. The non-annotation changes are listed above. - Known follow-up, not done here: `IModelDesign.Namespaces/TargetNamespace/NamespaceUris` stay non-nullable even though the validator returns null before it loads a dictionary. Making them nullable would ripple through every generator call site. ## Related Issues - Fixes #4439 ## 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. - [ ] 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. - [x] 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. The "no new warnings" box covers net10.0 and net48: full non-incremental builds of `UA.slnx`, `tools/SourceGeneration.slnx`, `tools/MigrationAnalyzer.slnx` and the Aot test projects finished with 0 warnings and 0 errors under the repository's warnings-as-errors settings. net9.0 and net8.0 also had no nullable diagnostics, but they were not rebuilt after the final fixes. Tests have not been run locally yet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Managed client sample
This console sample shows the recommended way to connect an OPC UA client: build a single ManagedSession with WithServerRedundancy(). The same code works whether or not the target server is configured for redundancy, because a client is almost never aware of the server topology until it has connected.
- Against a redundant server, the managed session reads
Server.ServerRedundancy/Server.ServiceLevel, discovers the redundant set from the connected server, and fails over transparently. There is no client-side failover-mode selection and no hand-maintained seed list — the peer set comes from the server. - Against a server that is not configured for redundancy, the same session simply runs as a resilient, automatically reconnecting client (
RedundancySupport=None).
Run
dotnet run --project samples\Redundancy\RedundantClient\RedundantClient.csproj -- `
--server opc.tcp://localhost:62543/RedundantServer --autoaccept --nosecurity --duration 00:05:00
| Option | Default | Description |
|---|---|---|
--server, -s |
opc.tcp://localhost:62543/RedundantServer |
Discovery URL of any server in the (optionally) redundant set. |
--nosecurity |
off | Select endpoints with MessageSecurityMode.None. |
--autoaccept |
off | Automatically accept untrusted server certificates (sample only). |
--duration, -d |
00:02:00 |
How long to monitor before exiting; 00:00:00 runs until Ctrl+C. |
--suite |
off | Run a browse/read/subscribe workload against the redundant ISession. |
--history |
off | Open raw, event, and processed HistoryRead continuations, pause for active-server loss, then resume all three through the promoted replica. Requires an independent client and the strong active/passive historian sample. |
--identity |
off | Cache factory-created shared NodeIds from Browse, wait for server failover, then Read and create a monitored item with the exact saved IDs. Requires an independent client. |
--history-failover-delay |
00:00:15 |
Pause after the first page so an operator or process test can terminate the active server. |
The sample connects, logs the server's reported RedundancySupport (or notes that the server is not redundant), subscribes to Server.ServerStatus.CurrentTime, and logs the values together with any transparent connection-state changes (reconnect or failover). To observe failover, lower the active server's service level (for example with the RedundantServer sample's manual failover support) or stop the active server; the managed session reconnects to a healthy peer on its own.
For the historical failover workflow, start the three-node strong server topology with one shared HA_RECORD_KEY, then run:
dotnet run --project samples\Redundancy\RedundantClient\RedundantClient.csproj -- `
--server opc.tcp://localhost:62543/RedundantServer --autoaccept --nosecurity `
--history --history-failover-delay 00:00:15
The workflow first writes a distinctive raw-history marker and reads it back through the active replica. When the client prints HISTORY: portable continuations ready, stop the active server. The managed session uses token-reuse takeover, resumes the raw, event, and processed cursors without duplicates or gaps, reads the same marker through the promoted replica, verifies that the promoted writer adds new shared raw and event history, and prints HISTORY HA OK.
See HighAvailability.md for the redundancy design and the RedundantServer sample for the server side.
For the NodeId identity workflow, run the client with --identity against a
replica set. When it prints IDENTITY: cached shared NodeIds, terminate its
serving server. It verifies the new serving ApplicationUri through ServerArray,
reads FactoryAssigned/Value and its NodeId-valued Target with the saved IDs,
and uses the saved value ID in CreateMonitoredItems. No post-failover browse or
namespace remapping is used. Success is reported as IDENTITY HA OK.
Run with docker compose (one file, the full HA matrix, env-driven)
A single docker-compose.yml in this folder runs the whole matrix — a redundant server set and one or more clients — where server consistency and client mode are chosen independently by an environment variable, and both sides log their failover / HA behavior, including data loss. The adjacent .env sets the default cell (eventual servers + independent clients), so a bare up needs no flags. docker compose auto-loads that .env even when you run from the repository root.
Selection is by Docker compose profiles (the COMPOSE_PROFILES environment variable) — pick one server profile and one client profile:
| Profile | Topology | Scales? |
|---|---|---|
server-eventual |
Active/active servers, CRDT gossip + DNS discovery (the default) | yes (--scale server=N) |
server-strong |
Fixed 3-node active/passive Raft servers — no data loss | no (fixed odd quorum) |
client-independent |
N managed clients, each fails over on its own (the default) | yes (--scale client=N) |
client-coordinated |
Fixed 3-node client replica set — exactly one active client | no (fixed odd quorum) |
For client-coordinated, CLIENT_CONSISTENCY (eventual | strong, default eventual) selects the shared-store flavor used to hand the session over to the promoted client. Only the eventual server and the independent clients are --scale-able; the strong server and the coordinated client are Raft quorums that need stable identities, so they are fixed 3-node named replicas. SERVER_REPLICAS (default 3) and CLIENT_REPLICAS (default 2) set the scalable counts.
Run from the repository root:
# DEFAULT: 3 eventual servers + 2 independent clients (failover + data loss demoable out of the box).
docker compose -f samples/Redundancy/RedundantClient/docker-compose.yml up --build
# Scale the eventual server and the independent clients independently:
docker compose -f samples/Redundancy/RedundantClient/docker-compose.yml up --build --scale server=5 --scale client=3
# STRONG server (no data loss) x independent clients:
COMPOSE_PROFILES=server-strong,client-independent \
docker compose -f samples/Redundancy/RedundantClient/docker-compose.yml up --build
# Any matrix cell, e.g. STRONG server x EVENTUAL coordinated client:
COMPOSE_PROFILES=server-strong,client-coordinated CLIENT_CONSISTENCY=eventual \
docker compose -f samples/Redundancy/RedundantClient/docker-compose.yml up --build
To trigger a server failover, stop the replica a client is on — for example scale the eventual servers down (… up -d --scale server=1) or docker kill one server container (for server-strong, stop the Raft leader, e.g. … stop server-a). Then watch the client log, which turns the transparent failover and its data impact into explicit lines:
| Client log line | Meaning |
|---|---|
Connection state: Connected -> Reconnecting / Failover |
The session lost its server and is selecting a healthy replica (independent client). |
CONNECTED: session (re)connected to … |
The session re-established a channel (transparent reconnect/failover). |
ACTIVE CLIENT: replica '<id>' is now the active client |
This coordinated replica was elected leader and (re)established monitoring. |
STANDBY: this replica is no longer the active client |
This coordinated replica lost leadership; a peer client took over. |
FAILOVER: now served by replica '<id>' (was '<other>') |
The HighAvailability.ActiveReplica value changed — a different server is now serving the session. |
DATA LOSS: CurrentTime jumped 6.0s (5 update(s) missed during failover) |
Gap in ServerStatus.CurrentTime: samples were missed while the subscription was re-established on the new server (or while a standby client was being promoted). |
DATA LOSS: Counter … across failover (state did not carry over) |
The replicated Counter regressed or diverged — the value was not preserved (see below). |
HA OK: Counter continued <n> -> <n+k> across failover (no data loss) |
The Counter value was preserved across the failover. |
The server log shows the other side of the same events: HA: replica <id> became ACTIVE writer / … became STANDBY on role changes, and a periodic HA: replica <id> ACTIVE, Counter=<n> heartbeat so you can see which replica produces which values.
Whether the Counter shows data loss or continuity depends on the server profile:
server-eventual(active/active, eventual consistency, the default). Every replica writes its own independentCounter, so on failover the value does not carry over and the client logs data loss (DATA LOSS: Counter jumped …). This is the scalable topology.server-strong(active/passive, strong Raft consistency). TheCounterand its Historical Access archive ride a linearizable Raft store, so a promoted standby continues both live state and portable raw/event/processed cursors. The client logs no data loss (HA OK: Counter continued …). Strong consistency needs a fixed odd quorum (3 nodes), so it is not--scale-able and requires one sharedHA_RECORD_KEY.
Client replica set (high availability)
Run the client image in multiple containers so each replica is its own process — a single-process demo cannot coordinate a real deployment. The client mode is chosen with CLIENT_MODE (which the compose sets per profile); two process-per-replica models are supported:
- Independent managed clients (
CLIENT_MODE=independent, the compose default, 2, scalable): every container builds aManagedSessionwithWithServerRedundancy()and reconnects to a healthy peer independently. Scale the client image with--scale client=N, or use theclientsprofile inRedundantServer/Scale/docker-compose.yml. - Coordinated single-active replica set (
CLIENT_MODE=eventual|strong, theclient-coordinatedprofile, fixed odd quorum, default 3): each container builds aRedundantClientSessionover a real Raft cluster among the client replicas (RaftLeaderElection+ aRaftSharedKeyValueStore, or aHybridSharedKeyValueStoreover CRDT gossip for eventual) — the same building blocks the server uses, mirrored on the client. The processes elect one leader that holds the session and share its protected session secrets, so a follower takes over on leader loss and resumes monitoring. Each replica exposes a transparentRedundantClientSession(ISession). Because the election is Raft-quorum-based in both eventual and strong, a coordinated set needs an odd ≥ 3 quorum (a 2-member set is degenerate); the compose ships a fixed 3-nodeclient-a/b/cset. A coordinated set mirrors session secrets through a networked store, so it requires a record protector: setCLIENT_RECORD_KEYto a shared base64 32-byte key in production, orCLIENT_INSECURE=truefor an isolated demo (a well-known, non-secret demo key). See HighAvailability.md.