Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServiceLevelBindingReplayTests.cs
T
Joseph Doherty 3fff439b11
v2-ci / unit-tests (push) Has been cancelled
fix(redundancy): advertise ServiceLevel 0 until the node has a redundancy level (#885)
The SDK starts Server.ServiceLevel at 255 (full service), and OpcUaPublishActor publishes nothing
until the node's first redundancy snapshot arrives, so a restarting pair node advertised 255 for the
~10-13 s it took to join, out-ranking the real Primary (250) for any client that picks the highest
ServiceLevel.

OtOpcUaSdkServer now writes StartupServiceLevel = 0 in OnNodeManagerStartedAsync: after the SDK's
CreateServerObjectAsync wrote 255 and before the server goes Running, so no session can ever read
255. 0 is the design's existing Down / Detached tier (ServiceLevelCalculator returns 0 for a member
that is not Up/Joining, and a node with no snapshot has no role yet). It is below the SDK 2
session-headroom floor (200), so it stands through session churn before the #884 claim, and the
claim's bind replays any level already requested. Single-node deployments are unaffected beyond
the window: the first snapshot publishes 250 as before.

New Node_with_no_requested_level_advertises_the_startup_level_until_one_is_published: reads 0
before the bind, 0 after a bind with nothing to replay, then 250 once published. Without the
startup write it reads 255 on the first session (mutation-checked). docs/Redundancy.md updated for
#884 and #885.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-12 11:11:11 -05:00

