From cc76249a69f13ea6d35c82cda60d77045b324329 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 12 Nov 2026 09:55:25 -0500 Subject: [PATCH] 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 --- .../SdkServiceLevelPublisher.cs | 29 +++++++ .../ServiceLevelBindingReplayTests.cs | 81 +++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs index 0ea04b889..ae482fcf1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkServiceLevelPublisher.cs @@ -17,11 +17,21 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; /// automatically during initialization. Writes are /// guarded by so concurrent diagnostics scans /// from the SDK don't fight with our update. +/// +/// SDK 2.0 (lmxopcua#801): the server now recomputes Server.ServiceLevel 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 , 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. +/// /// public sealed class SdkServiceLevelPublisher : IServiceLevelPublisher { private readonly IServerInternal _serverInternal; private readonly ILogger _logger; + private readonly Action? _claimedWriter; /// Initializes a new instance of the SdkServiceLevelPublisher class. /// The OPC UA server internal interface. @@ -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(); } /// 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) { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServiceLevelBindingReplayTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServiceLevelBindingReplayTests.cs index e5fae2cc4..74beac046 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServiceLevelBindingReplayTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/ServiceLevelBindingReplayTests.cs @@ -68,4 +68,85 @@ public sealed class ServiceLevelBindingReplayTests if (Directory.Exists(clientPki)) Directory.Delete(clientPki, recursive: true); } } + + /// + /// lmxopcua#801: SDK 2.0 recomputes Server.ServiceLevel from session headroom on every session create/close + /// and overwrites any value >= 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. + /// + [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.Instance); + }, () => new OtOpcUaSdkServer(), ct); + await using var host = started.Host; + var publisher = new SdkServiceLevelPublisher(started.Server.CurrentInstance, + NullLogger.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 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 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()!; + } }