Skip to content

Server quick wins from the o6 benchmark gap analysis: admission sizing, sampling permission cache, routed Read dispatch - #4743

Merged
marcschier merged 5 commits into
masterfrom
romanett/server-quick-wins
Oct 12, 2026
Merged

marcschier merged 5 commits into
masterfrom
romanett/server-quick-wins

Conversation

@romanett

Copy link
Copy Markdown
Contributor

The o6-automation benchmark gap analysis listed several server quick wins that need no wire or public API change. This PR implements three of them. The 2.0 server now passes the benchmark's 10 clients × 1000 outstanding Reads test with the benchmark's own queue sizing. Its 100 Hz sampling capacity grows about 7–10× on the same machine, and a single-value Read allocates 8 % less.

Results

Paired A/B against master (0716603): alternating builds, 3 rounds (5 for Read), medians, 24-core machine shared with other sessions. Allocated bytes and CPU per operation are the reliable signal here. Throughput varied up to 3× between rounds of the same build, so it is listed only where the result is pass/fail.

Scenario Server alloc / op Server CPU / op Result
o6 sub_client, 2048 counters, 10 ms sampling, 1 s publishing 2309 → 111 B / sample (−95 %) 3.75 → 1.14 µs / sample (−70 %) PASS on both
Harness counters, 2048 items, 10 ms / 1 s 232 → 11.2 KB / notification (−95 %) −74 % GC pause 644 → 24 ms in 12 s
o6 sampling capacity (largest count with every item ≥ 99 % of samples, doubling from 1024, then bisect) 2176–2304 → 16384–22528 items (2 searches each)
o6 client, 10 × 1000 outstanding Reads, MaxQueuedRequestCount = 10000 master fails 3/3 (BadServerTooBusy), this PR passes 3/3 (31–36k req/s)
o6 client, 10 × 100 outstanding Reads 7823 → 7556 B (−3 %) ±0
Read, 1 value (20 sessions) 6534 → 6020 B (−8 %) −1 %
Read, 100 values −1 % ±0
Write, 100 values −1 % −5 %

For reference, open62541 (single thread, same Windows machine and native client) sustained 5632 items in the gap analysis.

Changes

1. MaxQueuedRequestCount admits that many established requests (Balanced / TrustedReservations)

  • ServerResourceIsolationOptions.CreateRuntimePlan carved the bootstrap, reconnect, control and trusted floors out of the configured queue, so established sessions could queue only N − 3. The default totals of the decoded-request admission stages (RequestQueue, RequestQueueBytes, ParkedRequest) now add the floors on top; the shared capacity equals the configured value.
  • FairRequestQueue takes its physical bound from the provider's stage capacity (internal IResourceIsolationCapacitySource), so the protected entries stay reachable when the shared part is full.
  • RequestExecution keeps its floors inside the worker limit, so an idle worker remains for protected requests. Explicit Stages[].Capacity overrides keep their meaning as absolute totals including the floors.

2. Sampling without a role permission check per sample

  • Since Fix audit findings and review regressions across the OPC UA stack #4503 every sampling cycle called ValidateRolePermissionsAsync for every item. For a CustomNodeManager2 node that built full node metadata (13-attribute read, namespace defaults via sync-over-async, three nested node manager operations) only to return Good: about 80 % of the 2.3 KB per sample.
  • Each sampling loop now keeps per-item state between cycles: ReadValueIds, result buffers and the last permission verdict. A verdict is reused while the item's Session, effective identity and node handle are unchanged and no permission change was signalled:
    • NodeState bumps an internal version counter when RolePermissions or UserRolePermissions of any node change (setter or Write);
    • IConfigurationNodeManager.DefaultPermissionsChanged (namespace defaults);
    • a new EffectiveIdentity instance (ActivateSession with another user, or roles refreshed after RoleConfigurationChanged).
  • Independently, every item is revalidated once per second, spread over the cycles, which bounds the delay for permissions that a node manager computes without a signal (OnReadRolePermissions, custom ValidateRolePermissionsAsync).
  • The loop reuses its item snapshot until items are added or removed, reads all items of one owner with one context instead of a LINQ GroupBy (items moved to another session by TransferSubscriptions still use the per-owner path), marks disabled items processed instead of rebuilding the list, and checks for a detached loop without taking the group lock.
  • Opc.Ua.Types grants InternalsVisibleTo to Opc.Ua.Server for the version counter.

