feat(opcuaserver): bounded per-node parallel HistoryRead within a batch (03/S3)
This commit is contained in:
+106
@@ -61,6 +61,85 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parallelism is bounded ABOVE by <c>HistoryReadBatchParallelism</c>: with the property set to 3 and
|
||||
/// 12 nodes in flight, no more than 3 reads are ever concurrent (03/S3).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Parallelism_is_bounded_above()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
nm.HistoryReadBatchParallelism = 3;
|
||||
var fake = new ConcurrencyTrackingHistorianDataSource(TimeSpan.FromMilliseconds(100));
|
||||
nm.HistorianDataSource = fake;
|
||||
|
||||
var nodeIds = MaterializeHistorizedNodes(nm, count: 12);
|
||||
var details = new ReadRawModifiedDetails
|
||||
{
|
||||
StartTime = DateTime.UtcNow.AddHours(-1),
|
||||
EndTime = DateTime.UtcNow,
|
||||
NumValuesPerNode = 10,
|
||||
IsReadModified = false,
|
||||
};
|
||||
|
||||
await Task.Run(() => InvokeHistoryRead(server, nm, details, nodeIds), Ct)
|
||||
.WaitAsync(TimeSpan.FromSeconds(15), Ct);
|
||||
|
||||
fake.MaxObservedConcurrency.ShouldBeGreaterThanOrEqualTo(2);
|
||||
fake.MaxObservedConcurrency.ShouldBeLessThanOrEqualTo(3);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One node's backend throw does not poison a parallel batch: that node surfaces
|
||||
/// <c>BadHistoryOperationUnsupported</c> while the other seven return Good (per-node isolation holds
|
||||
/// under parallelism — 03/S3).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task One_node_failure_does_not_poison_a_parallel_batch()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
|
||||
var src = DateTime.UtcNow.AddSeconds(-5);
|
||||
var srv = DateTime.UtcNow;
|
||||
// Node index 3's tagname throws; all others return a single good sample.
|
||||
var fake = new PerTagnameFake(tagname => tagname == "WW.Tag3"
|
||||
? throw new InvalidOperationException("backend boom for Tag3")
|
||||
: new HistorianRead(new[] { new DataValueSnapshot(1.0f, StatusCodes.Good, src, srv) }, null));
|
||||
nm.HistorianDataSource = fake;
|
||||
|
||||
var nodeIds = MaterializeHistorizedNodes(nm, count: 8);
|
||||
var details = new ReadRawModifiedDetails
|
||||
{
|
||||
StartTime = DateTime.UtcNow.AddHours(-1),
|
||||
EndTime = DateTime.UtcNow,
|
||||
NumValuesPerNode = 10,
|
||||
IsReadModified = false,
|
||||
};
|
||||
|
||||
var (results, errors) = await Task.Run(() => InvokeHistoryRead(server, nm, details, nodeIds), Ct)
|
||||
.WaitAsync(TimeSpan.FromSeconds(15), Ct);
|
||||
|
||||
for (var i = 0; i < 8; i++)
|
||||
{
|
||||
if (i == 3)
|
||||
{
|
||||
StatusCode.IsBad(errors[i].StatusCode).ShouldBeTrue();
|
||||
errors[i].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
}
|
||||
else
|
||||
{
|
||||
errors[i].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
results[i].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)
|
||||
{
|
||||
@@ -161,6 +240,33 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
{
|
||||
public Task<HistorianRead> ReadRawAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
|
||||
CancellationToken cancellationToken) => Task.FromResult(rawHandler(fullReference));
|
||||
|
||||
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() { }
|
||||
}
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
||||
{
|
||||
var host = new OpcUaApplicationHost(
|
||||
|
||||
Reference in New Issue
Block a user