Restarting pair node advertises ServiceLevel 255 for ~10 s before its first redundancy snapshot (outranks the real Primary) #885
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found during the #801 pair-behaviour gate on the local docker-dev central pair. The image was #884's fix branch (
cc76249a), so the SDK session-headroom overwrite is out of the picture.Symptom
A pair node that is restarting serves
Server.ServiceLevel = 255from the moment its OPC UA endpoint listens until its first redundancy snapshot arrives. That's about 10–13 s on the rig. During that window it outranks the real Primary (250), so a client that picks the highest ServiceLevel would choose the node that hasn't even joined the cluster yet.Polling
i=2267once a second while the old Primary (central-2) wasdocker started after a failover kill:(central-1's 240 → 200 → 250 is its own promotion. It passes through the Stale tier for about 3 s while the
redundancy-statesingleton moves. That's expected.)Cause
OpcUaPublishActor.RecomputeServiceLevelreturns early while_localNode is null || _lastSnapshot is null. Until the node joins and receives the singleton's firstRedundancyStateChanged, nothing is published, so the SDK's built-in 255 stands. SDK 2.0.1 also writes 255 explicitly at server init (ServerInternalData,serverObject.ServiceLevel.Value = 255). #719/#720 made a requested level survive the binding race, but no level is requested in this window.docs/Redundancy.mdsays "a starting or detached node never leaves the SDK default 255". That holds only once a snapshot exists.The same window happens on every cold start and on a rolling restart of either node. It's most visible after a failover, when the surviving Primary is still at 240/200 and the rejoining node shows 255.
Suggested fix (not done here)
Publish a low "not yet in service" level, for example 0 or the 2–199 degraded band, at SDK binding time when no level has been requested yet. For example,
DeferredServiceLevelPublisher.SetInnercould replay 0 when it holds no request, orOpcUaPublishActorcould publish 0 before the first snapshot. Then cover it with a test that binds with no request and readsi=2267≠ 255. This needs #884's claim, or SDK 2's session-headroom logic will lift any value ≥ 200 back to 255. A value below 200 is left alone either way.Repro
docker kill otopcua-dev-central-2-1; sleep 20; docker start otopcua-dev-central-2-1.otopcua-cli read -u opc.tcp://localhost:4841 -n i=2267every second. It reads 255 until the node logsWelcome from [...central-1...].Fixed on
masterin3fff439b, on top of #884's claim (f66b9dae/5e3fb422).Fix
OtOpcUaSdkServernow writesStartupServiceLevel = 0inOnNodeManagerStartedAsync. The decompiled SDK 2.0.1StandardServer.StartApplicationAsyncruns that override afterCreateServerObjectAsynchas written its 255 andSetSessionManagerhas run, and beforeSetServerState(Running). So no session can ever read 255. TheSdkServiceLevelPublisherclaim (#884) replays whatever level the redundancy layer has already requested when the host binds. If nothing has been requested yet, 0 stands until the first snapshot.Why 0. It's the design's existing level for this state.
docs/Redundancy.mddefines Down / Detached as 0, andServiceLevelCalculatorreturns 0 for a member that isn't Up/Joining. A node with no redundancy snapshot has no role yet. 0 is also below the SDK 2 session-headroom floor of 200, so the SDK never lifts it, even before the claim.Single node. The node still settles: its first snapshot publishes 250 exactly as before. The new test covers this, and so does the rig (all three pairs settle, see below).
Test
ServiceLevelBindingReplayTests.Node_with_no_requested_level_advertises_the_startup_level_until_one_is_publishedchecks three stages:Mutation check: with the startup write disabled, the test fails on the first read, which returns 255.
The existing replay theory's
0case no longer tells "replayed" apart from "not replayed", so I commented it. The100case remains the replay evidence.Suites:
OpcUaServer.Tests1265/0/1 skipped.OpcUaServer.IntegrationTests310 passed / 35 skipped / 0 failed.Host.Testshosted-service + health 44/44.Rig (docker-dev, image built from
3fff439b, ACL off)Polled
i=2267on both central nodes. Each sample opens a fresh session, roughly every 2 s:0 → 100 → 240, c2110 → 250. No 255 at any point.docker startit 45 s later: c1240 → 200 → 250. c2 reads0for about 6 s (the old 255 window), then240. It never reads above 240.docker restartof the Secondary (c2):0 → 100 → 240.Full record on #801.