3. Read and Write call only the node managers routed for the request

  • NodeManagerServiceDispatcher.ReadAsync/WriteAsync called every node manager. Every AsyncCustomNodeManager (configuration, diagnostics, core, …) then copied the system context and allocated a dictionary and a list, even with none of the requested nodes in its namespaces.
  • Validation now collects the namespace routes of the valid operations and the dispatcher skips node managers outside them. Every manager of a requested namespace is still called in dispatch order, so overlapping managers keep their precedence. A namespace without a route still calls every node manager, which keeps BadNodeIdUnknown and custom routing unchanged.
  • Not done: reusing the handle resolved during validation inside ReadAsync needs a new node manager overload (public API), so nodes are still resolved twice. HistoryRead and Call keep the old dispatch: Call routes by object and method, and HistoryRead releases continuation points per manager.

Behaviour notes

  • MaxQueuedRequestCount keeps its 1.5.x meaning again: established sessions can have that many decoded requests waiting. Balanced and TrustedReservations size the RequestQueue stage at MaxQueuedRequestCount + floors (for example 10,003 for 10,000). A deployment that relied on the smaller queue lowers MaxQueuedRequestCount or sets the RequestQueue Capacity. docs/MigrationGuide.md, docs/ResourceIsolation.md and docs/ServerScalability.md are updated.
  • Permission changes for sampled items still apply within one or two sampling cycles when they come through NodeState.RolePermissions/UserRolePermissions, namespace default permissions, or a new effective identity. Permissions that a node manager computes without changing those (for example OnReadRolePermissions or a custom ValidateRolePermissionsAsync) apply within one second instead of on the next sample. docs/NodeManagers.md describes it.
  • Custom node managers that process Reads or Writes of nodes in namespaces they did not register receive those calls only while some valid node of the request is in an unrouted namespace. Such a manager registers its namespaces with RegisterNamespaceManager (documented in docs/NodeManagers.md).

Testing

  • New unit tests:
    • RuntimeResourceIsolationTests: plan sizing per mode, explicit capacities, and a fair queue that admits exactly MaxQueuedRequestCount requests from four owners, rejects the next and still admits bootstrap and reconnect.
    • SamplingGroupPermissionCacheTests: verdict reuse, revocation through node RolePermissions in the same cycle, namespace default change, identity change, periodic revalidation, disabled items. Each change is injected inside the Read of a known cycle, so the tests do not depend on timing.
    • NodeManagerDispatchRoutingTests: non-owning managers are skipped, several owners keep result order, an unrouted namespace still calls everyone, two managers of one namespace keep their precedence, Write.
  • net10.0: Opc.Ua.Server.Tests 8462 passed, Opc.Ua.Subscriptions.Tests 643 passed, Opc.Ua.Sessions.Tests 851 passed / 379 skipped, Opc.Ua.Core.Tests (Stack.Server) 1858 passed, Opc.Ua.Stress.Tests Contract|Integration 45 passed; 0 failures.
  • net48 build of Opc.Ua.Server.Tests clean; dotnet format whitespace clean.
  • Measured with perf/ServerLoadHarness and the o6 scenarios (o6read, o6counters) from the gap-analysis harness, which is not part of this PR.

🤖 Generated with Claude Code

romanett and others added 3 commits October 12, 2026 08:10
…tion

Balanced and TrustedReservations carved their bootstrap, reconnect,
control and trusted floors out of the configured request queue. With
MaxQueuedRequestCount = N, established Sessions could queue only N - 3,
so a client keeping exactly N requests outstanding (o6 benchmark,
10 clients x 1000 with N = 10000) received BadServerTooBusy.

The default totals of the decoded-request admission stages
(RequestQueue, RequestQueueBytes, ParkedRequest) now add the floors on
top of the configured value, so the shared capacity equals it. The
fair request queue sizes its physical bound from the provider's stage
capacity so the protected entries stay reachable when the shared part
is full. RequestExecution keeps its floors inside the worker limit.
Explicit stage capacities are unchanged absolute totals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ReadAsync and WriteAsync called every node manager, so each request
paid a system context copy, a dictionary and a list in every
AsyncCustomNodeManager (configuration, diagnostics, core, ...) even if
none of the requested nodes was in its namespaces.

