Files
UA-.NETStandard/tests/Opc.Ua.Redundancy.Server.Tests/Sessions/DistributedSessionFactoryTests.cs
romanettandClaude Opus 5.5 1da9fc0543 Redundancy.Server session mirroring: fix fast reconnect lifecycle (#4627) (#4704)
Fixes #4627 — the eight Redundancy.Server session mirroring defects
(HotAndMirrored fast reconnect).

## Changes

| ID | Fix |
|----|-----|
| RS-1 | The owning replica mirrors a liveness heartbeat
(`LastContactAt`), written at most once per quarter of the session
timeout while the client uses the session
(`DistributedSessionOptions.LivenessInterval`, default 1 s). A restore
expires the timeout after the later of the last activation and the last
mirrored contact, plus the heartbeat's maximum lag. |
| RS-2 | Entries record their owning replica (`OwnerId`). Only the owner
mirrors and deletes the entry. A restore takes ownership with a
conditional write. A stale copy on the previous replica is dropped
without deleting the entry, both when its heartbeat sees the change and
when the client fails back to it. `ISharedSessionStore` gains
`TryReplaceAsync`, `TryRemoveAsync` and `EnumerateAsync`. Over a store
without CAS (the CRDT gossip store) they fall back to
compare-then-write. |
| RS-3 | A restored session keeps the SessionId from `CreateSession`:
`Session.RequestedId`, and `DiagnosticsNodeManager` honours a requested
id when it is free. `DistributedSessionManager` mints GUID SessionIds
that are unique across the replica set. Continuation points therefore
stay under one owner id across repeated failovers. |
| RS-4 | Sessions whose user token is encrypted with an ECC/RSA-DH
EphemeralKey are flagged. Their restore is rejected up front
(`EphemeralKeyUnavailable`), before anything is consumed or counted as a
failed authentication. The documented option from the issue was chosen
over mirroring ephemeral private keys. |
| RS-5 | New `SessionManager.AdmitRestoredSessionAsync` hook: the
mirrored nonce is consumed only after the restored activation has passed
the signature, identity and continuity checks. If a restored session's
first activation fails, the session is discarded without the virtual
close, so the shared entry is untouched. Restored copies that are not
admitted now also release their diagnostics node, and
`CurrentSessionCount` is balanced for restored sessions. |
| RS-6 | Expired entries are deleted on restore and by a periodic sweep
(`SweepInterval`, default 5 min). The sweep also purges consumed-nonce
markers that no live entry references and that are older than
`NonceMarkerRetention` (default 1 h), via
`ISingleUseNonceRegistry.PurgeAsync`. |
| RS-7 | `SharedKeyValueSessionStore` and
`DistributedSessionManagerFactory` now throw for an external store
without an `IRecordProtector`, as the DI registration already does. Pass
`NullRecordProtector.Instance` to opt out explicitly. |
| RS-8 | Entries carry the original server certificate. A restored
session accepts a client signature over either that certificate or its
own one (`Session.OriginalServerCertificate`). |

Entries are now written at `SecurityStateVersion` 5. Entries at versions
3 and 4 can still be restored, without an owner.

### API changes (Redundancy.Server)
- New members on `ISharedSessionStore` and `ISingleUseNonceRegistry`.
- An optional `TimeProvider` parameter on
`SharedSingleUseNonceRegistry`.
- New `DistributedSessionOptions` properties.
- `SharedSessionEntry` v5 fields.
- `SharedKeyValueSessionStore` and `DistributedSessionManagerFactory`
throw for external stores without a protector.

## Tests
- New unit tests for every finding: the
restore/heartbeat/ownership/sweep/SessionId paths of
`DistributedSessionManager`; store conditional writes, the CAS fallback
and v5 round-trips; the registry purge; the factory/store guard; base
`SessionRestoreTests` (admitted once, discarded on validation failure or
rejected admission); and `SessionOriginalServerCertificateTests`.
- New end-to-end test
`SessionUsedPastItsTimeoutFailsOverWithItsSessionIdAsync`. The client
uses the session for 2× its timeout, then fails over. The test checks
that the SessionId is preserved and that the standby serves that
SessionId and owns the entry. It also closes the stale copy and checks
the entry survives. With the heartbeat disabled, the test fails as
expected.
- Local net10.0 runs: Redundancy.Server.Tests 883/883, Server.Tests 8347
passed / 5 skipped, Sessions.Tests 849 passed / 379 skipped (the usual
baseline). The net48 build is clean. The Redundancy session tests pass
on net48; the ECC-only cases are skipped there.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:54:14 +02:00

159 lines
6.2 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
// CA2007: tests run without a SynchronizationContext; ConfigureAwait(false)
// adds noise without a behavioural benefit. Disabled file-level for the suite.
#pragma warning disable CA2007
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using NUnit.Framework;
using Opc.Ua.Redundancy;
using Opc.Ua.Redundancy.Server;
using Opc.Ua.Tests;
namespace Opc.Ua.Server.Tests.Redundancy
{
/// <summary>
/// Unit tests for the distributed session DI seam:
/// <see cref="DistributedSessionManagerFactory"/> and the
/// <c>UseDistributedSessions</c> fluent registration.
/// </summary>
[TestFixture]
[Category("Distributed")]
[Parallelizable(ParallelScope.All)]
public sealed class DistributedSessionFactoryTests
{
[Test]
public void FactoryCreatesDistributedSessionManager()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
IServiceMessageContext context = ServiceMessageContext.CreateEmpty(telemetry);
var serverMock = new Mock<IServerInternal>();
serverMock.Setup(s => s.Telemetry).Returns(telemetry);
serverMock.Setup(s => s.MessageContext).Returns(context);
var configuration = new ApplicationConfiguration
{
ServerConfiguration = new ServerConfiguration
{
MinSessionTimeout = 1000,
MaxSessionTimeout = 3_600_000,
MaxSessionCount = 100
}
};
using var kv = new InMemorySharedKeyValueStore();
var factory = new DistributedSessionManagerFactory(kv);
using var manager = factory.Create(
serverMock.Object,
configuration,
TimeProvider.System,
_ => null) as DistributedSessionManager;
Assert.That(manager, Is.Not.Null);
}
/// <summary>
/// Issue #4627 RS-7: the public factory applies the same fail-closed rule as the
/// dependency injection registration instead of mirroring session secrets in
/// cleartext to an external store.
/// </summary>
[Test]
public void FactoryRejectsExternalStoreWithoutProtector()
{
using var inner = new InMemorySharedKeyValueStore();
var external = new SharedSessionStoreTests.NoCompareAndSwapStore(inner);
Assert.That(
() => new DistributedSessionManagerFactory(external),
Throws.InvalidOperationException);
Assert.That(
() => new DistributedSessionManagerFactory(external, NullRecordProtector.Instance),
Throws.Nothing);
}
[Test]
public void FactoryConstructorRejectsNullStore()
{
Assert.That(
() => new DistributedSessionManagerFactory(null!),
Throws.ArgumentNullException);
}
[Test]
public async Task UseDistributedSessionsRegistersFactoryAsync()
{
var services = new ServiceCollection();
services.AddOpcUa()
.AddServer(_ => { })
.UseDistributedSessions(o => o.EnableFastReconnect = true);
await using ServiceProvider provider = services.BuildServiceProvider();
Assert.That(
provider.GetRequiredService<ISessionManagerFactory>(),
Is.InstanceOf<DistributedSessionManagerFactory>());
Assert.That(
provider.GetRequiredService<ISharedKeyValueStore>(),
Is.InstanceOf<InMemorySharedKeyValueStore>());
}
[Test]
public async Task UseDistributedSessionsSharesStoreWithAddressSpaceAsync()
{
var services = new ServiceCollection();
services.AddOpcUa()
.AddServer(_ => { })
.UseDistributedAddressSpace()
.UseDistributedSessions();
await using ServiceProvider provider = services.BuildServiceProvider();
ISharedKeyValueStore first = provider.GetRequiredService<ISharedKeyValueStore>();
ISharedKeyValueStore second = provider.GetRequiredService<ISharedKeyValueStore>();
// Both features compose over a single shared backend.
Assert.That(ReferenceEquals(first, second), Is.True);
Assert.That(
provider.GetRequiredService<ISessionManagerFactory>(),
Is.InstanceOf<DistributedSessionManagerFactory>());
}
[Test]
public void DefaultOptionsDisableFastReconnect()
{
var options = new DistributedSessionOptions();
Assert.That(options.EnableFastReconnect, Is.False);
}
}
}