227 lines
12 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>Local loopback evidence that late binding replaces the SDK default on the wire.</summary>
public sealed class ServiceLevelBindingReplayTests
{
// Since #885 the server starts at 0, so the 0 case no longer distinguishes "replayed" from "not replayed" — it pins
// that a requested 0 still reads 0. The 100 case is the replay evidence.
[Theory]
[InlineData((byte)100)]
[InlineData((byte)0)]
public async Task Late_binding_replays_latest_request_to_standard_ServiceLevel_read(byte latest)
{
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
deadline.CancelAfter(TimeSpan.FromSeconds(15));
var ct = deadline.Token;
var pki = Path.Combine(Path.GetTempPath(), $"otopcua-replay-{Guid.NewGuid():N}");
var clientPki = TestClientSecurity.AllocatePkiRoot();
var port = 0;
try
{
var deferred = new DeferredServiceLevelPublisher();
deferred.Publish(240);
deferred.Publish(latest);
var started = await OpcUaHostStart.StartAsync(p =>
{
port = p;
return new OpcUaApplicationHost(new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.Replay",
ApplicationUri = $"urn:OtOpcUa.Replay:{Path.GetFileName(pki)}",
OpcUaPort = p, PublicHostname = "localhost", PkiStoreRoot = pki,
}, NullLogger<OpcUaApplicationHost>.Instance);
}, () => new OtOpcUaSdkServer(), ct);
await using var host = started.Host;
deferred.SetInner(new SdkServiceLevelPublisher(started.Server.CurrentInstance,
NullLogger<SdkServiceLevelPublisher>.Instance));
var config = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
{
ApplicationName = "OtOpcUa.ReplayClient",
ApplicationUri = $"urn:OtOpcUa.ReplayClient:{Guid.NewGuid():N}",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = TestClientSecurity.Build(clientPki),
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 10_000 },
TransportQuotas = new TransportQuotas { OperationTimeout = 5_000 },
};
await config.ValidateAsync(ApplicationType.Client, ct);
var endpoint = await CoreClientUtils.SelectEndpointAsync(config,
$"opc.tcp://localhost:{port}/OtOpcUa", false, DefaultTelemetry.Create(_ => { }), ct);
using var session = await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
config, new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("Endpoint discovery returned no endpoint."), EndpointConfiguration.Create(config)),
updateBeforeConnect: false, checkDomain: false, sessionName: "ServiceLevelReplay",
sessionTimeout: 10_000, identity: new UserIdentity(new AnonymousIdentityToken()),
preferredLocales: default, ct: ct);
var read = await session.ReadValueAsync(new Opc.Ua.NodeId(2267u), ct);
read.StatusCode.ShouldBe(StatusCodes.Good);
read.ClrValue()!.ShouldBe(latest);
}
finally
{
if (Directory.Exists(pki)) Directory.Delete(pki, recursive: true);
if (Directory.Exists(clientPki)) Directory.Delete(clientPki, recursive: true);
}
}
/// <summary>
/// lmxopcua#801: SDK 2.0 recomputes Server.ServiceLevel from session headroom on every session create/close
/// and overwrites any value &gt;= 200 unless a provider has claimed it. On the docker-dev pair that turned the
/// Primary's 250 and the Secondary's 240 into 255 on both nodes as soon as a client connected. A level published
/// BEFORE a session opens and one published while a session is open must both survive later sessions opening
/// and closing.
/// </summary>
[Theory]
[InlineData((byte)250)]
[InlineData((byte)240)]
[InlineData((byte)200)]
public async Task Published_level_survives_later_sessions(byte level)
{
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
deadline.CancelAfter(TimeSpan.FromSeconds(30));
var ct = deadline.Token;
var pki = Path.Combine(Path.GetTempPath(), $"otopcua-claim-{Guid.NewGuid():N}");
var clientPki = TestClientSecurity.AllocatePkiRoot();
var port = 0;
try
{
var started = await OpcUaHostStart.StartAsync(p =>
{
port = p;
return new OpcUaApplicationHost(new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.Claim",
ApplicationUri = $"urn:OtOpcUa.Claim:{Path.GetFileName(pki)}",
OpcUaPort = p, PublicHostname = "localhost", PkiStoreRoot = pki,
}, NullLogger<OpcUaApplicationHost>.Instance);
}, () => new OtOpcUaSdkServer(), ct);
await using var host = started.Host;
var publisher = new SdkServiceLevelPublisher(started.Server.CurrentInstance,
NullLogger<SdkServiceLevelPublisher>.Instance);
publisher.Publish(level);
var url = $"opc.tcp://localhost:{port}/OtOpcUa";
// SessionCreated: the first session opening.
using (var first = await OpenAsync(url, clientPki, ct))
{
(await ReadServiceLevelAsync(first, ct)).ShouldBe(level, "the first session opening must not displace the published level");
// An explicit CloseSession so the server raises SessionClosing (Dispose alone may leave the session to time out).
await first.CloseAsync(5_000, true, ct);
}
using var second = await OpenAsync(url, clientPki, ct);
(await ReadServiceLevelAsync(second, ct)).ShouldBe(level, "a session closing then another opening must not displace it");
// A level published while a session is open (230 keeps every case inside the SDK's >= 200 overwrite band)
// must survive another session opening AND, read on the surviving session, that session closing on its own.
const byte later = 230;
publisher.Publish(later);
using (var third = await OpenAsync(url, clientPki, ct))
{
(await ReadServiceLevelAsync(third, ct)).ShouldBe(later, "a level published while a session is open must survive the next one");
await third.CloseAsync(5_000, true, ct);
}
(await ReadServiceLevelAsync(second, ct)).ShouldBe(later, "a session closing on its own must not displace it");
}
finally
{
if (Directory.Exists(pki)) Directory.Delete(pki, recursive: true);
if (Directory.Exists(clientPki)) Directory.Delete(clientPki, recursive: true);
}
}
/// <summary>
/// lmxopcua#885: a node that has not yet received its first redundancy snapshot has requested no level. The SDK starts
/// <c>Server.ServiceLevel</c> at 255, so a restarting pair node advertised full service, out-ranking the real Primary
/// (250), for the ~10 s it took to join. It must read <see cref="OtOpcUaSdkServer.StartupServiceLevel"/> (0) from its
/// first session, through the bind with nothing to replay and across session churn, and still settle to the level
/// the redundancy layer publishes once it has one (a single-node deployment's 250).
/// </summary>
[Fact]
public async Task Node_with_no_requested_level_advertises_the_startup_level_until_one_is_published()
{
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
deadline.CancelAfter(TimeSpan.FromSeconds(30));
var ct = deadline.Token;
var pki = Path.Combine(Path.GetTempPath(), $"otopcua-startup-{Guid.NewGuid():N}");
var clientPki = TestClientSecurity.AllocatePkiRoot();
var port = 0;
try
{
var started = await OpcUaHostStart.StartAsync(p =>
{
port = p;
return new OpcUaApplicationHost(new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.Startup",
ApplicationUri = $"urn:OtOpcUa.Startup:{Path.GetFileName(pki)}",
OpcUaPort = p, PublicHostname = "localhost", PkiStoreRoot = pki,
}, NullLogger<OpcUaApplicationHost>.Instance);
}, () => new OtOpcUaSdkServer(), ct);
await using var host = started.Host;
var url = $"opc.tcp://localhost:{port}/OtOpcUa";
// Before the host binds a publisher: the SDK-start window.
using (var early = await OpenAsync(url, clientPki, ct))
{
(await ReadServiceLevelAsync(early, ct)).ShouldBe(OtOpcUaSdkServer.StartupServiceLevel,
"an endpoint that has not been told its redundancy level must not advertise the SDK's 255");
await early.CloseAsync(5_000, true, ct);
}
// The bind with nothing to replay (no snapshot yet), then session churn under the claim.
var deferred = new DeferredServiceLevelPublisher();
deferred.SetInner(new SdkServiceLevelPublisher(started.Server.CurrentInstance,
NullLogger<SdkServiceLevelPublisher>.Instance));
using var session = await OpenAsync(url, clientPki, ct);
(await ReadServiceLevelAsync(session, ct)).ShouldBe(OtOpcUaSdkServer.StartupServiceLevel,
"binding with no requested level must leave the startup level standing");
// The first snapshot arrives: a single-node deployment settles to its steady Primary value.
deferred.Publish(250);
using (var later = await OpenAsync(url, clientPki, ct))
await later.CloseAsync(5_000, true, ct);
(await ReadServiceLevelAsync(session, ct)).ShouldBe((byte)250, "the published level must replace the startup level");
}
finally
{
if (Directory.Exists(pki)) Directory.Delete(pki, recursive: true);
if (Directory.Exists(clientPki)) Directory.Delete(clientPki, recursive: true);
}
}
private static async Task<Opc.Ua.Client.ISession> OpenAsync(string url, string clientPki, CancellationToken ct)
{
var config = new ApplicationConfiguration(DefaultTelemetry.Create(_ => { }))
{
ApplicationName = "OtOpcUa.ClaimClient",
ApplicationUri = $"urn:OtOpcUa.ClaimClient:{Guid.NewGuid():N}",
ApplicationType = ApplicationType.Client,
SecurityConfiguration = TestClientSecurity.Build(clientPki),
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 10_000 },
TransportQuotas = new TransportQuotas { OperationTimeout = 5_000 },
};
await config.ValidateAsync(ApplicationType.Client, ct);
var endpoint = await CoreClientUtils.SelectEndpointAsync(config, url, false, DefaultTelemetry.Create(_ => { }), ct);
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
config, new ConfiguredEndpoint(null, endpoint ?? throw new InvalidOperationException("Endpoint discovery returned no endpoint."), EndpointConfiguration.Create(config)),
updateBeforeConnect: false, checkDomain: false, sessionName: "ServiceLevelClaim",
sessionTimeout: 10_000, identity: new UserIdentity(new AnonymousIdentityToken()),
preferredLocales: default, ct: ct);
}
private static async Task<byte> ReadServiceLevelAsync(Opc.Ua.Client.ISession session, CancellationToken ct)
{
var read = await session.ReadValueAsync(new Opc.Ua.NodeId(2267u), ct);
read.StatusCode.ShouldBe(StatusCodes.Good);
return (byte)read.ClrValue()!;
}
}