feat(opcuaserver): process-wide HistoryRead batch limiter — flood degrades to BadTooManyOperations, not pool exhaustion (03/S3)

This commit is contained in:
Joseph Doherty
2026-07-13 12:18:35 -04:00
parent 888cf86655
commit 3cebfae417
3 changed files with 242 additions and 70 deletions
@@ -171,6 +171,55 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable
await host.DisposeAsync();
}
/// <summary>
/// A saturated process-wide batch limiter degrades a second concurrent HistoryRead to
/// <c>BadTooManyOperations</c> (rather than exhausting the SDK request-thread pool); once the parked
/// batch completes the slot frees and a later read succeeds (03/S3).
/// </summary>
[Fact]
public async Task Saturated_limiter_rejects_with_BadTooManyOperations()
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
nm.MaxConcurrentHistoryReadBatches = 1;
nm.HistoryReadDeadline = TimeSpan.FromMilliseconds(200);
var fake = new GatedRawFake();
nm.HistorianDataSource = fake;
var ids = MaterializeHistorizedNodes(nm, count: 2);
var details = new ReadRawModifiedDetails
{
StartTime = DateTime.UtcNow.AddHours(-1),
EndTime = DateTime.UtcNow,
NumValuesPerNode = 10,
IsReadModified = false,
};
// First batch parks in the gated fake, holding the single limiter permit.
var first = Task.Run(() => InvokeHistoryRead(server, nm, details, ids[0]), Ct);
var spin = Stopwatch.StartNew();
while (fake.Entered == 0 && spin.Elapsed < TimeSpan.FromSeconds(5))
await Task.Delay(10, Ct);
fake.Entered.ShouldBe(1);
// Second concurrent batch: the limiter is saturated → all handles BadTooManyOperations.
var (_, errors2) = await Task.Run(() => InvokeHistoryRead(server, nm, details, ids[1]), Ct)
.WaitAsync(TimeSpan.FromSeconds(5), Ct);
errors2[0].StatusCode.Code.ShouldBe(StatusCodes.BadTooManyOperations);
fake.Entered.ShouldBe(1, "the rejected batch must never reach the backend");
// Release the parked batch; the slot frees and a later read succeeds.
fake.Release();
await first.WaitAsync(TimeSpan.FromSeconds(10), Ct);
var (_, errors3) = await Task.Run(() => InvokeHistoryRead(server, nm, details, ids[1]), Ct)
.WaitAsync(TimeSpan.FromSeconds(10), Ct);
errors3[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
await host.DisposeAsync();
}
/// <summary>Materialises <paramref name="count"/> historized Float variables and returns their NodeIds.</summary>
private static NodeId[] MaterializeHistorizedNodes(OtOpcUaNodeManager nm, int count)
{
@@ -306,6 +355,51 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable
public void Dispose() { }
}
/// <summary>A fake whose raw read parks (awaits a <see cref="TaskCompletionSource"/>) until the test
/// releases it — used to hold the process-wide batch limiter permit while a second batch is issued.</summary>
private sealed class GatedRawFake : IHistorianDataSource
{
private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously);
private int _entered;
/// <summary>Number of raw reads that reached the backend body.</summary>
public int Entered => Volatile.Read(ref _entered);
/// <summary>Releases the parked read so its awaiting batch can complete.</summary>
public void Release() => _gate.TrySetResult();
public async Task<HistorianRead> ReadRawAsync(
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
CancellationToken cancellationToken)
{
Interlocked.Increment(ref _entered);
// Deliberately ignore the per-request deadline token: the first batch must keep HOLDING the
// single limiter permit until the test releases it, so the second batch deterministically hits
// a saturated limiter (rather than the first batch's own deadline freeing the slot early).
await _gate.Task.ConfigureAwait(false);
return new HistorianRead(
new[] { new DataValueSnapshot(1.0f, StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow) }, null);
}
public Task<HistorianRead> ReadProcessedAsync(
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
HistoryAggregateType aggregate, CancellationToken cancellationToken) =>
NullHistorianDataSource.Instance.ReadProcessedAsync(fullReference, startUtc, endUtc, interval, aggregate, cancellationToken);
public Task<HistorianRead> ReadAtTimeAsync(
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken) =>
NullHistorianDataSource.Instance.ReadAtTimeAsync(fullReference, timestampsUtc, cancellationToken);
public Task<HistoricalEventsResult> ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
CancellationToken cancellationToken) =>
NullHistorianDataSource.Instance.ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, cancellationToken);
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
public void Dispose() { }
}
/// <summary>A per-tagname fake that delegates each raw read to a caller-supplied func (which may throw
/// to simulate a per-node backend failure). Processed/AtTime/Events delegate to the null source.</summary>
private sealed class PerTagnameFake(Func<string, HistorianRead> rawHandler) : IHistorianDataSource