Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayRawDirectionTests.cs
T
Joseph Doherty cf1af55720 fix(historian): #686 review — bounded, density-aware reverse raw walk; uncapped Gateway raw reads
1. Uncapped reverse raw read on a forward-only backend = ONE forward read of the
   window, filtered and reversed in memory (was a segment-by-segment walk).
2. Segment width is aimed at ~half the row budget from the density just observed
   (overflow: from the truncated read's own sample span) and carried page to page
   in HistoryContinuationState.SegmentWidthTicks.
3. The walk starts no later than now + 1 day and never reads before the epoch.
4. Request bounds are rounded outward to the millisecond, so a row AVEVA
   fabricates at a rounded-up query start cannot land inside the segment.
5. Budget M = max(1, MaxTieClusterOverfetch); reads ask M + 2 rows, completeness is
   'short OR last row past the segment', and a read crowded by rows before the
   segment is re-read once (<= 2M + 4). A cluster of exactly M, neighbouring
   clusters each within M, and M <= 1 are all served; only one timestamp over M
   is refused. ServerHistorianOptions text updated.
6. The raw and events tie-path oversized-cluster refusal is BadTooManyOperations
   (was BadHistoryOperationUnsupported), consistent with the walk.
7. GatewayHistorianDataSource.ReadRawAsync: a caller cancellation is not a backend
   failure; NumValuesPerNode = 0 is sent as int.MaxValue (the client package and
   the gateway reject max_values <= 0, so every uncapped Gateway raw read failed);
   a MaxValuesCeiling refusal is a HistoryReadLimitExceededException.
8. Tests: upper-edge filter, epoch floor, look-ahead, ms alignment, exact/adjacent
   clusters, minimal budget, carried density, and the walk over the real Gateway
   source + FakeHistorianGatewayClient (OpcUaServer internals visible to it).
2026-10-12 15:11:21 -04:00

186 lines
8.5 KiB
C#

using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.HistorianGateway.Client;
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
/// <summary>
/// Gitea #686: the gateway's <c>ReadRaw</c> streams ascending only and rejects <c>start &gt; end</c> with
/// <c>InvalidArgument</c>, which used to be recorded as a backend health failure. The source declares it cannot read
/// newest-first (so the server walks back with forward reads), and refuses a reverse window itself without sending
/// anything or counting a failure. The walk itself is driven here over the REAL source and an AVEVA-shaped fake.
/// </summary>
public sealed class GatewayRawDirectionTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static readonly DateTime T0 = new(2026, 10, 12, 18, 6, 0, DateTimeKind.Utc);
private static GatewayHistorianDataSource Source(FakeHistorianGatewayClient fake) =>
new(fake, NullLogger<GatewayHistorianDataSource>.Instance);
[Fact]
public void The_gateway_source_declares_it_cannot_read_raw_newest_first()
{
IHistorianDataSource ds = Source(new FakeHistorianGatewayClient());
ds.SupportsReverseRaw.ShouldBeFalse();
}
[Fact]
public async Task A_reverse_raw_window_is_refused_unsent_and_is_not_a_backend_failure()
{
var fake = new FakeHistorianGatewayClient();
IHistorianDataSource ds = Source(fake);
var end = new DateTime(2026, 10, 12, 18, 7, 45, DateTimeKind.Utc);
await Should.ThrowAsync<NotSupportedException>(() =>
ds.ReadRawAsync("Gate681", end, new DateTime(0, DateTimeKind.Utc), 3, Ct));
fake.ReadRawCallCount.ShouldBe(0);
var health = ds.GetHealthSnapshot();
health.TotalFailures.ShouldBe(0);
health.ConsecutiveFailures.ShouldBe(0);
// Positive control: a forward read of the same window is sent.
await ds.ReadRawAsync("Gate681", new DateTime(2026, 10, 12, 18, 0, 0, DateTimeKind.Utc), end, 3, Ct);
fake.ReadRawCallCount.ShouldBe(1);
}
/// <summary>
/// An uncapped raw read (<c>NumValuesPerNode = 0</c>, "all values") is sent as an unbounded <c>max_values</c>. The
/// client package and the gateway both reject <c>max_values &lt;= 0</c>, so forwarding 0 failed every uncapped raw
/// read — forward, and the one-shot read an uncapped reverse read is served from (#686 review).
/// </summary>
[Fact]
public async Task An_uncapped_raw_read_asks_the_gateway_for_an_unbounded_max_values()
{
var fake = new FakeHistorianGatewayClient();
await Source(fake).ReadRawAsync("T", T0, T0.AddHours(1), 0, Ct);
fake.LastReadRawMaxValues.ShouldBe(int.MaxValue);
}
[Fact]
public async Task A_deadline_cancellation_is_not_a_backend_failure()
{
var fake = new FakeHistorianGatewayClient { RawSamples = [Wire(T0, 1)] };
var ds = Source(fake);
using var cts = new CancellationTokenSource();
await cts.CancelAsync();
await Should.ThrowAsync<OperationCanceledException>(() => ds.ReadRawAsync("T", T0, T0.AddHours(1), 10, cts.Token));
ds.GetHealthSnapshot().TotalFailures.ShouldBe(0);
}
[Fact]
public async Task The_gateways_max_values_ceiling_rejection_of_a_raw_read_is_a_request_limit_not_a_backend_fault()
{
var rpc = new RpcException(new Status(StatusCode.InvalidArgument,
"max_values exceeds the server ceiling of 1000000; read large ranges by time-slicing ..."));
var ds = Source(new FakeHistorianGatewayClient { ThrowOnRead = new HistorianGatewayException(rpc.Status.Detail, rpc) });
await Should.ThrowAsync<HistoryReadLimitExceededException>(() => ds.ReadRawAsync("T", T0, T0.AddHours(1), 0, Ct));
ds.GetHealthSnapshot().TotalFailures.ShouldBe(0);
}
// ---- the walk-back over the real source ---------------------------------------------------------------------------
private static HistorianSample Wire(DateTime t, double v, uint opc = 192) =>
new() { Tag = "T", Timestamp = Timestamp.FromDateTime(t), NumericValue = v, OpcQuality = opc };
/// <summary>
/// A gateway over stored samples (value i at T0 + i s, i = 0..299) shaped like AVEVA: ReadRaw rejects
/// <c>start &gt; end</c> and <c>max_values &lt;= 0</c>, returns <c>[start, end]</c> ascending capped at
/// <c>max_values</c>, and first emits a row stamped at the query start carrying the value in effect there
/// (<c>Quality = 133</c>), or a NULL row (<c>OpcQuality = 0</c>, value 0) when there is none.
/// </summary>
private static FakeHistorianGatewayClient AvevaStore()
{
var stored = Enumerable.Range(0, 300).Select(i => (Time: T0.AddSeconds(i), Value: (double)i)).ToList();
return new FakeHistorianGatewayClient
{
RawSource = (start, end, max) =>
{
if (start > end) throw new RpcException(new Status(StatusCode.InvalidArgument, "start_time must be less than or equal to end_time."));
if (max <= 0) throw new ArgumentOutOfRangeException(nameof(max));
var rows = new List<HistorianSample>();
if (stored.All(s => s.Time != start))
{
var prior = stored.LastOrDefault(s => s.Time < start);
var boundary = prior.Time == default
? new HistorianSample { Tag = "T", NumericValue = 0, OpcQuality = 0, Quality = 1 }
: Wire(prior.Time, prior.Value);
boundary.Timestamp = Timestamp.FromDateTime(start);
if (prior.Time != default) boundary.Quality = 133;
rows.Add(boundary);
}
rows.AddRange(stored.Where(s => s.Time >= start && s.Time <= end).Select(s => Wire(s.Time, s.Value)));
return rows.Take(max);
},
};
}
private static void ShouldOnlyHaveSentValidForwardReads(FakeHistorianGatewayClient fake)
{
fake.RawCalls.ShouldNotBeEmpty();
foreach (var (start, end, max) in fake.RawCalls)
{
start.ShouldBeLessThanOrEqualTo(end);
max.ShouldBeGreaterThan(0);
start.ShouldBeGreaterThanOrEqualTo(DateTime.UnixEpoch);
}
fake.RawStreamsCompleted.ShouldBe(fake.ReadRawCallCount); // no gateway stream abandoned (historiangw#26)
}
[Fact]
public async Task A_capped_reverse_read_walked_back_over_the_gateway_returns_the_latest_values_newest_first()
{
var fake = AvevaStore();
var ds = Source(fake);
var page = await ReverseRawReader.ReadAsync(ds, "T", hi: T0.AddSeconds(200), lo: T0.AddSeconds(50), maxValues: 10,
maxTieCluster: 65536, nowUtc: T0.AddHours(1), widthHintTicks: 0, Ct);
page.Samples.Select(s => (double)s.Value!).ShouldBe(Enumerable.Range(191, 10).Reverse().Select(i => (double)i));
page.Samples.ShouldAllBe(s => s.StatusCode == 0u);
ShouldOnlyHaveSentValidForwardReads(fake);
ds.GetHealthSnapshot().TotalFailures.ShouldBe(0);
}
[Fact]
public async Task An_end_only_read_walked_back_over_the_gateway_reaches_the_first_sample_and_no_fabricated_row()
{
var fake = AvevaStore();
var ds = Source(fake);
var page = await ReverseRawReader.ReadAsync(ds, "T", hi: T0.AddSeconds(2), lo: new DateTime(0, DateTimeKind.Utc),
maxValues: 10, maxTieCluster: 65536, nowUtc: T0.AddHours(1), widthHintTicks: 0, Ct);
// 2, 1, 0 — and not the NULL row AVEVA fabricates at each query start before the first sample.
page.Samples.Select(s => (double)s.Value!).ShouldBe([2d, 1d, 0d]);
ShouldOnlyHaveSentValidForwardReads(fake);
}
[Fact]
public async Task An_uncapped_reverse_read_over_the_gateway_is_one_forward_read()
{
var fake = AvevaStore();
var ds = Source(fake);
var page = await ReverseRawReader.ReadAsync(ds, "T", hi: T0.AddSeconds(200), lo: T0.AddSeconds(50), maxValues: 0,
maxTieCluster: 65536, nowUtc: T0.AddHours(1), widthHintTicks: 0, Ct);
page.Samples.Select(s => (double)s.Value!).ShouldBe(Enumerable.Range(51, 150).Reverse().Select(i => (double)i));
fake.ReadRawCallCount.ShouldBe(1);
ShouldOnlyHaveSentValidForwardReads(fake);
}
}