cf1af55720
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).
186 lines
8.5 KiB
C#
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 > 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 <= 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 > end</c> and <c>max_values <= 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);
|
|
}
|
|
}
|