feat(opcuaserver): per-request HistoryRead deadline — hung backends surface BadTimeout (03/S3)
This commit is contained in:
+66
@@ -140,6 +140,37 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A per-request deadline bounds a hung backend: reads that only complete on token cancellation
|
||||
/// surface <c>BadTimeout</c> for every node and the call returns within the deadline (plus slack) —
|
||||
/// it does not hang an SDK request thread indefinitely (03/S3).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Deadline_bounds_a_hung_backend()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
nm.HistoryReadDeadline = TimeSpan.FromMilliseconds(500);
|
||||
nm.HistorianDataSource = new HangingHistorianDataSource();
|
||||
|
||||
var nodeIds = MaterializeHistorizedNodes(nm, count: 4);
|
||||
var details = new ReadRawModifiedDetails
|
||||
{
|
||||
StartTime = DateTime.UtcNow.AddHours(-1),
|
||||
EndTime = DateTime.UtcNow,
|
||||
NumValuesPerNode = 10,
|
||||
IsReadModified = false,
|
||||
};
|
||||
|
||||
var (_, errors) = await Task.Run(() => InvokeHistoryRead(server, nm, details, nodeIds), Ct)
|
||||
.WaitAsync(TimeSpan.FromSeconds(10), Ct);
|
||||
|
||||
for (var i = 0; i < 4; i++)
|
||||
errors[i].StatusCode.Code.ShouldBe(StatusCodes.BadTimeout);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Materialises <paramref name="count"/> historized Float variables and returns their NodeIds.</summary>
|
||||
private static NodeId[] MaterializeHistorizedNodes(OtOpcUaNodeManager nm, int count)
|
||||
{
|
||||
@@ -240,6 +271,41 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A fake whose reads complete ONLY on token cancellation — used to prove the per-request
|
||||
/// deadline surfaces <c>BadTimeout</c> rather than hanging.</summary>
|
||||
private sealed class HangingHistorianDataSource : IHistorianDataSource
|
||||
{
|
||||
private static async Task<HistorianRead> HangAsync(CancellationToken ct)
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
|
||||
return new HistorianRead(Array.Empty<DataValueSnapshot>(), null);
|
||||
}
|
||||
|
||||
public Task<HistorianRead> ReadRawAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
|
||||
CancellationToken cancellationToken) => HangAsync(cancellationToken);
|
||||
|
||||
public Task<HistorianRead> ReadProcessedAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
|
||||
HistoryAggregateType aggregate, CancellationToken cancellationToken) => HangAsync(cancellationToken);
|
||||
|
||||
public Task<HistorianRead> ReadAtTimeAsync(
|
||||
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken) =>
|
||||
HangAsync(cancellationToken);
|
||||
|
||||
public async Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false);
|
||||
return new HistoricalEventsResult(Array.Empty<HistoricalEvent>(), null);
|
||||
}
|
||||
|
||||
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