Skip to content

Server: remove the server-wide locks from the request pipeline, bounded transport buffer pool - #4742

Merged
marcschier merged 7 commits into
masterfrom
romanett/server-pipeline-locks-buffer-pool
Oct 12, 2026
Merged

marcschier merged 7 commits into
masterfrom
romanett/server-pipeline-locks-buffer-pool

Conversation

@romanett

@romanett romanett commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Server: remove the server-wide locks from the request pipeline, bounded transport buffer pool

Quick wins #5 and #6 of the o6 benchmark gap analysis. Under 10 channels × 100 outstanding single-value Reads on 24 cores, about 59 % of the server's on-CPU time spun in Monitor/Lock slow paths, and roughly 20 of the ~30 lock acquisitions per Read were on server-wide locks in the request pipeline. Separately, on hosts with one or two processors every response allocated a fresh 64 KiB chunk, because ArrayPool<byte>.Shared runs out with about a thousand responses in flight.

No wire or configuration changes. The only public API change is an additive RequestLifetime constructor. The admission sizing (MaxQueuedRequestCount → shared capacity and reserved floors) is untouched; this PR only changes how the accounting is locked.

Results

Paired A/B against origin/master (f5062be): the two harness builds alternate per round on the same machine, the server runs Server GC, and the table shows medians. o6read uses the o6-automation benchmark's native open62541 client workers (10 clients × 100 in flight); the other rows use the harness's .NET client with 20 sessions.

Scenario Rounds req/s Server CPU µs/req Alloc B/req p99
o6read 10×100, 24 cores 4 199,800 → 284,800 (+43 %) 65.7 → 44.5 (−32 %) 7,732 → 7,833 –
o6read 10×100, 4-core mask 6 105,300 → 102,600 (−3 %, ranges overlap) 37.8 → 38.8 (noise) 7,824 → 7,636 –
o6read 10×100, 1 core (DOTNET_PROCESSOR_COUNT=1) 3 19,750 → 41,070 (+108 %) 50.1 → 24.2 (−52 %) 55,345 → 7,428 –
read --nodes 1 --inflight 50, 24 cores 4 250,200 → 355,100 (+42 %) 50.3 → 31.0 (−38 %) 6,551 → 6,448 8.8 → 10.3 ms
read --nodes 1 --inflight 50, 1 core 3 45,300 → 89,550 (+98 %) 224 → 130 (−42 %) 6,985 → 6,748 29.3 → 19.6 ms
read --nodes 100, 24 cores 4 47,940 → 48,260 (+1 %) 336 → 324 (−4 %) 90,520 → 90,470 1.42 → 1.42 ms
browse, 24 cores 4/3 53,110 → 51,840 (−2 %) 214 → 218 (+2 %) 44,450 → 44,280 1.92 → 1.58 ms

Contention (--trace-profile contention, o6read 10×100, 24 cores, one run per side): the isolation provider, FairRequestQueue, the request queue's worker gate and RequestManager went from 26 % of all contention events (~42k in the window) to 1.2 % (~2k), at 40 % higher throughput. What remains is CustomNodeManager2's lock and the dispatcher's permission path (separate roadmap items). In the sampled CPU profile, Lock.TryEnterSlow fell from 43 % to 2.7 % of on-CPU time.

The 4-core rows are within noise: with four processors these locks were not the bottleneck. The read --inflight 50 p99 at 24 cores rose with 42 % more requests in flight per second; at 1 core it fell by a third.

Changes

