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).
This commit is contained in:
+144
-4
@@ -1,7 +1,12 @@
|
||||
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;
|
||||
|
||||
@@ -9,17 +14,21 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
|
||||
/// 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.
|
||||
/// 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 = new GatewayHistorianDataSource(
|
||||
new FakeHistorianGatewayClient(), NullLogger<GatewayHistorianDataSource>.Instance);
|
||||
IHistorianDataSource ds = Source(new FakeHistorianGatewayClient());
|
||||
ds.SupportsReverseRaw.ShouldBeFalse();
|
||||
}
|
||||
|
||||
@@ -27,7 +36,7 @@ public sealed class GatewayRawDirectionTests
|
||||
public async Task A_reverse_raw_window_is_refused_unsent_and_is_not_a_backend_failure()
|
||||
{
|
||||
var fake = new FakeHistorianGatewayClient();
|
||||
IHistorianDataSource ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
||||
IHistorianDataSource ds = Source(fake);
|
||||
var end = new DateTime(2026, 10, 12, 18, 7, 45, DateTimeKind.Utc);
|
||||
|
||||
await Should.ThrowAsync<NotSupportedException>(() =>
|
||||
@@ -42,4 +51,135 @@ public sealed class GatewayRawDirectionTests
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -69,7 +69,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
var p1 = ReadEvents(nm, node, max: 3, inboundCp: null);
|
||||
p1.Cp.ShouldNotBeNull();
|
||||
var p2 = ReadEvents(nm, node, max: 3, inboundCp: p1.Cp);
|
||||
p2.Error.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
p2.Error.StatusCode.Code.ShouldBe(StatusCodes.BadTooManyOperations);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
@@ -283,7 +283,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
var p1 = ReadEvents(nm, node, max: 3, inboundCp: null, end: Early, start: Late);
|
||||
p1.Cp.ShouldNotBeNull();
|
||||
ReadEvents(nm, node, max: 3, inboundCp: p1.Cp, end: Early, start: Late).Error.StatusCode.Code
|
||||
.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
.ShouldBe(StatusCodes.BadTooManyOperations);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
|
||||
+199
-10
@@ -253,8 +253,9 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>The absurd-burst backstop is preserved: a tie cluster STRICTLY larger than the configured
|
||||
/// <see cref="OtOpcUaNodeManager.MaxTieClusterOverfetch"/> still surfaces
|
||||
/// <c>BadHistoryOperationUnsupported</c> for that node rather than buffering an unbounded burst. With
|
||||
/// <see cref="OtOpcUaNodeManager.MaxTieClusterOverfetch"/> still fails that node rather than buffering an unbounded
|
||||
/// burst — <c>BadTooManyOperations</c>, the request-limit code the reverse walk-back uses too (#686; was
|
||||
/// <c>BadHistoryOperationUnsupported</c>). With
|
||||
/// the bound set to 3 and a 5-way tie cluster, the resume read that hits the cluster fails loudly.</summary>
|
||||
[Fact]
|
||||
public async Task Raw_tie_cluster_beyond_overfetch_bound_fails_loudly()
|
||||
@@ -283,7 +284,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
// Page 2: the resume read hits the cluster; over-fetch finds 5 > bound 3 ⇒ a clear error, NOT a
|
||||
// silent GoodNoData that would drop the un-emitted ties.
|
||||
var (r2, e2, cp2) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 2, inboundCp: cp1);
|
||||
e2.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
e2.StatusCode.Code.ShouldBe(StatusCodes.BadTooManyOperations);
|
||||
r2.ShouldBeEmpty();
|
||||
cp2.ShouldBeNull();
|
||||
|
||||
@@ -531,6 +532,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
values.ShouldBe(Enumerable.Range(10, 191).Reverse().Select(i => (double)i));
|
||||
cp.ShouldBeNull();
|
||||
source.ReverseWindowCount.ShouldBe(0);
|
||||
source.ReadCount.ShouldBe(1, "an uncapped reverse read is ONE forward read, reversed in memory — not a walk");
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
@@ -564,7 +566,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
var collected = await DrainAsync(nm, nodeId, DateTime.MinValue, Epoch.AddHours(1), max: 100);
|
||||
|
||||
collected.ShouldBe(Enumerable.Range(0, 250).Reverse().Select(i => (double)i));
|
||||
source.MaxRowsReturned.ShouldBeLessThanOrEqualTo(17);
|
||||
source.MaxRowsReturned.ShouldBeLessThanOrEqualTo(2 * 16 + 4);
|
||||
source.ReverseWindowCount.ShouldBe(0);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -587,7 +589,8 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
values.ShouldBe([2d, 1d]);
|
||||
cp.ShouldBeNull();
|
||||
source.ReadCount.ShouldBeLessThan(40);
|
||||
source.ReadCount.ShouldBeLessThanOrEqualTo(20);
|
||||
source.MinRequestedStart.ShouldBeGreaterThanOrEqualTo(DateTime.UnixEpoch, "the walk never asks for data before 1970");
|
||||
source.ReverseWindowCount.ShouldBe(0);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -609,6 +612,159 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>#686 review: a dense tag is not re-discovered every page — the width the walk ended on rides the
|
||||
/// continuation point, so after the first page each page is about one read, with no full (discarded) reads.</summary>
|
||||
[Fact]
|
||||
public async Task Raw_reverse_paging_on_a_forward_only_backend_carries_the_observed_density_between_pages()
|
||||
{
|
||||
// 2000 samples 10 ms apart: a 1-minute first segment holds all of them, far over a budget of 64.
|
||||
var series = Enumerable.Range(0, 2000)
|
||||
.Select(i => { var t = Epoch.AddMilliseconds(10L * i); return new DataValueSnapshot((double)i, StatusCodes.Good, t, t); })
|
||||
.ToArray();
|
||||
var source = new ForwardOnlyHistorianDataSource(series);
|
||||
var (host, nm, nodeId) = await BootForwardOnlyAsync(source, "FwdDensity");
|
||||
nm.MaxTieClusterOverfetch = 64;
|
||||
|
||||
// Page 1 finds the data (walking down an empty hour) and its density; measure the pages after it.
|
||||
var (first, _, cp) = ReadRaw(nm, nodeId, DateTime.MinValue, Epoch.AddSeconds(20), max: 20, inboundCp: null);
|
||||
first.ShouldBe(Enumerable.Range(1980, 20).Reverse().Select(i => (double)i));
|
||||
source.ResetCounts();
|
||||
|
||||
var collected = new List<double>(first);
|
||||
var pages = 0;
|
||||
while (cp is not null)
|
||||
{
|
||||
var (values, error, next) = ReadRaw(nm, nodeId, DateTime.MinValue, Epoch.AddSeconds(20), max: 20, inboundCp: cp);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
collected.AddRange(values);
|
||||
cp = next;
|
||||
(++pages).ShouldBeLessThan(200);
|
||||
}
|
||||
|
||||
collected.ShouldBe(Enumerable.Range(0, 2000).Reverse().Select(i => (double)i));
|
||||
// The discriminating count: reads that came back full and were thrown away. Without the carried width every page
|
||||
// starts on a 1-minute segment holding all 2000 samples and discards full reads first (~2 per page).
|
||||
source.FullReads.ShouldBeLessThanOrEqualTo(2);
|
||||
// About one read per page, plus the final pages' walks from the first sample down to the 1970 floor (~16 each).
|
||||
source.ReadCount.ShouldBeLessThanOrEqualTo(pages + 40);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>#686 review: a reverse read from <c>StartTime = DateTime.MaxValue</c> walks back from now + 1 day, not from
|
||||
/// year 9999.</summary>
|
||||
[Fact]
|
||||
public async Task Raw_reverse_read_from_max_value_on_a_forward_only_backend_starts_at_now_plus_one_day()
|
||||
{
|
||||
var source = new ForwardOnlyHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1));
|
||||
var (host, nm, nodeId) = await BootForwardOnlyAsync(source, "FwdMax");
|
||||
var now = Epoch.AddHours(1);
|
||||
nm.Clock = new FixedClock(now);
|
||||
|
||||
var (values, error, _) = ReadRaw(nm, nodeId, DateTime.MaxValue, Epoch.AddSeconds(-1), max: 3, inboundCp: null);
|
||||
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
values.ShouldBe([249d, 248d, 247d]);
|
||||
source.MaxRequestedEnd.ShouldBeLessThanOrEqualTo(now.AddDays(1).AddMilliseconds(1));
|
||||
source.ReadCount.ShouldBeLessThanOrEqualTo(10);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>#686 review: AVEVA may stamp the row it fabricates at a query start rounded UP to the millisecond. The walk
|
||||
/// requests millisecond-aligned starts, so that row can never land inside (EndTime, …] when EndTime is not on a
|
||||
/// millisecond.</summary>
|
||||
[Fact]
|
||||
public async Task Raw_reverse_read_on_a_forward_only_backend_never_returns_a_boundary_row_rounded_up_into_the_window()
|
||||
{
|
||||
var source = new ForwardOnlyHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1), boundaryAtCeilingMs: true);
|
||||
var (host, nm, nodeId) = await BootForwardOnlyAsync(source, "FwdCeil");
|
||||
|
||||
var (values, error, _) = ReadRaw(nm, nodeId, Epoch.AddSeconds(10), Epoch.AddTicks(5_000), max: 100, inboundCp: null);
|
||||
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
values.ShouldBe(Enumerable.Range(1, 10).Reverse().Select(i => (double)i)); // (Epoch+0.5ms, Epoch+10s]: no 0
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>#686 review: the upper edge is filtered too — the request end is rounded UP to the millisecond, so a
|
||||
/// sample one tick after StartTime comes back from the backend and must not be returned.</summary>
|
||||
[Fact]
|
||||
public async Task Raw_reverse_read_on_a_forward_only_backend_excludes_a_sample_one_tick_after_the_start()
|
||||
{
|
||||
DataValueSnapshot At(long ticks, double v)
|
||||
{
|
||||
var t = Epoch.AddTicks(ticks);
|
||||
return new DataValueSnapshot(v, StatusCodes.Good, t, t);
|
||||
}
|
||||
|
||||
var t0 = TimeSpan.TicksPerSecond;
|
||||
var source = new ForwardOnlyHistorianDataSource([At(0, 0), At(t0, 1), At(t0 + 1, 99)]);
|
||||
var (host, nm, nodeId) = await BootForwardOnlyAsync(source, "FwdUpper");
|
||||
|
||||
var (values, error, _) = ReadRaw(nm, nodeId, Epoch.AddTicks(t0), Epoch.AddSeconds(-1), max: 10, inboundCp: null);
|
||||
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
values.ShouldBe([1d, 0d]);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>#686 review: a tie cluster of EXACTLY <c>MaxTieClusterOverfetch</c> is served — the row the backend
|
||||
/// fabricates at the query start does not consume the budget.</summary>
|
||||
[Fact]
|
||||
public async Task Raw_reverse_read_on_a_forward_only_backend_serves_a_tie_cluster_of_exactly_the_budget()
|
||||
{
|
||||
var source = new ForwardOnlyHistorianDataSource(MakeSeriesWithTieCluster(distinctBefore: 2, tieCount: 4, distinctAfter: 2));
|
||||
var (host, nm, nodeId) = await BootForwardOnlyAsync(source, "FwdExact");
|
||||
nm.MaxTieClusterOverfetch = 4;
|
||||
|
||||
var (values, error, _) = ReadRaw(nm, nodeId, Epoch.AddHours(1), Epoch.AddSeconds(-1), max: 100, inboundCp: null);
|
||||
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
values.ShouldBe(Enumerable.Range(0, 8).Reverse().Select(i => (double)i));
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>#686 review: adjacent tie clusters one millisecond apart, none over the budget, are served — a neighbour
|
||||
/// inside the request's alignment slack does not get the segment refused.</summary>
|
||||
[Fact]
|
||||
public async Task Raw_reverse_read_on_a_forward_only_backend_serves_adjacent_tie_clusters_within_the_budget()
|
||||
{
|
||||
var series = new List<DataValueSnapshot>();
|
||||
var v = 0;
|
||||
for (var c = 0; c < 3; c++)
|
||||
{
|
||||
var t = Epoch.AddSeconds(5).AddMilliseconds(c);
|
||||
for (var i = 0; i < 4; i++) series.Add(new DataValueSnapshot((double)v++, StatusCodes.Good, t, t));
|
||||
}
|
||||
|
||||
var source = new ForwardOnlyHistorianDataSource(series);
|
||||
var (host, nm, nodeId) = await BootForwardOnlyAsync(source, "FwdAdjacent");
|
||||
nm.MaxTieClusterOverfetch = 4;
|
||||
|
||||
var (values, error, _) = ReadRaw(nm, nodeId, Epoch.AddHours(1), Epoch.AddSeconds(-1), max: 100, inboundCp: null);
|
||||
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
values.ShouldBe(Enumerable.Range(0, 12).Reverse().Select(i => (double)i));
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>#686 review: a <c>MaxTieClusterOverfetch</c> of 1 (or a misconfigured 0) does not make every reverse read on
|
||||
/// a forward-only backend refuse — distinct-timestamp data is still served.</summary>
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(0)]
|
||||
public async Task Raw_reverse_read_on_a_forward_only_backend_works_with_a_minimal_budget(int budget)
|
||||
{
|
||||
var source = new ForwardOnlyHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1));
|
||||
var (host, nm, nodeId) = await BootForwardOnlyAsync(source, $"FwdMin{budget}");
|
||||
nm.MaxTieClusterOverfetch = budget;
|
||||
|
||||
var (values, error, _) = ReadRaw(nm, nodeId, DateTime.MinValue, Epoch.AddSeconds(100), max: 3, inboundCp: null);
|
||||
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
values.ShouldBe([100d, 99d, 98d]);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>A backend that DECLARES native reverse support is still sent the reverse window — one read.</summary>
|
||||
[Fact]
|
||||
public async Task Raw_end_only_read_on_a_reverse_capable_backend_is_one_native_reverse_read()
|
||||
@@ -630,7 +786,8 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>Pages a Raw read to completion and returns the union of the pages in arrival order.</summary>
|
||||
private static Task<List<double>> DrainAsync(OtOpcUaNodeManager nm, NodeId nodeId, DateTime start, DateTime end, uint max)
|
||||
private static Task<List<double>> DrainAsync(OtOpcUaNodeManager nm, NodeId nodeId, DateTime start, DateTime end, uint max,
|
||||
int maxPages = 40)
|
||||
{
|
||||
var collected = new List<double>();
|
||||
byte[]? cp = null;
|
||||
@@ -642,7 +799,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
collected.AddRange(values);
|
||||
cp = nextCp;
|
||||
pageCount++;
|
||||
pageCount.ShouldBeLessThan(40, "paging must terminate, not loop");
|
||||
pageCount.ShouldBeLessThan(maxPages, "paging must terminate, not loop");
|
||||
}
|
||||
while (cp is not null);
|
||||
return Task.FromResult(collected);
|
||||
@@ -802,8 +959,10 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
/// the query start carrying the value in effect there (Bad 0 when there is none) — counted against the cap.
|
||||
/// </summary>
|
||||
private sealed class ForwardOnlyHistorianDataSource(
|
||||
IReadOnlyList<DataValueSnapshot> series, bool emitBoundaryRow = true) : IHistorianDataSource
|
||||
IReadOnlyList<DataValueSnapshot> series, bool emitBoundaryRow = true, bool boundaryAtCeilingMs = false)
|
||||
: IHistorianDataSource
|
||||
{
|
||||
private readonly Lock _gate = new();
|
||||
private int _readCount;
|
||||
private int _reverseWindowCount;
|
||||
private int _maxRowsReturned;
|
||||
@@ -812,6 +971,19 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
public int ReverseWindowCount => _reverseWindowCount;
|
||||
public int MaxRowsReturned => _maxRowsReturned;
|
||||
|
||||
/// <summary>The earliest query start and latest query end this backend was sent.</summary>
|
||||
public DateTime MinRequestedStart { get; private set; } = DateTime.MaxValue;
|
||||
public DateTime MaxRequestedEnd { get; private set; } = DateTime.MinValue;
|
||||
|
||||
/// <summary>Reads whose row count equalled the cap (a full, possibly truncated, read).</summary>
|
||||
public int FullReads { get; private set; }
|
||||
|
||||
public void ResetCounts()
|
||||
{
|
||||
lock (_gate) FullReads = 0;
|
||||
Interlocked.Exchange(ref _readCount, 0);
|
||||
}
|
||||
|
||||
public bool SupportsReverseRaw => false;
|
||||
|
||||
public Task<HistorianRead> ReadRawAsync(
|
||||
@@ -825,15 +997,27 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
throw new InvalidOperationException("start_time must be less than or equal to end_time. (InvalidArgument)");
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (startUtc < MinRequestedStart) MinRequestedStart = startUtc;
|
||||
if (endUtc > MaxRequestedEnd) MaxRequestedEnd = endUtc;
|
||||
}
|
||||
|
||||
static DateTime Ts(DataValueSnapshot s) => s.SourceTimestampUtc ?? DateTime.MinValue;
|
||||
var prior = series.LastOrDefault(s => Ts(s) <= startUtc);
|
||||
// AVEVA may stamp its fabricated row at the query start rounded UP to its millisecond resolution.
|
||||
var stamp = boundaryAtCeilingMs && startUtc.Ticks % TimeSpan.TicksPerMillisecond != 0
|
||||
? new DateTime(startUtc.Ticks - startUtc.Ticks % TimeSpan.TicksPerMillisecond + TimeSpan.TicksPerMillisecond, DateTimeKind.Utc)
|
||||
: startUtc;
|
||||
var rows = new List<DataValueSnapshot>();
|
||||
if (emitBoundaryRow)
|
||||
rows.Add(prior is null
|
||||
? new DataValueSnapshot(0d, StatusCodes.BadNoData, startUtc, startUtc)
|
||||
: new DataValueSnapshot(prior.Value, prior.StatusCode, startUtc, startUtc));
|
||||
? new DataValueSnapshot(0d, StatusCodes.BadNoData, stamp, stamp)
|
||||
: new DataValueSnapshot(prior.Value, prior.StatusCode, stamp, stamp));
|
||||
rows.AddRange(series.Where(s => Ts(s) >= startUtc && Ts(s) <= endUtc));
|
||||
var page = maxValuesPerNode == 0 ? rows : rows.Take((int)maxValuesPerNode).ToList();
|
||||
if (maxValuesPerNode > 0 && page.Count == maxValuesPerNode)
|
||||
lock (_gate) FullReads++;
|
||||
int seen;
|
||||
while (page.Count > (seen = Volatile.Read(ref _maxRowsReturned))
|
||||
&& Interlocked.CompareExchange(ref _maxRowsReturned, page.Count, seen) != seen) { }
|
||||
@@ -898,6 +1082,11 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private sealed class FixedClock(DateTime now) : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow() => new(now);
|
||||
}
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
||||
{
|
||||
var applicationUri = $"urn:OtOpcUa.HistoryPagingTest:{Guid.NewGuid():N}";
|
||||
|
||||
Reference in New Issue
Block a user