feat(opcuaserver): process-wide HistoryRead batch limiter — flood degrades to BadTooManyOperations, not pool exhaustion (03/S3)
This commit is contained in:
+94
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user