Bounded transport buffer pool (#6)

  • BoundedArrayPool (internal): power-of-two buckets in lock-free ConcurrentQueues, at most 1024 arrays and at most 64 MiB in the largest bucket (1024 × 64 KiB for the default MaxBufferSize), retained memory below 2 × 64 MiB. Arrays are retained only once returned, so a pool grows to the peak concurrency it has seen.
  • One instance per maximum array length is shared by every client and server buffer manager of the process, so client channels do not each keep their own arrays.
  • ArrayPoolBufferManagerBase uses it instead of ArrayPool<byte>.Shared for buffer sizes up to 1 MiB (larger sizes keep their configurable pool). GetExpectedBufferSize no longer doubles for the next bucket on .NET Framework, since the bounded pool allocates the exact bucket when it is empty.

Isolation provider (#5)

  • No provider-wide lock on TryAcquire/release. Stage counters, owner usage and protected floors are reserved with bounded compare-and-swap loops that never pass a limit; counters already reserved are returned if a later one is full.
  • The owner table is a ConcurrentDictionary. An owner that already holds a lease of the same accounting class is admitted lock-free. A class count crosses zero only under a table lock, which keeps the exclusive class, the protected table slots and entry removal consistent with the leases.

FairRequestQueue (#5)

  • The selected head is unlinked when it is selected, so an admitted dispatch takes the gate once instead of twice.
  • In weighted mode several workers select at once, instead of one selector while the others go back to wait for a wake signal.
  • A head that finds execution capacity exhausted is relinked in admission order, and its owner is not retried before capacity changes (as before).
  • Global FIFO mode keeps one selection at a time, so its blocked head still cannot be overtaken.

ServerBase.RequestQueue (#5)

  • Fair mode no longer takes the worker gate in ScheduleIncomingRequest.
  • GrowFairWorkers pre-checks the counters and takes the gate only to add a worker.
  • Running handlers are counted with Interlocked; shutdown's drain is created after the workers exit and completed by the last handler.

One cancellation source per request

  • New public constructor RequestLifetime(TimeSpan timeout, CancellationToken first, CancellationToken second). It links only the tokens that can be cancelled (a duplicate token once) and applies the timeout with CancelAfter on the same source. EndpointIncomingRequest.CallAsync uses it instead of a CancellationToken[], a separate TimeoutHint source and a linked source over three tokens. The lifetime drops from 528 B to 240 B, and a timeout still reports Bad_Timeout.
  • ServerBase.RequestQueue no longer links the transport token again for requests that link it themselves (internal ILinksScheduledCancellation, implemented by EndpointIncomingRequest). Other IEndpointIncomingRequest implementations keep the linked token.
  • Paired against the lock changes alone (4 rounds, 24 cores): allocation −4 % (o6read 7,823 → 7,539 B, read --nodes 1 --inflight 50 6,446 → 6,164 B); CPU and throughput unchanged within noise.

RequestManager (#5)

  • Executing requests and validation scopes are ConcurrentDictionarys; registration and completion take no lock.
  • Drains publish a count before they snapshot, and a completion takes the lock only when a drain is registered.
  • The request timer is stopped only if no deadline was registered meanwhile.
  • The validation scope's AsyncLocal was write-only and is removed. The request scope skips the ambient id when the dispatch scope already carries it: 4 fewer ExecutionContext allocations per request.

Behaviour notes

  • Admission races: two acquisitions racing for the last units of a stage may both be rejected where a serialized order would admit one. No limit is ever exceeded, and the usage gauge never reads above capacity.
  • Weighted-mode ordering: concurrent selections of one owner may dispatch in a different order when one of them fails execution admission while the other succeeds. Blocked requests are relinked in admission order. Global FIFO mode is unchanged.
  • Cancellation and stop: a request cancelled, or a queue stopped, while the request's admission runs is completed exactly once by the selecting worker (Bad_RequestCancelledByClient / Bad_ServerHalted), as before.
  • Session close and Cancel: registration ends with a full fence. A Cancel records its pending entry before it scans the registered requests, so a request registering concurrently is either swept or sees the mark (the same guarantee the shared lock gave).
  • ProcessRequestAsync overrides: a ServerBase.ProcessRequestAsync override now receives the server's stop token for endpoint requests instead of a token that is also linked to the transport. The request lifetime that services observe is still cancelled by the transport, the client and the timeout hint.
  • Memory: the transport pool can retain up to 64 MiB per bucket size after a peak, where ArrayPool<byte>.Shared would have trimmed it under memory pressure. There is no trimming yet.

Testing

  • New tests:

    • BoundedArrayPoolTests: bucket sizing, reuse, bound under concurrent returns, a thousand outstanding buffers, a deep pipeline through FastBufferManager without allocation.
    • ConcurrentLeasesStayWithinLimitsAndAreAllReturnedAsync: 8 threads × 20k operations with ordinary, bootstrap and reconnect owners and a 4-entry owner table. A mutation that lets the capacity check overshoot by one fails it with 6,700 violations.
    • FairRequestQueueTests: concurrent selection in weighted mode and not in FIFO mode; relink in admission order with two threads (fails if relinked at the head); stop during grant.
    • DrainsRacingLockFreeRegistrationAndCompletionAlwaysCompleteAsync: 200 drains against four threads registering and completing requests and validation scopes. It times out if completions skip drains.
  • Suites (net10.0): Opc.Ua.Core.Tests Stack.*, Opc.Ua.Server.Tests, Opc.Ua.Sessions.Tests, Redundancy Server/Client/Samples, Stress Contract + Integration:

    Suite Passed Failed Skipped
    Opc.Ua.Core.Tests Stack.* 3604 0 80
    Opc.Ua.Server.Tests 8447 0 5
    Opc.Ua.Sessions.Tests 852 0 379
    Opc.Ua.Redundancy.Server.Tests 884 0 0
    Opc.Ua.Redundancy.Client.Tests 134 0 0
    Opc.Ua.Redundancy.Samples.Tests 17 0 0
    Opc.Ua.Stress.Tests Contract|Integration (Release) 45 0 0
  • dotnet format whitespace --verify-no-changes on the changed files; net48 build.

Not covered: CustomNodeManager2's lock and the channel gate (separate roadmap items); admission sizing is in a parallel PR.

Remaining per-request cancellation cost: the request lifetime links the server's stop token and the transport token in one source (240 B), so that shutdown and a closed channel still cancel running requests.

🤖 Generated with Claude Code

Copilot AI balanced review requested due to automatic review settings October 12, 2026 06:45
romanett and others added 5 commits October 12, 2026 08:45
ArrayPool<byte>.Shared keeps a few arrays per bucket and processor. A server
with about a thousand responses waiting to be sent exhausts it on a host with
one or two processors (or a container limited to them), and then allocates a
new 64 KiB chunk for every response: 54 KB per Read instead of 7.6 KB with
10 channels x 100 outstanding requests at DOTNET_PROCESSOR_COUNT=1.

Buffer managers up to 1 MiB now rent from a BoundedArrayPool: power-of-two
buckets in lock-free queues, up to 1024 arrays (64 MiB) in the largest bucket,
one instance per maximum array length shared by all client and server
managers of the process. Retained memory stays below 2 x 64 MiB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every request takes three leases (RequestQueue, RequestQueueBytes,
RequestExecution) and returns them, and each of the six calls took the
provider's single lock: about 18 % of the server's on-CPU time spun on it
with 10 channels x 100 outstanding Reads on 24 cores.

Stage counters, owner usage and the protected floors are now reserved with
bounded compare-and-swap loops that never pass a limit, and the counters
already reserved are returned when a later one is full. The owner table is
a ConcurrentDictionary. An owner that already holds a lease of the same
accounting class is admitted without any lock; a class count of an owner
crosses zero only under a table lock, which keeps the exclusive class, the
protected table slots and entry removal consistent with the leases. Lease
release takes the table lock only for the owner's last lease of a class.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FairRequestQueue took its server-wide gate three times per request (enqueue,
select, finish) and let only one worker select at a time: the others went
back to wait for a wake signal while the selector ran the revalidation and
the execution admission outside the gate.

The selected head is now unlinked when it is selected, so an admitted
dispatch needs no second gate entry, and in weighted mode several workers
select at once. A head that finds execution capacity exhausted is relinked
in admission order (concurrent selections of one owner may fail in any
order) and its owner is not retried before capacity changes, as before.
A request cancelled or a queue stopped while its admission runs is
completed once by the selecting worker. Global FIFO mode still selects one
request at a time, so its blocked head cannot be overtaken.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ServerBase.RequestQueue took its server-wide worker gate three to five times
per request: in ScheduleIncomingRequest even in fair mode, where the locked
block does nothing, in GrowFairWorkers at enqueue and at worker start, and
twice in ProcessRequestSafeAsync to count running handlers.

Fair mode now reads the stop flag without the gate (the fair queue rejects
admissions itself once stopped). GrowFairWorkers checks the counters before
it takes the gate, which it then needs only when a worker is to be added.
Running handlers are counted with Interlocked; shutdown creates the drain
completion after the workers have exited and the last handler completes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RequestManager took its server-wide lock four times per request (validation
scope enter and exit, registration, completion) and wrote three AsyncLocal
scopes, each set and restored.

Executing requests and validation scopes are ConcurrentDictionaries, so
registration and completion take no lock. A drain publishes a count before
it snapshots them, and a completion takes the lock only when a drain is
registered, so every completion a drain waits for still reaches it. The
request timer is stopped only when no deadline was registered meanwhile.
A Cancel remembers itself before it scans the registered requests, and
registration ends with a full fence, so a request registering concurrently
with a Cancel or a Session close sweep is either seen or sees the mark.

The validation scope's AsyncLocal was never read and is gone; the request
scope skips the ambient id when the dispatch scope already carries it.
Enumeration, drains, timeouts and Cancel still run under the lock.

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

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

The buffer-pool tests contain a nondeterministic allocation assertion and a contradictory memory-bound case.

2 open findings
What changed in this PR

Removes server-wide request-pipeline contention and introduces bounded, process-wide transport buffer pooling.

Changes:

  • Reworks request admission, dispatch, draining, and shutdown accounting with concurrent collections and atomic operations.
  • Adds a bounded transport buffer pool with updated sizing behavior.
  • Adds concurrency, ordering, shutdown, and buffer-reuse tests.
File Description
src/​Opc.Ua.Core/​Stack/​Bindings/​BoundedArrayPool.cs Implements bounded process-wide pooling.
src/​Opc.Ua.Core/​Stack/​Bindings/​ArrayPoolBufferManagerBase.cs Selects the bounded pool and updates sizing.
src/​Opc.Ua.Core/​Stack/​Server/​FairRequestQueue.cs Enables concurrent weighted selection and ordered relinking.
src/​Opc.Ua.Core/​Stack/​Server/​ServerBase.RequestQueue.cs Reduces worker-gate locking and revises shutdown draining.
src/​Opc.Ua.Server/​ResourceIsolation/​DefaultServerResourceIsolationProvider.cs Replaces global admission locking with atomic accounting.
src/​Opc.Ua.Server/​Server/​RequestManager.cs Makes registration and completion lock-free.
src/​Opc.Ua.Server/​Server/​StandardServer.cs Updates request-registration synchronization documentation.
tests/​Opc.Ua.Core.Tests/​Stack/​Server/​FairRequestQueueTests.cs Tests concurrent selection, ordering, and stopping.
tests/​Opc.Ua.Core.Tests/​Types/​Bindings/​ArrayPoolBufferManagerBaseCoverageTests.cs Updates pool-selection and sizing coverage.
tests/​Opc.Ua.Core.Tests/​Types/​Bindings/​BoundedArrayPoolTests.cs Tests bounded pooling and reuse.
tests/​Opc.Ua.Server.Tests/​RequestManagerTests.cs Stress-tests concurrent drains.
tests/​Opc.Ua.Server.Tests/​ResourceIsolation/​RuntimeResourceIsolationTests.cs Stress-tests concurrent admission limits.

🧠 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 tests/Opc.Ua.Core.Tests/Types/Bindings/BoundedArrayPoolTests.cs
Comment thread tests/Opc.Ua.Core.Tests/Types/Bindings/BoundedArrayPoolTests.cs Outdated
@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% (311045/351096 lines) >= 70.00%
✅ Project branch rate 79.01% >= 60.00%
✅ Patch coverage 96.22% (331/344 changed lines) >= 75.00% (> 100 changed lines)
ℹ️ Baseline delta (advisory) +14.99 pp 73.60% recorded
Uncovered changed lines
  • src/Opc.Ua.Core/Stack/Server/FairRequestQueue.cs: 308, 313, 322, 323
  • src/Opc.Ua.Server/ResourceIsolation/DefaultServerResourceIsolationProvider.cs: 378, 380, 381, 382, 599
  • src/Opc.Ua.Core/Stack/Server/ServerBase.RequestQueue.cs: 186, 187
  • src/Opc.Ua.Server/Server/RequestManager.cs: 165, 166

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%.

@codecov

codecov Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.47674% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.80%. Comparing base (0716603) to head (77523e6).
⚠️ Report is 2 commits behind head on master.

Files with missing lines Patch % Lines
src/Opc.Ua.Core/Stack/Server/FairRequestQueue.cs 89.70% 4 Missing and 3 partials ⚠️
...solation/DefaultServerResourceIsolationProvider.cs 94.33% 5 Missing and 1 partial ⚠️
...pc.Ua.Core/Stack/Server/ServerBase.RequestQueue.cs 88.46% 2 Missing and 1 partial ⚠️
src/Opc.Ua.Server/Server/RequestManager.cs 95.38% 2 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #4742      +/-   ##
==========================================
- Coverage   82.86%   82.80%   -0.06%     
==========================================
  Files        2268     2300      +32     
  Lines      346319   351096    +4777     
  Branches    60386    61265     +879     
==========================================
+ Hits       286985   290738    +3753     
- Misses      39233    39881     +648     
- Partials    20101    20477     +376     
Flag Coverage Δ
actions 82.80% <94.47%> (-0.06%) ⬇️

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

Files with missing lines Coverage Δ
....Core/Stack/Bindings/ArrayPoolBufferManagerBase.cs 98.97% <100.00%> (+0.95%) ⬆️
src/Opc.Ua.Core/Stack/Bindings/BoundedArrayPool.cs 100.00% <100.00%> (ø)
...ack/Server/EndpointBase.EndpointIncomingRequest.cs 85.07% <100.00%> (-0.22%) ⬇️
src/Opc.Ua.Server/Server/StandardServer.cs 85.03% <ø> (ø)
src/Opc.Ua.Types/BuiltIn/RequestLifetime.cs 93.75% <100.00%> (+2.84%) ⬆️
...pc.Ua.Core/Stack/Server/ServerBase.RequestQueue.cs 87.43% <88.46%> (+0.52%) ⬆️
src/Opc.Ua.Server/Server/RequestManager.cs 92.30% <95.38%> (+4.91%) ⬆️
...solation/DefaultServerResourceIsolationProvider.cs 88.49% <94.33%> (+0.61%) ⬆️
src/Opc.Ua.Core/Stack/Server/FairRequestQueue.cs 92.04% <89.70%> (-2.34%) ⬇️

... and 95 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 and others added 2 commits October 12, 2026 09:53
…en once

Per request, EndpointIncomingRequest.CallAsync built a CancellationToken[]
and a separate timeout CancellationTokenSource for the TimeoutHint, and
linked both with the caller's and the transport's token (528 bytes for the
lifetime alone). Before that, the request queue had already linked the
worker's stop token with the same transport token.

The new RequestLifetime(TimeSpan timeout, CancellationToken first,
CancellationToken second) constructor links the two tokens (only those that
can be cancelled, a duplicate once) and applies the timeout with CancelAfter
on the same source: 240 bytes. A timeout still leaves the status Good, which
CallAsync reports as Bad_Timeout.

Requests that link their scheduled token themselves say so through the
internal ILinksScheduledCancellation, and the queue then passes the stop
token alone instead of a second linked source. Other IEndpointIncomingRequest
implementations keep the linked token. A ServerBase.ProcessRequestAsync
override now sees the server's stop token for endpoint requests; the request
lifetime the services observe is still cancelled by the transport, the
client and the timeout hint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review feedback: the default pool kept at least 16 arrays, so a pool for
arrays above 4 MiB could retain more than the documented 64 MiB per bucket.
The floor is now one array. The transport only uses the pool up to 1 MiB,
where the bound was already 64 arrays, so nothing changes for it.

The deep-pipeline test measured allocations through the process-wide pool,
which parallel tests share; it now uses a private pool with the default
configuration and checks that every buffer is reused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@marcschier
marcschier enabled auto-merge (squash) October 12, 2026 08:20
@marcschier
marcschier merged commit 25ebf5a into master Oct 12, 2026
110 checks passed
@marcschier
marcschier deleted the romanett/server-pipeline-locks-buffer-pool branch October 12, 2026 09:14
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>
romanett added a commit that referenced this pull request Oct 14, 2026
… request path (−16 % allocation per Read) (#4767)

## Description

Five small changes to the per-request server path, found in the CPU and
allocation profiles of the o6 read benchmark (`AsyncCustomNodeManager`,
Server GC, 10 clients × 100 Reads in flight) on master 3a31473. Each
change is its own commit and can be reviewed and reverted on its own.
The channel gate (about 40 % of waits) is not touched; that is A1.

### Results

Paired A/B runs against master (0e40e12), alternating builds per
round, 5 rounds, medians (min..max). The machine is shared with other
sessions: allocated bytes per request are deterministic; CPU per request
is best read from the runs pinned to 4 cores.

| Scenario | master | this PR | Change |
|---|---|---|---|
| o6read 10 × 100, server pinned to 4 cores: CPU / request | 39.1 µs
(35.9..39.8) | 36.9 µs (35.5..37.6) | **−5.6 %** |
| same: allocated / request | 6,198 B | 5,224 B | **−15.7 %** |
| same: req/s | 98.7k (95.4..108.5k) | 104.6k (97.6..105.9k) | +5.9 % |
| o6read 10 × 100, 24 cores: req/s | 183.7k (173.3..198.0k) | 189.8k
(178.9..197.5k) | +3.4 % |
| same: allocated / request | 6,500 B | 5,578 B | −14.2 % |
| o6read 10 × 100 Basic256Sha256, 24 cores: req/s | 159.8k
(149.4..178.7k) | 164.2k (156.7..165.3k) | +2.7 % |
| same: allocated / request | 6,590 B | 5,694 B | −13.6 % |
| `read --nodes 1`, 20 sessions: req/s | 155.3k (141.6..157.6k) | 163.8k
(163.4..166.5k) | +5.5 % |
| same: allocated / request | 5,548 B | 4,689 B | −15.5 % |
| `read --nodes 100`: allocated / request | 74.4 KB | 70.1 KB | −5.8 % |
| `browse`: allocated / request | 39.8 KB | 35.0 KB | −12.2 % |
| `browse`: lock contentions / 1000 requests | 474 | 367 | −23 % |

At 24 cores CPU per request stays within noise, because the time these
changes free now waits at the channel gate (see below).

Per item, pinned to 4 cores (5 rounds; CPU deltas of one item are within
run-to-run noise, allocation deltas are not):

| Item | CPU / request | Allocated / request |
|---|---|---|
| Q1 system context | +1.8 % | −1.7 % |
| Q2 request table | −4.3 % | −1.4 % |
| Q3 request timeouts | −2.8 % | −2.2 % |
| Q4 namespace metadata | −3.5 % | ±0 |
| Q5 read-path allocations | −2.4 % | −10.9 % |

Sampled thread time at 24 cores (cpu trace, first `Opc.Ua` frame above
the wait or copy):

| Frame | master | this PR |
|---|---|---|
| `ServerSystemContext.Copy` (incl. `MemberwiseClone`) | 12.4 % | 0.1 %
|
| `RequestManager` (`ConcurrentDictionary` inside
`EnterValidationScope`, scope `Dispose`, `RequestReceived`,
`CompleteRequest`) | 7.1 % | 0 |
| `RequestLifetime` (CTS registration spin,
ctor/`RegisterExternal`/`DisposeResources`) | 4.8 % | 0.2 % |
| `NamespaceMetadataRegistry.GetAsync` lock | 4.0 % | 0 |
| `RuntimeHelpers.InitializeArray` (`ReadValidationAttributes`) | 2.2 %
| 0 |
| Channel gate (`ChannelGate+AwaitEntryAsync`, inclusive) | 31 % | 46 %
|

## Changes

**Q1: one system context copy per request and node manager.**
`AsyncCustomNodeManager`'s service methods (Read, Write, Browse,
TranslateBrowsePaths, Call, HistoryRead/Update, monitored item and event
services) use the per-request copy from
`OperationContext.GetSystemContext` instead of copying the node
manager's context again. The per-request cache keeps the first node
manager's copy without an extra object and up to eight more, so a
request that touches several node managers no longer evicts the other
one's copy. `ServerSystemContext` copies itself with a copy constructor
instead of `MemberwiseClone` (which allocates through a runtime call)
unless it is a derived type; `SessionSystemContext` gains a protected
copy constructor. Copies per Read went from about three to one.

**Q2: lock-free request registration in `RequestManager`.** #4742 took
the server-wide lock off the request path, but the two
`ConcurrentDictionary`s for executing requests and open validation
scopes stayed the main waiting point after the channel gate (four writes
per request from every worker). `RequestTable<T>` places an entry with a
compare-and-swap into a free slot near a scrambled hash of its key, and
the entry remembers its slot so completion is one more compare-and-swap;
only when every probed slot is taken does an entry go to an overflow set
under a lock. Drains, Cancel, Session close, the timeout sweep and
`Dispose` read the slots one after the other, which keeps the existing
ordering arguments (registration fence vs. Session/Cancel marks, drain
count published before the snapshot). Sharding the dictionaries over
per-processor locks was tried first and only moved about 5 % of waits
into the shard locks.

**Q3: no timer or shared-token registrations per request.**
`RequestLifetime`'s timeout constructor, used by the endpoint for every
request, no longer creates a `Timer` and no longer registers with the
server's request-queue token and the transport token, which every worker
registered with and so contended on. The deadline and the two tokens are
watched by `RequestDeadlineMonitor`: one sweep every 20 ms over
intrusive lists sharded by processor, running only while requests are
monitored. The `params` constructor keeps its synchronous registrations.

**Q4: namespace metadata lookups without a lock.**
`NamespaceMetadataRegistry` reads its caches (found and missing, by URI
and now also by index) without its lock; only filling and invalidating
take it. The generation moves before the caches are cleared, so a lookup
racing an invalidation never reports a stale miss.

**Q5: fewer per-call allocations on the read path.**
`ReadValidationAttributes` fills its attribute-id array element by
element instead of with an array initializer
(`RuntimeHelpers.InitializeArray` per call); single-node Read and Write
requests skip the per-request attribute cache and validate with
`permissionsOnly` (the same three attributes, read once); `ReadAsync`
allocates the list of nodes to validate only when needed. (Review: a
shared static attribute array, a per-thread `Variant[]` scratch array
and a per-thread Read node cache were dropped again because they crossed
virtual calls; the measurements below were taken with them, so Q5's
allocation saving is a little smaller than shown.)

## Behaviour notes

- **Request timeouts (Q3):** a `TimeoutHint` now fires up to 20 ms late
(the sweep period), and cancelling the server's or the transport's token
reaches the request's `CancellationToken` up to 20 ms later instead of
synchronously. A token that is already cancelled cancels the request at
once, and `TryCancel` checks the tokens first, so a token cancelled
before an explicit Cancel still owns the terminal state. The
exactly-once terminal state of #4744 is unchanged: the sweep claims it
through the same compare-and-swap.
- **System contexts (Q1):** a custom node manager that changed fields of
the context it got in a service call now changes the copy shared by that
request's other calls into the same node manager; it never reaches other
requests or the node manager's own `SystemContext`.
- **Request ids (Q2):** `RequestManager.RequestReceived` no longer
throws for a different `OperationContext` with a request id that is
still registered. Requests are tracked by their context; ids come from a
server-wide counter.

## Testing

- New: `ServerSystemContextCopyTests` (every field copied, derived types
kept), `AsyncCustomNodeManagerTests` (one copy per node manager and
request, two managers in one request, not shared across requests;
single- vs multi-node Read verdicts identical incl. a node read twice),
`RequestTableTests` (idempotent add, exactly-once remove, overflow,
concurrent churn while enumerating; drain racing completion on many
threads; Session close and Cancel by handle exactly once while others
register), `RequestDeadlineMonitorTests` (timeout precision with a fake
clock, token propagation, ownership vs. explicit Cancel, no leaked
entries, sweep/cancel/complete race), `NamespaceMetadataRegistryTests`
(cached miss by index invalidated, lookups racing `Invalidate`,
permission changes visible through the cached node).
- Run locally (net10.0): Opc.Ua.Types.Tests, Opc.Ua.Core.Tests (Stack),
Opc.Ua.Server.Tests, Opc.Ua.Sessions.Tests, Opc.Ua.Subscriptions.Tests,
Opc.Ua.Redundancy.Server.Tests, Opc.Ua.Stress.Tests Contract +
Integration. net48 builds.
- Measured with `perf/ServerLoadHarness` scenarios `o6read` (o6 harness,
not part of this PR), `read`, `browse`.

🤖 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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants