SDK 2.0.1: Server.ServiceLevel reads 255 on both pair nodes — SDK session-headroom calculator overwrites 250/240/200 (publisher never claims IServerServiceLevelControl) #884
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 while running the #801 pair-behaviour gate on the local docker-dev rig (master
438484fd, SDK 2.0.1).Symptom
On the docker-dev central pair,
Server.ServiceLevel(i=2267) reads 255 on both nodes, so a client can't tell the Primary from the Secondary. The nodes' own metric shows the right levels:otopcua_redundancy_service_level_change_total(computed and published)i=2267drivermember = Primary)240:1 250:1240:1Polling
i=2267on central-1 just after a restart shows the bootstrap100(14:43:40). The value then jumps to255with a fresh SourceTimestamp at the moment the next client session opens (14:43:48). It stays at 255 even though the node published 240.Cause
SDK 2.0.1 adds a session-headroom ServiceLevel calculator.
ServerInternalData.OnSessionCountChangedruns on everySessionCreatedandSessionClosing, andUpdateServerServiceLeveloverwritesServerObject.ServiceLevelwithServerServiceLevelCalculator.CalculateTarget(...)whenever the current value is ≥ 200 and no explicit owner has claimed it. That target is 255 when the server is lightly loaded. A provider opts out by callingIServerServiceLevelControl.ClaimServiceLevelControl(), which returns the writer to use from then on.SdkServiceLevelPublishernever claims it. It writesServerObject.ServiceLevel.ValueinsideUpdateServerDiagnostics, so:ServiceLevelBindingReplayTestsdidn't catch it. It publishes 0/100 before the session opens and reads once, so no session-count change happens after a ≥200 publish.Repro
Published_level_survives_later_sessions(250|240|200)(on branchfix/801-servicelevel-claim) publishes a level, opens and closes sessions, and readsi=2267. On master'sSdkServiceLevelPublisherall three cases fail on the first session (should be 250/240/200, read 255).docker compose -f docker-dev/docker-compose.yml up -d central-1 central-2, thenotopcua-cli redundancy -u opc.tcp://localhost:4840and the same on:4841. Both nodes showService Level: 255, whilecurl central-N:9000/metrics | grep service_level_changeshows 250 and 240.Fix (branch, not merged)
fix/801-servicelevel-claim@cc76249a(rebased on master0f001bcc) is a small change.SdkServiceLevelPublisherclaimsIServerServiceLevelControlin its constructor and publishes through the returned writer. A server without the claim (the unit-test DispatchProxy doubles) keeps the old path. If the claim fails, the existing binding catch marks the endpoint failed instead of leaving a silent 255. Tests:OpcUaServer.TestsServiceLevel 7/7,Runtime.TestsServiceLevel 4/4 andHost.TestsOtOpcUaServerHostedService*15/15 still pass.On the docker-dev pair with the fix image, the wire reads 250 and 240 and follows failover. See the #801 results comment.
Impact
5fb4b4db(SDK 2.0.1) since 2026-11-12. It's a single node, so it advertises 255 instead of 250. That's harmless there, except that a Stale 200 would also read 255.Merged to
master:f66b9dae(the fix, rebased fromcc76249aontoda948e5f) and5e3fb422(test hardening). Fast-forward.Review of the claim
I checked each write path against the decompiled SDK 2.0.1 (
ServerInternalData.ClaimServiceLevelControl/PublishServiceLevel/UpdateServerServiceLevel):src/the only writer isOpcUaPublishActor→DeferredServiceLevelPublisher→SdkServiceLevelPublisher. After the bind, every tier goes through the claimed writer. Nothing else writesServer.ServiceLevel.OtOpcUaServerHostedService.StartAsync), and the deferred publisher replays the latest request under its lock. There are two gaps before that. From SDKRunningto the bind, the SDK default 255 still stands. If no redundancy snapshot has arrived yet, no request is held, so 255 stays after the bind too. Both are the #885 window, fixed separately.DetachSinksunbinds, and nothing publishes on stop, because connection-refused is the failover signal. The claim lasts for the server's lifetime, so sessions closing while the node stops can no longer lift the last value to 255. Before the fix they could.m_serviceLevelLock, and the calculator checks the owner flag under that lock. A session event therefore can't overwrite a claimed value, even when the two interleave. On our side,DeferredServiceLevelPublisher._gateserialises publish and bind. The write no longer goes throughUpdateServerDiagnostics. That lock never protected ServiceLevel from the calculator, so nothing is lost.SessionCreatedandSessionClosing, since they share one handler and one owner check.ClaimServiceLevelControlon the sameServerInternalDatathrows. There's one construction perStartAsync, and each start builds a freshOtOpcUaSdkServer, so this can't happen. A failed claim now fails the endpoint at bind. Before, a missing ServiceLevel node failed only on the first publish. That's stricter, and the right way round.StatusCode = Goodthe way the old path did. That makes no difference, because nothing ever sets it to anything else.Test gap, fixed in
5e3fb422. The new test disposed its sessions. That can leave a session to time out instead of raisingSessionClosing, so only theSessionCreatedhalf was proven. The 200 case's second level was 190, which is below the SDK's 200 floor, so that step was vacuous. Sessions nowCloseAsyncexplicitly. A second level (230) is published and read back on a surviving session after another session closes on its own.Mutation checks
if (false && …)):Published_level_survives_later_sessionsfails in 3/3 cases on the first session read (255).SessionClosingpath is exercised and the assertion can fail.Tests (rebased tree)
OpcUaServer.IntegrationTests: full suite 309 passed / 35 skipped / 0 failed.ServiceLevelBindingReplayTests5/5.OpcUaServer.TestsServiceLevel 7/7.Runtime.TestsServiceLevel +OpcUaPublishActor*108/108.Host.TestsOtOpcUaServerHostedService*+FailedServiceLevelBindingCleanup18/18.Commons.TestsDeferred*15/15.Cluster.TestsServiceLevel/Redundancy 16/16.ControlPlane.TestsServiceLevel/Redundancy 25/25.Rig verification of the wire pair values follows on #801 once #885 has landed. DEV vd03 (
5fb4b4db) still advertises 255 until it's redeployed.