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:
Joseph Doherty
2026-10-12 15:11:21 -04:00
parent be2f0cc14d
commit cf1af55720
9 changed files with 554 additions and 109 deletions
@@ -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();
}
@@ -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}";