Validation now collects the namespace routes of the valid operations
and the dispatcher skips node managers that no route includes. Every
node manager of a requested namespace is still called in dispatch
order, so overlapping managers keep their precedence, and a namespace
without a route still calls every node manager, which keeps
BadNodeIdUnknown and custom routing unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since #4503 every sampling cycle validated the Read permission of every
item through ValidateRolePermissionsAsync. For a CustomNodeManager2 node
that built full node metadata (13 attributes, namespace defaults with a
sync-over-async lookup, nested node manager operations) only to return
Good: about 80 % of the 2.2 KB allocated per sample.

Each sampling loop now keeps per-item state between cycles: the
ReadValueIds, result buffers and the last permission verdict. A verdict
is reused while the item's Session, effective identity and node handle
are unchanged and no RolePermissions/UserRolePermissions of a node
(a version counter in NodeState) and no namespace default permissions
(DefaultPermissionsChanged) changed. Independently of these signals,
every item is revalidated once per second, spread over the cycles.

The loop also reuses its item snapshot until items are added or
removed, reads all items of one owner with one context instead of a
LINQ GroupBy, marks disabled items processed instead of rebuilding the
list, and checks for a detached loop without taking the group lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 12, 2026 06:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Split routing snapshots can misroute concurrent requests, and periodic permission revalidation can exceed its documented security bound.

7 open findings
What changed in this PR

Improves server scalability through queue admission sizing, cached sampling permissions, and namespace-routed Read/Write dispatch.

Changes:

  • Preserves configured shared request-queue capacity while adding protected reservations.
  • Caches sampling permission verdicts with change-based and periodic invalidation.
  • Routes Read/Write operations only to relevant node managers, with regression tests and documentation.
File Description
tests/​Opc.Ua.Server.Tests/​ResourceIsolation/​RuntimeResourceIsolationTests.cs Tests queue sizing and protected reservations.
tests/​Opc.Ua.Server.Tests/​NodeManagerDispatchRoutingTests.cs Tests namespace-routed Read/Write dispatch.
tests/​Opc.Ua.Server.Tests/​NodeManager/​SamplingGroupPermissionCacheTests.cs Tests permission-cache invalidation behavior.
src/​Opc.Ua.Types/​State/​NodeState.cs Tracks role-permission changes globally.
src/​Opc.Ua.Types/​Opc.Ua.Types.csproj Grants server access to internal permission versioning.
src/​Opc.Ua.Server/​ResourceIsolation/​ServerResourceIsolationOptions.cs Adds protected floors above decoded-request capacities.
src/​Opc.Ua.Server/​ResourceIsolation/​DefaultServerResourceIsolationProvider.cs Exposes stage capacity internally.
src/​Opc.Ua.Server/​NodeManager/​NodeManagerServiceDispatcher.cs Filters Read/Write dispatch by namespace route.
src/​Opc.Ua.Server/​NodeManager/​MonitoredItem/​SamplingGroup.cs Reuses sampling state and permission verdicts.
src/​Opc.Ua.Server/​NodeManager/​Lifecycle/​NodeManagerRoutingTable.cs Exposes manager identity comparison internally.
src/​Opc.Ua.Core/​Stack/​Server/​IServerResourceIsolationProvider.cs Adds internal capacity-source capability.
src/​Opc.Ua.Core/​Stack/​Server/​FairRequestQueue.cs Aligns physical queue bounds with provider capacity.
docs/​ServerScalability.md Documents queue-sizing guidance.
docs/​ResourceIsolation.md Documents additive request-stage reservations.
docs/​NodeManagers.md Documents routed dispatch and permission caching.
docs/​MigrationGuide.md Explains restored queue semantics.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Opc.Ua.Server/NodeManager/MonitoredItem/SamplingGroup.cs Outdated
Comment thread src/Opc.Ua.Server/NodeManager/NodeManagerServiceDispatcher.cs Outdated
Comment thread src/Opc.Ua.Server/NodeManager/NodeManagerServiceDispatcher.cs Outdated
Comment thread src/Opc.Ua.Core/Stack/Server/IServerResourceIsolationProvider.cs Outdated
Comment thread tests/Opc.Ua.Server.Tests/NodeManager/SamplingGroupPermissionCacheTests.cs Outdated
Comment thread tests/Opc.Ua.Server.Tests/NodeManagerDispatchRoutingTests.cs Outdated
Comment thread tests/Opc.Ua.Server.Tests/ResourceIsolation/RuntimeResourceIsolationTests.cs Outdated
- Read and Write take the dispatch order and the namespace routes from
  one routing snapshot, so a concurrent lifecycle replacement cannot
  make the route filter skip both generations of a node manager.
- The sampling permission revalidation stride rounds down, so no
  verdict is reused for longer than one second (600 ms sampling
  revalidates every cycle instead of every 1.2 s).
- Comment typos, a dedicated Lock in the new tests, sealed fixture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Code coverage

✅ Coverage gate passed.

Check Result Threshold
✅ Project line rate 88.59% (311072/351117 lines) >= 70.00%
✅ Project branch rate 79.00% >= 60.00%
✅ Patch coverage 97.52% (197/202 changed lines) >= 75.00% (> 100 changed lines)
ℹ️ Baseline delta (advisory) +14.99 pp 73.60% recorded
Uncovered changed lines
  • src/Opc.Ua.Server/NodeManager/MonitoredItem/SamplingGroup.cs: 198, 882, 885, 1002
  • src/Opc.Ua.Server/NodeManager/NodeManagerServiceDispatcher.cs: 4081

Coverage is above the recorded baseline - consider ratcheting coverage-thresholds.json.

Thresholds live in coverage-thresholds.json. Whole report before exclusions: line 87.74%, branch 78.22%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@marcschier
marcschier enabled auto-merge (squash) October 12, 2026 08:22
@marcschier marcschier added the ready Ready to merge once CI Passes label Oct 12, 2026
@marcschier
marcschier merged commit 0dff94b into master Oct 12, 2026
115 checks passed
@codecov

codecov Bot commented Oct 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.06931% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.81%. Comparing base (0716603) to head (7da66f0).
⚠️ Report is 3 commits behind head on master.

Files with missing lines Patch % Lines
....Server/NodeManager/MonitoredItem/SamplingGroup.cs 92.48% 4 Missing and 6 partials ⚠️
...Server/NodeManager/NodeManagerServiceDispatcher.cs 95.45% 1 Missing and 1 partial ⚠️
src/Opc.Ua.Core/Stack/Server/FairRequestQueue.cs 75.00% 0 Missing and 1 partial ⚠️
...esourceIsolation/ServerResourceIsolationOptions.cs 90.90% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #4743      +/-   ##
==========================================
- Coverage   82.86%   82.81%   -0.06%     
==========================================
  Files        2268     2299      +31     
  Lines      346319   351117    +4798     
  Branches    60386    61261     +875     
