Files
romanett 25ebf5a4d8 Server: remove the server-wide locks from the request pipeline, bounded transport buffer pool (#4742)
# 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` (f5062be34): 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
`ConcurrentQueue`s, 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 `ConcurrentDictionary`s;
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](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:39:27 +00:00

515 lines
21 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
// CA2000: test code; many disposables are ownership-transferred to test fixtures or short-lived,
// making CA2000 noisy without a real leak risk. Disabled file-level for the suite.
#pragma warning disable CA2000
using System;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using NUnit.Framework;
using Opc.Ua.Tests;
namespace Opc.Ua.Core.Tests.Stack.Server
{
/// <summary>
/// Verifies worker sizing, request completion and parked-request dispatch.
/// </summary>
[TestFixture]
[Category("Server")]
[SetCulture("en-us")]
[SetUICulture("en-us")]
[Parallelizable]
public class RequestQueueTests
{
private sealed class TestEndpointIncomingRequest : IEndpointIncomingRequest
{
public TaskCompletionSource<bool> ProcessingStarted { get; } = new TaskCompletionSource<bool>();
public TaskCompletionSource<bool> ProcessingCompleted { get; } = new TaskCompletionSource<bool>();
public StatusCode? CompletedStatusCode { get; private set; }
public IServiceRequest Request => null!;
public SecureChannelContext SecureChannelContext => null!;
public ValueTask CallAsync(CancellationToken cancellationToken = default)
{
ProcessingStarted.TrySetResult(true);
return new ValueTask(ProcessingCompleted.Task);
}
public void OperationCompleted(IServiceResponse? response, ServiceResult error)
{
CompletedStatusCode = error?.StatusCode;
ProcessingCompleted.TrySetResult(true);
}
}
/// <summary>
/// Records the token the queue passes to <see cref="CallAsync"/>, optionally declaring that
/// it links its scheduled token itself, as endpoint requests do.
/// </summary>
private class TokenRecordingRequest : IEndpointIncomingRequest
{
public TaskCompletionSource<CancellationToken> Called { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public TaskCompletionSource<bool> Release { get; } = new();
public IServiceRequest Request => null!;
public SecureChannelContext SecureChannelContext => null!;
public ValueTask CallAsync(CancellationToken cancellationToken = default)
{
Called.TrySetResult(cancellationToken);
return new ValueTask(Release.Task);
}
public void OperationCompleted(IServiceResponse? response, ServiceResult error)
{
Release.TrySetResult(true);
}
}
private sealed class SelfLinkingRequest(CancellationToken scheduled) :
TokenRecordingRequest, ILinksScheduledCancellation
{
public CancellationToken ScheduledCancellationToken { get; } = scheduled;
}
/// <summary>
/// The queue links the scheduled token into the handler's token unless the request links
/// it itself, so endpoint requests do not pay for a second linked token source.
/// </summary>
[TestCase(false)]
[TestCase(true)]
public async Task ScheduledTokenIsLinkedOnlyWhenTheRequestDoesNotLinkItAsync(bool selfLinking)
{
using var server = new TestServer();
server.ReplaceRequestQueue(1, 2, 10);
using var transport = new CancellationTokenSource();
TokenRecordingRequest request = selfLinking
? new SelfLinkingRequest(transport.Token)
: new TokenRecordingRequest();
server.ScheduleIncomingRequest(request, transport.Token);
CancellationToken passed = await request.Called.Task
.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
transport.Cancel();
Assert.That(passed.IsCancellationRequested, Is.EqualTo(!selfLinking));
Assert.That(passed.CanBeCanceled, Is.True, "the server's stop token is always passed");
request.Release.TrySetResult(true);
}
private sealed class ParkableTestRequest : IParkableIncomingRequest
{
public TaskCompletionSource<bool> ProcessingStarted { get; } = new TaskCompletionSource<bool>();
public TaskCompletionSource<bool> ProcessingCompleted { get; } = new TaskCompletionSource<bool>();
public StatusCode? CompletedStatusCode { get; private set; }
public IServiceRequest Request => null!;
public SecureChannelContext SecureChannelContext => null!;
RequestParkSink IParkableIncomingRequest.ParkSink => m_parkSink;
public ValueTask CallAsync(CancellationToken cancellationToken = default)
{
ProcessingStarted.TrySetResult(true);
// Simulate reaching the park point (queued waiting for a notification):
// the worker should be released even though processing has not completed.
m_parkSink.NotifyParked();
return new ValueTask(ProcessingCompleted.Task);
}
public void OperationCompleted(IServiceResponse? response, ServiceResult error)
{
CompletedStatusCode = error?.StatusCode;
ProcessingCompleted.TrySetResult(true);
}
private readonly RequestParkSink m_parkSink = new();
}
/// <summary>
/// Hosts configurable production request queues and controllable test handlers.
/// </summary>
private sealed class TestServer : ServerBase
{
public TestServer()
: base(NUnitTelemetryContext.Create(true))
{
}
/// <summary>
/// Gets the actual number of retained queue workers, including idle workers.
/// </summary>
public int WorkerCount
{
get
{
FieldInfo queueField = typeof(ServerBase).GetField(
"m_requestQueue", BindingFlags.NonPublic | BindingFlags.Instance)!;
var queue = (RequestQueue)queueField!.GetValue(this)!;
FieldInfo countField = typeof(RequestQueue).GetField(
"m_totalThreadCount", BindingFlags.NonPublic | BindingFlags.Instance)!;
return (int)countField!.GetValue(queue)!;
}
}
/// <summary>
/// Replaces the default queue with the requested worker and admission envelope.
/// </summary>
public void ReplaceRequestQueue(
int minThreads,
int maxThreads,
int maxQueue,
bool decoupleHeldPublishRequests = true,
IServerResourceIsolationProvider? provider = null)
{
FieldInfo field = typeof(ServerBase).GetField(
"m_requestQueue",
BindingFlags.NonPublic | BindingFlags.Instance)!;
var oldQueue = field!.GetValue(this) as IDisposable;
oldQueue?.Dispose();
var newQueue = new RequestQueue(
this, minThreads, maxThreads, maxQueue, decoupleHeldPublishRequests, provider, 10);
field.SetValue(this, newQueue);
}
/// <summary>
/// Fair scheduling does not reserve the maximum worker envelope before demand exists.
/// </summary>
[Test]
public void FairQueueStartsOnlyItsMinimumWorkers()
{
var provider = new Mock<IServerResourceIsolationProvider>();
provider.SetupGet(value => value.UseFairScheduling).Returns(true);
using var server = new TestServer();
server.ReplaceRequestQueue(2, 16, 100, provider: provider.Object);
Assert.That(server.WorkerCount, Is.EqualTo(2));
}
protected override Task ProcessRequestAsync(IEndpointIncomingRequest request, CancellationToken cancellationToken = default)
{
return request.CallAsync(cancellationToken).AsTask();
}
}
[Test]
public async Task TestRequestProcessingAsync()
{
using var server = new TestServer();
server.ReplaceRequestQueue(2, 4, 10);
var request1 = new TestEndpointIncomingRequest();
server.ScheduleIncomingRequest(request1);
using var cts = new CancellationTokenSource(5000);
Task<bool> t1 = request1.ProcessingStarted.Task;
if (await Task.WhenAny(t1, Task.Delay(5000, cts.Token)).ConfigureAwait(false) != t1)
{
Assert.Fail("Timed out waiting for processing to start.");
}
request1.ProcessingCompleted.SetResult(true);
Assert.That(request1.CompletedStatusCode, Is.Null, "Request should not have an error status.");
}
[Test]
public async Task TestTooManyRequestsAsync()
{
using var server = new TestServer();
server.ReplaceRequestQueue(1, 1, 1);
var req1 = new TestEndpointIncomingRequest();
var req2 = new TestEndpointIncomingRequest();
var req3 = new TestEndpointIncomingRequest();
server.ScheduleIncomingRequest(req1);
// Wait for req1 to start processing so it leaves the queue.
Task<bool> t1 = req1.ProcessingStarted.Task;
using (var cts1 = new CancellationTokenSource(5000))
{
if (await Task.WhenAny(t1, Task.Delay(5000, cts1.Token)).ConfigureAwait(false) != t1)
{
Assert.Fail("Timed out waiting for processing to start.");
}
}
// req1 is active (taking the 1 thread), queue is now empty. Capacity is 1.
server.ScheduleIncomingRequest(req2); // Goes to queue.
server.ScheduleIncomingRequest(req3); // Should fail to enter queue.
// req3 should be rejected synchronously or near-synchronously; wait briefly.
using (var cts3 = new CancellationTokenSource(5000))
{
await Task.WhenAny(req3.ProcessingCompleted.Task, Task.Delay(5000, cts3.Token))
.ConfigureAwait(false);
}
Assert.That(req3.CompletedStatusCode, Is.EqualTo(StatusCodes.BadServerTooBusy));
req1.ProcessingCompleted.TrySetResult(true);
Task<bool> t2 = req2.ProcessingStarted.Task;
using (var cts2 = new CancellationTokenSource(5000))
{
if (await Task.WhenAny(t2, Task.Delay(5000, cts2.Token)).ConfigureAwait(false) != t2)
{
Assert.Fail("Timed out waiting for req2 processing to start.");
}
}
req2.ProcessingCompleted.TrySetResult(true);
req3.ProcessingCompleted.TrySetResult(true);
}
[Test]
public async Task TestHighLoadAsync()
{
using var server = new TestServer();
server.ReplaceRequestQueue(4, 10, 1000);
const int count = 500;
var requests = new TestEndpointIncomingRequest[count];
for (int i = 0; i < count; i++)
{
requests[i] = new TestEndpointIncomingRequest();
// Automatically complete request when it starts to parallelize processing
TestEndpointIncomingRequest req = requests[i];
_ = req.ProcessingStarted.Task.ContinueWith(_ => req.ProcessingCompleted.TrySetResult(true), TaskScheduler.Default);
}
var tcsStart = new TaskCompletionSource<bool>();
var loadTask = Task.Run(async () =>
{
await tcsStart.Task.ConfigureAwait(false);
for (int i = 0; i < count; i++)
{
server.ScheduleIncomingRequest(requests[i]);
}
});
tcsStart.SetResult(true);
await loadTask.ConfigureAwait(false);
// Wait for all to finish
using var cts = new CancellationTokenSource(5000);
Task<bool[]> allFinished = Task.WhenAll(Array.ConvertAll(requests, r => r.ProcessingCompleted.Task));
if (await Task.WhenAny(allFinished, Task.Delay(5000, cts.Token)).ConfigureAwait(false) != allFinished)
{
Assert.Fail("Timed out waiting for all requests to finish.");
}
for (int i = 0; i < count; i++)
{
Assert.That(requests[i].CompletedStatusCode, Is.Not.EqualTo(StatusCodes.BadServerTooBusy));
Assert.That(requests[i].CompletedStatusCode, Is.Not.EqualTo(StatusCodes.BadServerHalted));
}
}
[Test]
public async Task TestDisposeAsync()
{
using var server = new TestServer();
server.ReplaceRequestQueue(1, 1, 10);
var req1 = new TestEndpointIncomingRequest();
var req2 = new TestEndpointIncomingRequest();
server.ScheduleIncomingRequest(req1);
// Wait for req1 to start processing so it leaves the queue.
Task<bool> t1 = req1.ProcessingStarted.Task;
using (var cts1 = new CancellationTokenSource(5000))
{
if (await Task.WhenAny(t1, Task.Delay(5000, cts1.Token)).ConfigureAwait(false) != t1)
{
Assert.Fail("Timed out waiting for processing to start.");
}
}
// req1 is active (taking the 1 thread)
// queue is now empty. Capacity is 10.
server.ScheduleIncomingRequest(req2); // Goes to queue.
// Request queue has req2 pending.
// Dispose the server, which disposes the queue.
var disposeTask = Task.Run(server.Dispose);
// req2 should be failed with BadServerHalted.
Task<bool> t2 = req2.ProcessingCompleted.Task;
using (var cts2 = new CancellationTokenSource(5000))
{
if (await Task.WhenAny(t2, Task.Delay(5000, cts2.Token)).ConfigureAwait(false) != t2)
{
Assert.Fail("Timed out waiting for req2 processing to be completed via Dispose.");
}
}
Assert.That(req2.CompletedStatusCode, Is.EqualTo(StatusCodes.BadServerHalted));
// Scheduling a new request should immediately fail with BadServerHalted
var req3 = new TestEndpointIncomingRequest();
server.ScheduleIncomingRequest(req3);
// req3 should be rejected synchronously or near-synchronously; wait briefly.
using (var cts3 = new CancellationTokenSource(5000))
{
await Task.WhenAny(req3.ProcessingCompleted.Task, Task.Delay(5000, cts3.Token))
.ConfigureAwait(false);
}
Assert.That(req3.CompletedStatusCode, Is.EqualTo(StatusCodes.BadServerHalted));
req1.ProcessingCompleted.TrySetResult(true);
await disposeTask.ConfigureAwait(false);
}
[Test]
public async Task ParkedRequestReleasesWorkerForNextRequestAsync()
{
using var server = new TestServer();
// A single worker: only the park decoupling can let a second request be
// serviced while the first is still parked waiting for a notification.
server.ReplaceRequestQueue(1, 1, 10, decoupleHeldPublishRequests: true);
var reqA = new ParkableTestRequest();
var reqB = new ParkableTestRequest();
server.ScheduleIncomingRequest(reqA);
await AwaitOrFailAsync(reqA.ProcessingStarted.Task, "request A to start").ConfigureAwait(false);
server.ScheduleIncomingRequest(reqB);
await AwaitOrFailAsync(reqB.ProcessingStarted.Task, "request B to start while A is parked")
.ConfigureAwait(false);
Assert.That(
reqA.ProcessingCompleted.Task.IsCompleted,
Is.False,
"Request A should still be parked while B is serviced.");
reqA.ProcessingCompleted.TrySetResult(true);
reqB.ProcessingCompleted.TrySetResult(true);
}
[Test]
public async Task DisabledDecouplingKeepsWorkerBlockedUntilCompletionAsync()
{
using var server = new TestServer();
// Decoupling disabled: the single worker stays bound to the parked request,
// so a second request cannot start until the first completes.
server.ReplaceRequestQueue(1, 1, 10, decoupleHeldPublishRequests: false);
var reqA = new ParkableTestRequest();
var reqB = new ParkableTestRequest();
server.ScheduleIncomingRequest(reqA);
await AwaitOrFailAsync(reqA.ProcessingStarted.Task, "request A to start").ConfigureAwait(false);
server.ScheduleIncomingRequest(reqB);
bool bStarted = await CompletedWithinAsync(reqB.ProcessingStarted.Task, 500).ConfigureAwait(false);
Assert.That(
bStarted,
Is.False,
"Request B must not start while the only worker is blocked on parked A.");
// Completing A frees the worker to service B.
reqA.ProcessingCompleted.TrySetResult(true);
await AwaitOrFailAsync(reqB.ProcessingStarted.Task, "request B to start after A completes")
.ConfigureAwait(false);
reqB.ProcessingCompleted.TrySetResult(true);
}
[Test]
public async Task ManyParkedRequestsAreServedBySmallWorkerPoolAsync()
{
using var server = new TestServer();
// Two workers must be able to service far more concurrently-parked requests
// than the worker count, because a parked request does not hold a worker.
server.ReplaceRequestQueue(2, 2, 1000, decoupleHeldPublishRequests: true);
const int count = 200;
var requests = new ParkableTestRequest[count];
for (int i = 0; i < count; i++)
{
requests[i] = new ParkableTestRequest();
server.ScheduleIncomingRequest(requests[i]);
}
Task allStarted = Task.WhenAll(Array.ConvertAll(requests, r => r.ProcessingStarted.Task));
await AwaitOrFailAsync(allStarted, "all parked requests to start", 15000).ConfigureAwait(false);
// None of them have completed - they are all still parked - yet all were
// admitted and started by only two workers.
foreach (ParkableTestRequest request in requests)
{
Assert.That(
request.CompletedStatusCode,
Is.Null,
"A parked request should not have been faulted.");
request.ProcessingCompleted.TrySetResult(true);
}
}
private static async Task AwaitOrFailAsync(Task task, string what, int timeoutMs = 5000)
{
using var cts = new CancellationTokenSource();
var delay = Task.Delay(timeoutMs, cts.Token);
if (await Task.WhenAny(task, delay).ConfigureAwait(false) != task)
{
Assert.Fail($"Timed out waiting for {what}.");
}
cts.Cancel();
await task.ConfigureAwait(false);
}
private static async Task<bool> CompletedWithinAsync(Task task, int timeoutMs)
{
using var cts = new CancellationTokenSource();
var delay = Task.Delay(timeoutMs, cts.Token);
Task completed = await Task.WhenAny(task, delay).ConfigureAwait(false);
cts.Cancel();
return completed == task;
}
}
}