Server: size isolation execution capacity from the worker limit

- CreateRuntimePlan sizes RequestExecution from MaxRequestThreadCount
  instead of at least 100, so ordinary requests cannot occupy every
  worker past the reserved floors; too few workers fail at startup.
- Count parked requests explicitly for the parked gauge.
- Forward --isolation to the native benchmark server child.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Roman Ettlinger
2026-10-28 06:30:04 +01:00
co-authored by Claude Opus 5.5
parent f80e7e0634
commit fc77ad7bea
5 changed files with 78 additions and 4 deletions
+3 -1
View File
@@ -593,7 +593,9 @@ bootstrap, reconnect, control and trusted floors, so the shared capacity equals
the configured value. For example, with `MaxQueuedRequestCount = 10000`, ten
clients can each keep 1,000 requests outstanding. Balanced then sizes the
RequestQueue stage at 10,003 entries. RequestExecution keeps its floors inside
the worker limit, so an idle worker remains available for protected requests.
the worker limit, so an idle worker remains available for protected requests:
its total is `MaxRequestThreadCount`, and startup fails when that leaves no
worker for ordinary requests (Balanced needs at least 4).
An explicit `Stages[].Capacity` is still an absolute total that includes the
floors, and it cannot exceed the default total.
@@ -413,7 +413,9 @@ namespace Opc.Ua.Perf.ServerLoadHarness
FormattableString.Invariant(
$"--counters {(counters ? Program.Arg("nodes", "1") : "0")} --sampling {Program.Arg("sampling", "10")} ") +
FormattableString.Invariant(
$"--counter-lock {Program.Arg("counter-lock", "1")} --async-node-manager {Program.Arg("async-node-manager", "1")}"),
$"--counter-lock {Program.Arg("counter-lock", "1")} --async-node-manager {Program.Arg("async-node-manager", "1")} ") +
FormattableString.Invariant(
$"--isolation {Program.Arg("isolation", "default")}"),
// the benchmark's .NET workers run with Server GC; SERVER_DOTNET_gcServer=0 overrides it
new Dictionary<string, string>(StringComparer.Ordinal) { ["DOTNET_gcServer"] = "1" });
string ready = handle.m_lines.Take();
@@ -124,7 +124,7 @@ namespace Opc.Ua
description: "Requests executing and holding a permit or worker.");
m_meter.CreateObservableGauge(
"opcua.server.request_queue.parked",
() => Math.Max(0, Volatile.Read(ref m_processingCount) - Volatile.Read(ref m_activeThreadCount)),
() => Volatile.Read(ref m_parkedCount),
description: "Requests parked (for example held Publish requests) after releasing their permit or worker.");
m_workers = [];
@@ -330,6 +330,7 @@ namespace Opc.Ua
if (!processing.IsCompleted)
{
await Task.WhenAny(processing, parkSink.ParkedTask).ConfigureAwait(false);
CountWhileParked(processing);
}
}
else
@@ -553,6 +554,7 @@ namespace Opc.Ua
if (!processing.IsCompleted)
{
await Task.WhenAny(processing, parkSink.ParkedTask).ConfigureAwait(false);
CountWhileParked(processing);
}
}
finally
@@ -566,6 +568,26 @@ namespace Opc.Ua
// required here because ProcessRequestSafeAsync observes all faults.
}
/// <summary>
/// Counts a request that released its permit or worker at the park point as
/// parked until its handler finishes.
/// </summary>
private void CountWhileParked(Task processing)
{
if (processing.IsCompleted)
{
return;
}
Interlocked.Increment(ref m_parkedCount);
// ProcessRequestSafeAsync never throws, so the continuation only counts.
_ = processing.ContinueWith(
static (_, state) => Interlocked.Decrement(ref ((RequestQueue)state!).m_parkedCount),
this,
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
}
/// <summary>
/// Invokes the server request handler and traps any unexpected error the same
/// way the legacy inline path did, guaranteeing the request is always faulted
@@ -793,6 +815,12 @@ namespace Opc.Ua
/// </summary>
private int m_processingCount;
/// <summary>
/// Number of handlers that parked and released their permit or worker,
/// changed with <see cref="Interlocked"/>.
/// </summary>
private int m_parkedCount;
/// <summary>
/// Number of requests executing rather than parked or waiting: the permits in
/// use, changed under <see cref="m_workerGate"/>, or the busy fair-queue
@@ -281,7 +281,9 @@ namespace Opc.Ua.Server
? rateLimits.MaxConcurrentSessionEstablishment
: ServerRateLimitOptions.DefaultMaxConcurrentSessionEstablishment;
int queuedCapacity = Math.Max(100, server.MaxQueuedRequestCount);
int executionCapacity = Math.Max(100, Math.Max(server.MinRequestThreadCount, server.MaxRequestThreadCount));
// the request queue starts at most this many workers, so the execution stage cannot
// promise protected classes more than the workers can run
int executionCapacity = Math.Max(1, Math.Max(server.MinRequestThreadCount, server.MaxRequestThreadCount));
long retainedRequestSlots = 2L * queuedCapacity + executionCapacity;
if (retainedRequestSlots > long.MaxValue / quotas.MaxMessageSize)
{
@@ -382,6 +384,13 @@ namespace Opc.Ua.Server
SubtractFloor(ref remaining, owner.Floors[ii]);
trustedTotal += owner.Floors[ii];
}
if (remaining < unit && ii == (int)ResourceIsolationStage.RequestExecution && item?.Capacity == null)
{
throw new ArgumentException(
$"MaxRequestThreadCount ({executionCapacity}) must exceed the requests reserved for " +
"bootstrap, reconnect, control and trusted owners. Increase MaxRequestThreadCount or " +
"reduce the RequestExecution reservations.");
}
if (remaining < unit || hardLimit < Math.Max(Math.Max(bootstrapFloor, reconnectFloor), controlFloor))
{
throw new ArgumentException($"The {(ResourceIsolationStage)ii} pools cannot hold an operation.");
@@ -60,6 +60,39 @@ namespace Opc.Ua.Server.Tests
.And.Message.Contains("two minutes"));
}
/// <summary>
/// The execution stage holds no more requests than the request queue has workers,
/// so reserved classes cannot be crowded out of the workers by ordinary requests.
/// </summary>
[Test]
public void RuntimePlanExecutionCapacityIsMaxRequestThreadCount()
{
ApplicationConfiguration configuration = Configuration();
configuration.ServerConfiguration.MinRequestThreadCount = 1;
configuration.ServerConfiguration.MaxRequestThreadCount = 4;
ResourceIsolationStagePlan execution = Options()
.CreateRuntimePlan(configuration, new ServerRateLimitOptions())
.GetStage(ResourceIsolationStage.RequestExecution);
Assert.That(execution.Capacity, Is.EqualTo(4));
Assert.That(execution.SharedCapacity, Is.EqualTo(1));
}
/// <summary>
/// Too few workers for the reserved requests are named at startup.
/// </summary>
[Test]
public void RuntimePlanRejectsMaxRequestThreadCountBelowReservations()
{
ApplicationConfiguration configuration = Configuration();
configuration.ServerConfiguration.MinRequestThreadCount = 1;
configuration.ServerConfiguration.MaxRequestThreadCount = 3;
Assert.That(() => Options().CreateRuntimePlan(configuration, new ServerRateLimitOptions()),
Throws.ArgumentException.With.Message.Contains("MaxRequestThreadCount (3)"));
}
[Test]
public async Task StartupRejectsInvalidTimeoutWithoutInstallingOrDisposingProvidersAsync(
[Values] ServerResourceIsolationMode mode,