==========================================
+ Hits       286985   290763    +3778     
- Misses      39233    39875     +642     
- Partials    20101    20479     +378     
Flag Coverage Δ
actions 82.81% <93.06%> (-0.06%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...e/Stack/Server/IServerResourceIsolationProvider.cs 29.41% <ø> (ø)
...r/NodeManager/Lifecycle/NodeManagerRoutingTable.cs 84.12% <100.00%> (+0.06%) ⬆️
...solation/DefaultServerResourceIsolationProvider.cs 87.91% <100.00%> (+0.03%) ⬆️
src/Opc.Ua.Types/State/NodeState.cs 83.46% <100.00%> (+0.07%) ⬆️
src/Opc.Ua.Core/Stack/Server/FairRequestQueue.cs 94.09% <75.00%> (-0.29%) ⬇️
...esourceIsolation/ServerResourceIsolationOptions.cs 77.54% <90.90%> (+0.89%) ⬆️
...Server/NodeManager/NodeManagerServiceDispatcher.cs 87.39% <95.45%> (+0.17%) ⬆️
....Server/NodeManager/MonitoredItem/SamplingGroup.cs 93.50% <92.48%> (+4.42%) ⬆️

... and 103 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

romanett added a commit that referenced this pull request Oct 12, 2026
…that are actually reused, ready list instead of an O(N) scan (#4748)

Roadmap item A5 of the o6 benchmark gap analysis. Every Publish response
deep-cloned its NotificationMessage in `SentMessageQueue` (PR #4707 kept
the clone because a retained message returns its pooled payload when the
client acknowledges it, which can happen before the response was sent).
This PR replaces the clone with a send-completion lease, makes the
activator pool that recycles the notifications O(1) and sizes it for the
server's load, builds messages without intermediate copies (the
notification array is rented), and lets monitored items tell their
subscription when they have something to report so the publish timer no
longer walks every item.

## Results

Paired A/B against master `0dff94bd3` (includes #4742 and #4743;
alternating builds, 3 rounds, medians; 20 sessions unless noted,
workstation GC, 24-core machine shared with other sessions). Server =
the reference server child process.

| Scenario | Server alloc / op | Server GC pause | Server CPU / op |
Write→notify p50 / p99 | Throughput |
|---|---|---|---|---|---|
| `sub`, 5 subs × 100 items, pub 100 ms | **−57 %** (373 → 159 B) |
**−57 %** | −7 % ¹ | −1 % / −2 % | +1 % ² |
| `sub`, 5 subs × 100 items, pub 10 ms | **−57 %** (375 → 163 B) | **−52
%** | +9 % ¹ | −22 % / +1 % | ±0 ² |
| `sub`, pub 100 ms, SignAndEncrypt | **−57 %** (380 → 163 B) | **−53
%** | +4 % ¹ | −11 % / **−12 %** | ±0 ² |
| `sub`, 2 sessions × 1 sub × 1000 items, 20 changing, pub 50 ms (9
rounds) | −2 % ³ | ±0 | −23 % ³ | ±0 / −2 % | ±0 |
| `counters`, 4000 items, 10 ms sampling, pub 1 s (per notification) |
−2 % | −6 % | −6 % | – | ±0 |
| `o6counters`, 2048 items (per sample) | −3 % ⁴ (111 → 108 B) | −5 % |
−7 % | – | ±0, all PASS |
| `o6counters`, 4096 items (per sample) | −3 % ⁴ | +14 % ⁵ | −6 % | – |
±0, all PASS |

¹ CPU per notification varies by ±15 % between rounds on this machine;
in an earlier 8-round batch against `071660354` the same scenarios
measured −14 % / −1 %. Read it as "no regression". Client CPU per
notification moved −18 %…0 %, within its spread.
² Bounded by the writer rate.
³ The server is nearly idle (0.05–0.12 cores, 360 notifications/s; its
allocation is the writer and keep-alives). The first 3 rounds measured
+31 % CPU, 6 more rounds −23 % (medians 969 → 742 ms per window):
noise-bound, no regression.
⁴ With #4743's sampling permission cache a sample costs 111 B; one
Publish per second carries 2048/4096 notifications.
⁵ The activator pools no longer allocate their 1023-slot arrays up
front; the heap is a few MB smaller, which shifts the GC's generation
budgets so that some low-allocation runs make every collection gen1 (26
of 26 instead of 2). Pause totals differ by milliseconds per 10 s window
(37 → 42 ms here, 21 → 20 ms at 2048 items). Allocating the arrays
eagerly restored master's gen1 counts in a separate test.

In a single CPU trace of `sub` at pub 10 ms, recycling acknowledged
payloads (pool `Return`) went from 16 % of server on-CPU time (master,
the full pool scanned per return) to 5 %.

## Changes

- **Send completion instead of a deep clone**
(`NotificationMessageLease`, `LeasedPublishResponse`,
`SentMessageQueue`): the retransmission queue and every Publish response
that carries a message each own a reference. The transports already call
`IPooledEncodeable.Reuse()` on a response once it is encoded or dropped;
the Publish response is now a `LeasedPublishResponse` whose `Reuse()`
releases its reference. The pooled payload (`NotificationMessage`,
`DataChangeNotification`/`EventNotificationList`,
`MonitoredItemNotification`/`EventFieldList`) is recycled when the last
owner releases it, so an acknowledgement that overtakes the send no
longer recycles a payload that is still being encoded, and the queue and
the response share one message. A reference that is never released only
leaves the payload to the GC; every owner releases at most once.
Keep-alive messages are owned by their response alone. Republish, the
durable snapshot and the retransmission mirror still copy.
- **Transports**: the HTTPS (binary and JSON), WebSocket (UA-SC, JSON,
OpenAPI) and Kestrel TCP listeners and the WebAPI endpoint dispatcher
now release responses after encoding, as the TCP listener does, so
leased payloads are recycled there too. A response retained for a
reconnect is released when it is sent; a send that throws leaves the
response to the GC.
- **Activator pool** (`BoundedObjectPool`): rent and return scanned the
1023 slots linearly; with the pool full every returned notification
scanned all of them before being dropped (16 % of server CPU at 94k
notifications/s). It is now a `ConcurrentQueue` behind the lock-free
first slot with an `Interlocked` bound (a locked stack spun under
contention when transport threads return whole messages). The queue is
created on first use, so activators of types that are never recycled no
longer allocate a 1023-slot array each. The client decoder rents through
the same pool.
- **Pool bound** (`PooledEncodeableType<T>.EnsureMaximumRetained`,
internal): the bound can be raised, never lowered. The subscriptions
raise the `MonitoredItemNotification` and `EventFieldList` pools to
twice the number of monitored items in the process (a notification is
recycled about two publishing cycles after it was built), between the
default 1024 and 65536. At the cap the retained notifications take about
5 MB; the bound stays raised after items are deleted.
- **Message construction**
(`Subscription.InnerPublish`/`ConstructMessage`,
`MonitoredItem.Publish`): the per-Publish `Queue<>`/`List<>` objects are
reused by the subscription; messages are built at their final size (no
`List`→`ArrayOf` copy, no `AddItem` reallocation); the
`MonitoredItemNotification[]` of a message is rented from `ArrayPool`
and the message carries an exact-length slice of it; the lease returns
the array (cleared) with the payload, and messages dropped on queue
overflow return theirs at once; only arrays the subscription rented are
tracked, so restored, mirrored and copied messages never reach the array
pool; the `DiagnosticInfo` list is only allocated when a notification
has one; timestamps are stripped on the struct before the pooled
notification is filled.
- **Ready list** (`IReadySignalingMonitoredItem`,
`Subscription.PromoteReadyMonitoredItems`): `MonitoredItem` acquires a
ready signal (compare-exchange, a full fence after its flags are set)
when it queues a value or event or a resend is requested, and hands
itself to the subscription's intake list. The publish timer checks only
these candidates instead of every item under the subscription lock. An
item stays a candidate while it has something pending that can become
reportable without a new value (sampling deadline, aggregate interval,
samples of a SAMPLING item, trigger flag); after releasing the signal
the subscription checks again, so a value queued concurrently is never
missed. Created, restored, modified, monitoring-mode and triggering
changes mark candidates, as does an item that leaves the publish list
with something pending.

### Behaviour notes (custom subscription or monitored-item extensions)
- **Publish responses share the retained message.**
`PublishResponse.NotificationMessage` returned by
`SubscriptionManager.PublishAsync` is the retained message itself, not a
copy, and its payload objects return to the activator pools after the
response was sent and acknowledged. Code that keeps or modifies the
message of a Publish response (for example in a
`StandardServer.PublishAsync` override or a request interceptor) must
copy it first (`CoreUtils.Clone`). `Republish` still returns a copy.
- **Custom transports**: a transport that does not call `Reuse()` on
responses keeps working; the payload is then collected by the GC instead
of being pooled.
- **Custom `IMonitoredItem` implementations** (not derived from
`MonitoredItem`) cannot signal readiness: a subscription that contains
one scans all its items on every publishing cycle, as before.
`MonitoredItem` subclasses signal through the base class.
- **`DataChangeNotification.MonitoredItems` of a published message is a
slice of a pooled array**: read it through `ArrayOf<T>` (`Span`,
indexer, `Count`); code that unwraps the underlying array would see a
longer array.
- **Order within a message**: notifications of different monitored items
appear in the order the items became ready rather than in the internal
list order. OPC UA does not define an order across items; the order of
the values of one item is unchanged.

## Harness (`perf/`)
- `--write-nodes K` for `sub`: the writer changes only the first K
monitored nodes, for large subscriptions in which few items change.
- `server-scenarios.txt`: publish-path scenarios (`--pub 10`, 1000 items
per subscription with 20 changing).

## Testing
- New `NotificationEncodingLifetimeRegressionTests` cases: an
acknowledgement before send completion does not recycle the payload
still being encoded (the send completion does); Republish after the send
completed returns the original content and stays independent of the
recycled original; a failed send that releases the response twice, plus
a copy of the response, recycles the payload exactly once and only after
the queue released it; a keep-alive is owned by its response; the rented
notification array stays intact until both acknowledgement and send
completion (either order), the message encodes only its slice, and queue
overflow returns the arrays of dropped messages. The existing 30-case
matrix (publish / queued / republish / mirrors × acknowledge / evict /
clear × data / events) now asserts that a shared payload survives every
queue mutation until its response is released.
- New `ReadyListRegressionTests`: the same scenarios on a subscription
that uses the ready list and on one that scans (it also has an item that
cannot signal) must publish the same notifications, for triggering
(sampling items, backlog of the triggering item), monitoring-mode
changes (Sampling↔Reporting, Disabled, disabled before publish),
disabled publishing with keep-alives and ResendData, a value queued
while the item was in the publish list, and the backlog of an item that
left Reporting while in the publish list. Mutation check: removing the
item's signal fails 5 of 6, removing the subscription-side marks fails
the backlog case. Plus: a published item without pending values is not
tracked; creating 1200 items raises the notification pool bounds to at
least 2400.
- `PooledEncodeableTypeTests`: the raised bound is respected and never
lowered.
- net10.0 on the rebased branch: Server 8481 passed (5 skipped);
Sessions 851 (379 skipped); Client 2788; Subscriptions.Classic 57;
Subscriptions.Durable 36 (11 skipped); Redundancy.Server 884,
Redundancy.Client 134, Redundancy.Samples 17; Stress
Contract+Integration 45; Subscriptions 657 (6 skipped); Types pool tests
165. All 0 failed.
- net48 build of `Opc.Ua.Server.Tests` and `Opc.Ua.Bindings.Https` and
`Opc.Ua.Types.Tests` clean; `dotnet format whitespace` clean.
## Not changed (follow-ups)
- Republish still copies the message (`ISubscriptionManager.Republish`
returns a `NotificationMessage`, so the response cannot carry a lease
without an API change).
- `EventFieldList[]` per event message could be rented like the data
change array.
- Sampling remains the larger share of allocation and CPU in the
`counters`/`o6counters` scenarios.
🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
romanett added a commit that referenced this pull request Oct 13, 2026
…lidation (#4753)

# Description

Every request passes through `SessionManager.TryGetSessionContext` at
admission and again at dispatch
(`FairRequestQueue.GetRevalidationStatus` →
`DefaultServerResourceIsolationProvider` →
`ISessionBindingProvider.TryGetSessionContext`), and then through
`Session.ValidateRequest`. Together they took:

1. the server-wide `m_bindingsLock`, to confirm the candidate binding
was still the committed one;
2. the per-session diagnostics lock in `Session.HasExpired`. It is
inlined, so CPU traces charge its spin to `TryGetSessionContext`;
3. the session lock in `Session.IsSecureChannelValid`;
4. the session lock around all of `Session.ValidateRequest`, plus the
diagnostics lock twice (contact refresh, diagnostic counters);
5. the server-wide `SessionManager` event lock, to raise
`SessionDiagnosticsChanged` for every request even when nothing
subscribes.

With the FairRequestQueue lock also removed (a local change without the
queue-wide lock, not yet a PR), these were the next waiting points:
14–16.5 % of on-CPU samples spun in `Lock.TryEnterSlow` under
`TryGetSessionContext`, and once that was fixed another 7–10 % under
`Session.ValidateRequest`.

## Changes

**SessionManager: confirm the binding without the bindings lock**
- `SessionActivationState` publishes a `DispatchBinding` (the committed
binding while no activation is committing, otherwise `null`) with
`Volatile.Write`. The `BindingContext` and `IsCommitting` setters
republish it, and every writer of those already runs under
`m_bindingsLock`.
- The lookup reads it once for the candidate and once to confirm. A
commit always creates a new `SessionBindingContext`, so an unchanged
reference means nothing replaced it. A token is mapped to a session
object at most once and is never re-added after removal, so finding the
token still mapped to the same session afterwards means both held at the
instant of the confirming read, which is the guarantee the lock gave.
- IsCommitting, transfer, close/claim, shutdown and the
certificate/security checks are unchanged.

**Session: read the channel id without the session lock**
- `SecureChannelId` is published with a volatile write in `Activate`,
and `IsSecureChannelValid` reads it without the session lock.

**Session: request validation and diagnostic counters without locks**
- Request counts are interlocked increments of per-session slots (total
and error per `RequestType`, plus unauthorized). They are applied to
`SessionDiagnostics` under the diagnostics lock whenever it is read
(`ReadDiagnostics`, `UpdateDiagnostics`, the diagnostics-node callback,
`ClientLastContactTime`), so readers see the same totals.
- The contact refresh is a compare-and-swap on the tick count, and
requests within the same millisecond do not write. An expiry observed by
`HasExpired` or by a refresh is recorded in the contact word
(`kExpiredFlag`), so a request that read the clock before the monitor
saw the timeout cannot refresh it afterwards. This keeps the documented
"a request cannot revive a session the monitor saw expire" guarantee
without the lock. The intermediate commit that made `HasExpired`
lock-free on its own had opened that window; this commit closes it.
- `ValidateRequest` takes no session lock: the channel id and
`Activated` are published with volatile writes.
- `RaiseSessionEvent` returns before taking the event lock when a
`DiagnosticsChanged` event has no subscriber.

Behaviour differences reviewers should know about:
- `ClientLastContactTime` is millisecond-granular, and two concurrent
refreshes can publish their wall-clock times in reverse order, which
leaves it up to about 1 ms older than the latest contact.
- After an expiry has been observed, an `Activate` no longer refreshes
the contact time. `SessionManager` already rejects activation of an
expired session before that point.

## Tests
- `LookupDuringActivationCommitReturnsNoBindingAsync`: a lookup inside
the commit window (after `Session.Activate`, before the binding commit)
returns no binding. A test session re-implements `ISession.Activate` to
run the hook. Before this test, publishing the binding regardless of
`IsCommitting` passed all 690 session tests.
- `LookupRacingTransfersAndCloseReturnsOnlyCommittedBindingsAsync`: 4
readers look up while 1000 transfers alternate between two channels,
followed by a close. Each result must be a committed binding of the
queried channel, no older than the last completed activation and never
decreasing, and nothing may come back after the close.
- `RequestRacingObservedExpiryDoesNotReviveTheSessionAsync`: a clock
hook lets the monitor observe the expiry between a request's clock read
and its refresh, and the request must fail with `Bad_SessionClosed`. It
fails if `HasExpired` does not record the expiry.
- `ConcurrentRequestsAreCountedExactlyAsync`: 8 threads × 2100
validations (Read/Browse, plus rejected Writes). The totals, per-service
counts, error counts and unauthorized count must be exact, and a
concurrent reader never sees the total go backwards.
- Local runs (net10.0): `Opc.Ua.Server.Tests` 8487 passed / 0 failed;
`Opc.Ua.Sessions.Tests` 851 passed / 0 failed / 379 skipped. net48
builds.

## Measurements
`perf/ServerLoadHarness`, Server GC, paired alternating A/B, 7 rounds.
Baseline is master 8b360da + that FairRequestQueue change; candidate
adds the three commits.

| scenario | CPU µs/req | alloc B/req | lock contentions/req | req/s |
|---|---|---|---|---|
| read 1 node, 10 sessions × 100 in flight | 25.4 → 24.1 (−5 %) | 5456 →
5455 | −66 % | +1 % |
| read 1 node, 200 sessions | 42.9 → 43.7 (+2 %) | 5225 → 5212 | −60 % |
−5 % |

CPU and throughput differences are within this shared machine's
run-to-run noise (CPU ranges overlap on both sides). Allocation and
contention per request are the stable signals.

CPU trace (10 × 100), share of on-CPU samples spinning in
`Lock.TryEnterSlow`, 2 runs each:

| build | total | under `TryGetSessionContext` | under
`Session.ValidateRequest` |
|---|---|---|---|
| baseline | 20.8 / 23.7 % | 14.0 / 16.5 % | 0.8 / 0.9 % |
| + binding snapshot | 18.0 / 16.6 % | 12.5 / 11.3 % (inlined
`HasExpired`) | 0.9 / 0.7 % |
| + lock-free `HasExpired` / channel id | 9.5–13.5 % | 0 | 4.8–9.9 % |
| + lock-free validation and counters | 3.5 / 3.4 % | 0 | 0 |

The spin moves rather than disappearing: `Monitor.Enter_Slowpath`, from
the channel locks in `TcpServerChannel.HandleIncomingMessageAsync` /
`SendResponseAsync` and `ChannelGate`, rises from 37–40 % to about 47 %.
That is the next waiting point.

On master alone (FairRequestQueue lock still in place), CPU/req and
alloc/req changes are within noise.

## Related Issues

- Follow-up to the o6 benchmark gap work (#4742, #4743).

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
(Opc.Ua.Server.Tests and Opc.Ua.Sessions.Tests on net10.0; net48
compiles)
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready Ready to merge once CI Passes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants