Repository navigation
Fix the Sessions LoadTest fixture and the server bottlenecks it exposed - #4749
Conversation
The LoadTest fixture set MaxChannelCount equal to MaxSessionCount, which resource isolation rejects at startup since #4541, so every LoadTest case failed in OneTimeSetUp. Running it again exposed: - detached monitored item recovery scanned every monitored item of every subscription for each node a NodeManager added, which made CreateSubscription quadratic in the monitored item count - the client request path entered the channel gate with a blocking wait - status code and symbolic id translation took the ResourceManager lock for every notification of sessions that request diagnostics - translation tables used the culture-sensitive string comparer - namespace default permissions were read and deep-copied per node The test counts notifications with per-node interlocked counters and waits until delivery stops instead of a fixed delay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The permission optimization omits synchronous node managers, and the recovery gate lacks regression coverage.
2 open findings
What changed in this PR
Fixes the Sessions load fixture and optimizes server/client hot paths exposed by load testing.
Changes:
- Repairs load-test resource limits and notification accounting/draining.
- Reduces permission, translation, and detached-item recovery overhead.
- Makes client channel-gate acquisition asynchronous.
| File | Description |
|---|---|
tests/Opc.Ua.Sessions.Tests/LoadTest.cs |
Repairs setup and notification measurement. |
tests/Opc.Ua.Server.Tests/OperationContextDefaultPermissionsCacheTests.cs |
Tests permission-cache behavior. |
src/Opc.Ua.Server/Subscription/MonitoredItem/MonitoredItem.cs |
Tracks recoverable items globally. |
src/Opc.Ua.Server/Server/OperationContext.cs |
Adds per-request permission caching. |
src/Opc.Ua.Server/NodeManager/ResourceManager.cs |
Introduces lock-free translation snapshots. |
src/Opc.Ua.Server/NodeManager/MasterNodeManager.cs |
Skips unnecessary recovery scans. |
src/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs |
Uses cached namespace permissions. |
src/Opc.Ua.Core/Stack/Tcp/UaSCBinaryClientChannel.cs |
Awaits channel-gate entry. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The event test waited 20 publishing intervals after the last event and counted everything still queued as lost. It now waits until the received count stops growing and logs the drain time; every event is delivered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Code coverage✅ Coverage gate passed.
Uncovered changed lines
Coverage is above the recorded baseline - consider ratcheting Thresholds live in |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #4749 +/- ##
========================================
Coverage 82.82% 82.82%
========================================
Files 2300 2304 +4
Lines 351259 351645 +386
Branches 61305 61392 +87
========================================
+ Hits 290928 291251 +323
- Misses 39857 39880 +23
- Partials 20474 20514 +40
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
…ecovery count - CustomNodeManager2.SetDefaultPermissions reads the namespace defaults once per namespace and service request through the same OperationContext cache as AsyncCustomNodeManager. - Regression tests that every detach state transition of a MonitoredItem (deleted, detached, rebound, restored, disposed) keeps the recoverable count exact. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

The
[Explicit]SessionsLoadTestfixture has not started since #4541 (also on the 2.0.0 tag): it setMaxChannelCount = MaxSessionCount = 10100, and resource isolation refuses to start unless the channel cap covers the sessions plus the bootstrap and reconnect floors plus one. Every LoadTest case failed inOneTimeSetUp. Running the fixture again, and profiling it and theperf/ServerLoadHarness, found several bottlenecks that shipped in 2.0.Changes
Test
MaxChannelCount = MaxSessionCount + 3, the same margin as the framework defaults (100 / 103).Interlockedcounters. The sharedConcurrentDictionary<NodeId, int>, updated by 750 subscriptions, took 13 % of the process CPU in bucket-lock contention.Server / client
AddPredefinedNodeAsync, recursively for children) scanned every monitored item of every subscription for detached items.CreateSubscriptionadds ~30 diagnostics nodes, so with 450k monitored items subscription creation was quadratic: 69 % of CPU, andCreateSubscriptiontimed out.MonitoredItemnow keeps an exact process-wide count of live detached or deleted items, andMasterNodeManagerskips recovery while it is zero. All flag changes go through one helper under the item lock; a disposed item does not count, because it cannot be attached again.UaSCUaBinaryClientChannel.SendRequestAsyncentered the gate with a blockingGate.Enter()inside an async method, andSemaphoreSlim.Waitspun for 19 % of CPU with many requests in flight on one channel. It now awaitsGate.EnterAsync(ct), like the server side.SortedDictionary<string, string>with the default, culture-sensitive comparer: up to 1 µs per Browse display name, 22 % of Browse server CPU. They now use an ordinalDictionary.DefaultAccessRestrictions/DefaultRolePermissions/DefaultUserRolePermissionsof the node's namespace through two server-wide locks and deep-copied the role permission array, per node. That was 99 % of lock contention for Read/Write of 100 values. The values are now read once per namespace per service request and remembered on theOperationContext. Contexts that are not a service request (monitored item and session contexts, which can live long) remember nothing, so a changed default applies to them at once.Results
perf/ServerLoadHarness, paired A/B againstmaster(3 alternating rounds, medians, 20 sessions, 24 cores). The harness numbers predate the recovery, client gate and status code translation commits, which those scenarios do not exercise:¹ Throughput varied run to run on the shared machine; per-op CPU and allocation are the reliable signal.
² Browse contention is the Server node's browse lock in
NodeState.CreateBrowser(all sessions browse the same node); not changed here.LoadTest(opc.tcp, Release, in-process client and server):ServerReadLoadTestAsyncServerManySessionsLoadTestAsync(500)ServerSubscribeLoadTest_ClassicCreateSubscriptionBadRequestTimeoutServerSubscribeLoadTest_ManagedSessionV2ServerEventSubscribeLoadTestAsyncThe subscribe cases now lose notifications rather than deliver them late: delivery stops ~2 s after the last write. Every monitored item has queue size 1 and the writer is not paced by delivery, so whatever exceeds the publish rate (~585k notifications/s here, SignAndEncrypt with the client in the same process) is overwritten.
The event test lost nothing either: it waited a fixed 2 s after the last event and counted the backlog as lost. It now waits until delivery stops. Its throughput is bound by garbage collection: the event consumer of the Server node spends ~90 % of its wall time suspended for workstation GCs of the shared test process. With
DOTNET_gcServer=1it generates 702 instead of ~210 events/s and drains in 7 s instead of ~40 s, and the subscribe cases reach 87 % (Classic) and 93 % (ManagedSessionV2) at 78–106 M notifications.Testing
Opc.Ua.Server.Tests8468 passed / 5 skipped,Opc.Ua.Client.Tests2788 passed,Opc.Ua.Sessions.Tests851 passed / 379 skipped,Opc.Ua.Core.TestsTcp/Channel 797 passed (net10.0).OperationContextDefaultPermissionsCacheTests.Opc.Ua.Serverbuilds for net48;dotnet format whitespaceclean.🤖 Generated with Claude Code