fix(redundancy): advertise ServiceLevel 0 until the node has a redundancy level (#885)
v2-ci / unit-tests (push) Has been cancelled

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
This commit is contained in:
Joseph Doherty
2026-11-12 11:11:11 -05:00
parent 5e3fb4229d
commit 3fff439b11
3 changed files with 93 additions and 4 deletions
@@ -305,11 +305,30 @@ public sealed class OtOpcUaSdkServer : StandardServer
}
}
/// <summary>
/// The <c>Server.ServiceLevel</c> a node advertises from the moment its endpoint accepts sessions until the
/// redundancy layer publishes a real level (lmxopcua#885): <c>0</c>, the design's Down / Detached tier
/// (<c>ServiceLevelCalculator</c> returns 0 for a member that is not Up/Joining, and a node that has not yet
/// received its first redundancy snapshot has no role). The SDK starts the value at 255 — full service — so
/// a restarting pair node out-ranked the real Primary (250) for the ~10 s it took to join. Below the SDK's
/// 200 floor, so the session-headroom calculator never lifts it either.
/// </summary>
public const byte StartupServiceLevel = 0;
/// <inheritdoc />
protected override async ValueTask OnNodeManagerStartedAsync(
IServerInternal server, CancellationToken cancellationToken = default)
{
await base.OnNodeManagerStartedAsync(server, cancellationToken).ConfigureAwait(false);
// Runs after CreateServerObjectAsync wrote the SDK's 255 and before the server goes Running, so no session can
// ever read 255. SdkServiceLevelPublisher claims the value once the host binds and replays any level the
// redundancy layer already requested; until one is requested this value stands.
if (server.ServerObject?.ServiceLevel is { } serviceLevel)
{
serviceLevel.Value = StartupServiceLevel;
serviceLevel.Timestamp = DateTime.UtcNow;
serviceLevel.ClearChangeMasks(server.DefaultSystemContext, includeChildren: false);
}
var auditType = server.DiagnosticsNodeManager?
.FindPredefinedNode<BaseObjectTypeState>(ObjectTypeIds.AuditWriteUpdateEventType)
?? throw new InvalidOperationException("AuditWriteUpdateEventType is unavailable during server startup.");