Compare commits

...

1 Commits

Author SHA1 Message Date
Joseph Doherty cc76249a69 fix(redundancy): claim Server.ServiceLevel so SDK 2 stops overwriting 250/240 with 255 (#801)
SDK 2.0.1 recomputes Server.ServiceLevel from session headroom on every session create/close and
overwrites any value >= 200 unless a provider has claimed it through
IServerServiceLevelControl.ClaimServiceLevelControl. SdkServiceLevelPublisher never claimed it, so on
the docker-dev pair both nodes read 255 as soon as a client connected while their own metrics showed
250 (Primary) and 240 (Secondary); the Stale tier's 200 was lifted to 255 too. Levels below 200 (100,
0) were untouched, which is why the bootstrap 100 showed briefly.

The publisher now claims the value at construction and writes through the SDK's writer; a server
without the claim (test doubles) keeps the diagnostics-guarded write. New
Published_level_survives_later_sessions (250/240/200) fails without the claim (reads 255) and passes
with it.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-12 10:22:52 -05:00
2 changed files with 110 additions and 0 deletions
@@ -17,11 +17,21 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// automatically during <see cref="DiagnosticsNodeManager"/> initialization. Writes are
/// guarded by <see cref="IServerContext.UpdateServerDiagnostics"/> so concurrent diagnostics scans
/// from the SDK don't fight with our update.
/// <para>
/// SDK 2.0 (lmxopcua#801): the server now recomputes <c>Server.ServiceLevel</c> from session headroom on
/// every session create/close (255 when lightly loaded) and overwrites any value ≥ 200 — so a Primary's
/// 250, a Secondary's 240 and the Stale tier's 200 all read back as 255 after the next client connects,
/// and both halves of a pair look identical. The SDK stops doing that only once a provider claims the
/// value through <see cref="IServerServiceLevelControl.ClaimServiceLevelControl"/>, so this publisher
/// claims it at construction and writes through the returned writer. A server that does not offer the
/// claim (test doubles) keeps the direct diagnostics-guarded write.
/// </para>
/// </summary>
public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher
{
private readonly IServerInternal _serverInternal;
private readonly ILogger<SdkServiceLevelPublisher> _logger;
private readonly Action<byte>? _claimedWriter;
/// <summary>Initializes a new instance of the SdkServiceLevelPublisher class.</summary>
/// <param name="serverInternal">The OPC UA server internal interface.</param>
@@ -30,11 +40,30 @@ public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher
{
_serverInternal = serverInternal;
_logger = logger;
// Claim ownership so the SDK's session-headroom calculator stops overwriting the redundancy value
// (lmxopcua#801). Throws if the server has no ServiceLevel or another provider already claimed it —
// the hosted service's binding catch turns that into a failed endpoint rather than a silent 255.
if (serverInternal is IServerServiceLevelControl control)
_claimedWriter = control.ClaimServiceLevelControl();
}
/// <inheritdoc />
public void Publish(byte serviceLevel)
{
if (_claimedWriter is not null)
{
try
{
_claimedWriter(serviceLevel);
return;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "SdkServiceLevelPublisher: write to Server.ServiceLevel threw");
throw;
}
}
var node = _serverInternal.ServerObject?.ServiceLevel;
if (node is null)
{
@@ -68,4 +68,85 @@ public sealed class ServiceLevelBindingReplayTests
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";
using (var first = await OpenAsync(url, clientPki, ct))
(await ReadServiceLevelAsync(first, ct)).ShouldBe(level, "the first session opening must not displace the published level");
using var second = await OpenAsync(url, clientPki, ct);
(await ReadServiceLevelAsync(second, ct)).ShouldBe(level, "a session closing then another opening must not displace it");
publisher.Publish((byte)(level - 10));
using var third = await OpenAsync(url, clientPki, ct);
(await ReadServiceLevelAsync(third, ct)).ShouldBe((byte)(level - 10), "a level published while a session is open must survive the next one");
}
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()!;
}
}