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:
+40
-2
@@ -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 <= 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()
|
||||
{
|
||||
|
||||
+12
@@ -72,6 +72,18 @@ public sealed class OtOpcUaServerHostedServiceHistoryUpdateTests
|
||||
Service(view: null).BuildHistoryUpdatePosture().MaxFutureSkew.ShouldBe(TimeSpan.FromDays(1));
|
||||
}
|
||||
|
||||
/// <summary>#691: the ReturnBounds kill switch is bound from <c>ServerHistorian:HonorReturnBounds</c>, default on.</summary>
|
||||
[Fact]
|
||||
public void The_return_bounds_kill_switch_comes_from_options_and_defaults_on()
|
||||
{
|
||||
Service(view: null).HonorReturnBounds.ShouldBeTrue();
|
||||
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ServerHistorian:HonorReturnBounds"] = "false",
|
||||
}).Build();
|
||||
Service(view: null, config: config).HonorReturnBounds.ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static OtOpcUaServerHostedService Service(
|
||||
IHistoryUpdateRoleView? view, IDriverPrimaryRoleSource? primary = null, IConfiguration? config = null,
|
||||
TimeProvider? clock = null) => new(
|
||||
|
||||
@@ -292,6 +292,71 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) :
|
||||
Render(values, T("5:00"), T("5:05")).ShouldBe("5:00, 5:02, 5:03, 5:05");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #691 review: no data AND no bound found ⇒ <c>GoodNoData</c> with no values — the answer a tag with no history (and a
|
||||
/// disabled historian) gave before bounds (§6.5.3.2: bounds make the result Good only "if one or both exist"). Only
|
||||
/// when the WHOLE read is empty: a single bound found keeps the placeholders (Table 1: "4:48 4:48 1 ⇒ FIRST").
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(Shape.Reverse)]
|
||||
[InlineData(Shape.ForwardOnly)]
|
||||
[InlineData(Shape.Microsecond)]
|
||||
public async Task No_data_and_no_bound_found_is_GoodNoData_with_no_values(Shape shape)
|
||||
{
|
||||
var windows = new (string S, string E)[] { ("5:00", "5:05"), ("5:05", "5:00"), ("5:00", "5:00"), ("5:00", ""), ("", "5:05") };
|
||||
foreach (var (start, end) in windows)
|
||||
{
|
||||
var s = start.Length == 0 ? Unspecified : T(start);
|
||||
var e = end.Length == 0 ? Unspecified : T(end);
|
||||
foreach (var n in new uint[] { 0, 1, 2, 3 })
|
||||
{
|
||||
if (n == 0 && (s == Unspecified || e == Unspecified)) continue; // a one-sided read needs a cap
|
||||
var (values, error, cp) = await ReadResultAsync(Source(shape, []), s, e, n);
|
||||
values.ShouldBeEmpty($"{start}->{end} by {n}");
|
||||
cp.ShouldBeNull();
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
LastNodeStatus.Code.ShouldBe(StatusCodes.GoodNoData, $"{start}->{end} by {n}");
|
||||
}
|
||||
}
|
||||
|
||||
// Positive control: one bound found keeps the missing one's placeholder.
|
||||
var single = await DrainAsync(Source(shape, TableData()), T("4:48"), T("4:48"), 1, bounds: true);
|
||||
Render(single, T("4:48"), T("4:48")).ShouldBe("FIRST, 5:00");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #691 review: <c>ServerHistorian:HonorReturnBounds = false</c> restores the pre-#691 answer — ReturnBounds is ignored
|
||||
/// (Table 1's "No" rows for every "Yes" row) and a modified read with ReturnBounds is not refused for it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task The_kill_switch_ignores_return_bounds()
|
||||
{
|
||||
await fx.Gate.WaitAsync(TestContext.Current.CancellationToken);
|
||||
try
|
||||
{
|
||||
fx.Nm.HonorReturnBounds = false;
|
||||
fx.Nm.HistorianDataSource = Source(Shape.Reverse, TableData());
|
||||
var (values, _, _) = Read(T("5:01"), T("5:07"), 0, bounds: true, cp: null, modified: false);
|
||||
Render(values, T("5:01"), T("5:07")).ShouldBe("5:02, 5:03, 5:05, 5:06");
|
||||
|
||||
var (_, modErr, _) = Read(T("5:00"), T("5:05"), 0, bounds: true, cp: null, modified: true);
|
||||
modErr.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported, "no trail on this fake — not InvalidArgument");
|
||||
}
|
||||
finally
|
||||
{
|
||||
fx.Nm.HonorReturnBounds = true;
|
||||
fx.Gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bounds_are_honoured_by_default()
|
||||
{
|
||||
await fx.Gate.WaitAsync(TestContext.Current.CancellationToken);
|
||||
try { fx.Nm.HonorReturnBounds.ShouldBeTrue(); }
|
||||
finally { fx.Gate.Release(); }
|
||||
}
|
||||
|
||||
/// <summary>Part 11 §6.5.3.3: a modified read with ReturnBounds is <c>Bad_InvalidArgument</c>.</summary>
|
||||
[Fact]
|
||||
public async Task A_modified_read_with_return_bounds_is_an_invalid_argument()
|
||||
@@ -357,6 +422,13 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) :
|
||||
return all;
|
||||
}
|
||||
|
||||
/// <summary>The node-level StatusCode of the last <see cref="Read"/> (GoodNoData vs Good).</summary>
|
||||
private StatusCode LastNodeStatus;
|
||||
|
||||
private async Task<(IReadOnlyList<DataValue> Values, ServiceResult Error, byte[]? Cp)> ReadResultAsync(
|
||||
IHistorianDataSource source, DateTime start, DateTime end, uint max) =>
|
||||
await ReadAsync(source, start, end, max, bounds: true, cp: null);
|
||||
|
||||
private async Task<(IReadOnlyList<DataValue> Values, ServiceResult Error, byte[]? Cp)> ReadAsync(
|
||||
IHistorianDataSource source, DateTime start, DateTime end, uint max, bool bounds, byte[]? cp)
|
||||
{
|
||||
@@ -381,6 +453,7 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) :
|
||||
var results = new List<SdkHistoryReadResult> { null! };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
fx.Nm.HistoryRead(context, details, TimestampsToReturn.Both, releaseContinuationPoints: false, nodesToRead, results, errors);
|
||||
LastNodeStatus = results[0]?.StatusCode ?? StatusCodes.Bad;
|
||||
|
||||
var values = new List<DataValue>();
|
||||
if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho))
|
||||
|
||||
@@ -104,6 +104,21 @@ public sealed class ServerHistorianOptionsTests
|
||||
/// #674: the HistoryUpdate future-skew bound defaults to one day and binds from configuration as a TimeSpan;
|
||||
/// a non-positive value is a deliberate "disabled", not a misconfiguration, so it does not warn.
|
||||
/// </summary>
|
||||
/// <summary>#691: the ReturnBounds kill switch defaults on and binds off; neither value warns.</summary>
|
||||
[Fact]
|
||||
public void HonorReturnBounds_defaults_to_true_and_binds_false_silently()
|
||||
{
|
||||
Assert.True(new ServerHistorianOptions().HonorReturnBounds);
|
||||
var bound = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { ["ServerHistorian:HonorReturnBounds"] = "false" })
|
||||
.Build()
|
||||
.GetSection(ServerHistorianOptions.SectionName)
|
||||
.Get<ServerHistorianOptions>();
|
||||
Assert.False(bound!.HonorReturnBounds);
|
||||
Assert.Empty(new ServerHistorianOptions
|
||||
{ Enabled = true, Endpoint = "https://h:5222", ApiKey = "histgw_x_y", HonorReturnBounds = false }.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HistoryUpdateMaxFutureSkew_defaults_to_one_day_binds_and_zero_is_silent()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user