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
This commit is contained in:
Joseph Doherty
2026-11-12 09:55:25 -05:00
parent da948e5fb5
commit f66b9daec0
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)
{