fix(historian): #691 review — clamp Gateway at-or-after read, GoodNoData when nothing exists, HonorReturnBounds kill switch

1. The at-or-after bound read on a forward-only backend (Gateway) ends at
   now + ReverseRawReader.LookAhead instead of DateTime.MaxValue (the gateway
   only checks start <= end, so a year-9999 end reached AVEVA); a bound beyond
   the horizon is not found without a read. SqlServer/QuestDB keep MaxValue:
   they bind it safely and may hold samples further ahead. The Gateway fake now
   refuses far-future ends, so the missing-end-bound test is no longer vacuous.
3. No data and no bound found (whole read empty) => GoodNoData, no values —
   the pre-bounds answer (6.5.3.2: success only if one or both bounds exist).
   A one-value page whose start bound is missing first checks there is data or
   a found end bound.
4. ServerHistorian:HonorReturnBounds (default true); false ignores ReturnBounds
   as before #691, including the modified-read Bad_InvalidArgument.
This commit is contained in:
Joseph Doherty
2026-10-12 17:00:40 -04:00
parent ebc6b8d4d9
commit 934909b430
8 changed files with 233 additions and 12 deletions
@@ -40,14 +40,28 @@ public sealed class GatewayBoundingValueTests
Quality = isNull ? 1u : 0u,
};
/// <summary>ReadRaw over <c>[start, end]</c> ascending, capped, led by AVEVA's boundary row at the query start.</summary>
private static FakeHistorianGatewayClient Aveva()
/// <summary>The latest query end any ReadRaw was sent (a far-future end is the #691 review's risk).</summary>
private sealed class EndProbe
{
public DateTime MaxEnd = DateTime.MinValue;
}
/// <summary>
/// ReadRaw over <c>[start, end]</c> ascending, capped, led by AVEVA's boundary row at the query start. A query end more
/// than two days past <see cref="Now"/> is REFUSED (<c>InvalidArgument</c>), modelling the unverified AVEVA behaviour
/// for a year-9999 end (the HistorianGateway only checks <c>start &lt;= end</c>, so it reaches AVEVA unclamped).
/// </summary>
private static FakeHistorianGatewayClient Aveva(EndProbe? probe = null)
{
var sorted = Gate681.Select(x => (T: At(x.Sec), V: x.Value, x.Null)).ToList();
return new FakeHistorianGatewayClient
{
RawSource = (start, end, max) =>
{
if (probe is not null && end > probe.MaxEnd) probe.MaxEnd = end;
if (end > Now.AddDays(2))
throw new Grpc.Core.RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.InvalidArgument,
"end_time is outside the historian's time range"));
var rows = new List<HistorianSample>();
if (sorted.All(d => d.T != start))
{
@@ -124,6 +138,30 @@ public sealed class GatewayBoundingValueTests
missing.SourceTimestampUtc.ShouldBe(At(101));
}
/// <summary>
/// #691 review: the at-or-after read never sends an end beyond now + <see cref="ReverseRawReader.LookAhead"/> (the
/// historian stores nothing further ahead), and an end bound wanted beyond that horizon is not found without a read.
/// </summary>
[Fact]
public async Task The_at_or_after_read_never_asks_past_now_plus_one_day()
{
var probe = new EndProbe();
var fake = Aveva(probe);
var source = Source(fake);
var found = await HistoryBounds.EndBoundAsync(source, "T", HistoryBounds.For(At(0), At(41)), At(40), 65536, Now, Ct);
found.SourceTimestampUtc.ShouldBe(At(50));
probe.MaxEnd.ShouldBeLessThanOrEqualTo(Now + ReverseRawReader.LookAhead.Add(TimeSpan.FromMilliseconds(1)));
var calls = fake.ReadRawCallCount;
var beyond = Now.AddDays(3);
var missing = await HistoryBounds.EndBoundAsync(source, "T", HistoryBounds.For(At(0), beyond), At(100), 65536, Now, Ct);
missing.StatusCode.ShouldBe(Opc.Ua.StatusCodes.BadBoundNotFound);
missing.SourceTimestampUtc.ShouldBe(beyond);
fake.ReadRawCallCount.ShouldBe(calls, "a bound beyond the horizon is not found without a read");
source.GetHealthSnapshot().TotalFailures.ShouldBe(0);
}
[Fact]
public async Task A_reverse_read_end_bound_walks_back_to_the_stored_sample_at_or_before_the_end_time()
{