Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/RawPageLimitTests.cs
T
Joseph Doherty 3c3513ea3e fix(historian): #694 page raw reads at a strict gateway's learned ceiling; opt-in EventReadHorizon
Item 1: IHistorianDataSource.MaxRawValuesPerRead (Gateway: learned ceiling - 16 = L; 0 elsewhere, guard-tested).
RawPageLimit plans every raw page on top of #692's lookahead: with L > 0 the read (page P + boundary ties k
+ lookahead) is <= L, P = min(N, L - k - 1) (uncapped: L - k - 1); no room => straight to the tie-cluster
over-fetch (never an uncapped or underflowing request). One re-plan + re-read when the read taught a new
limit or was refused for crowding pre-window rows (HistoryReadLimitExceededException.PreWindowRows): the
page shrinks by them. Over-fetch bound min(MaxTieClusterOverfetch, L - 1); slice size never 0.
Review fixes: F1 a window starting at/before the epoch counts AVEVA's synthetic row as pre-window (a
ceiling-truncated read was reported complete); F2 the page path disables ReverseRawReader's own re-run
(cold end-only ceiling 20/N=3000 cost 3,023 reads, now <= 40); F3 crowded look-back re-read around, not
refused; F4 the #687 crowding re-read room at least doubles (up to 8 re-reads), so a cluster of hundreds
just before a resume point no longer fails on a default gateway; a crowding refusal that leaves no room
is BadTooManyOperations, never a tick-by-tick cursor.

Item 2: ServerHistorian:EventReadHorizon (default 00:00:00 = exact, unchanged). When set, every fresh
HistoryReadEvents window's earlier bound is raised to now - horizon (both directions, all backends).
Lossy by design; documented.

Item 3 (native TimeAverage/Total re-check) stays blocked on historiangw#27.
2026-10-13 01:39:32 -04:00

143 lines
5.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Gitea #694: <see cref="RawPageLimit"/> — the page plan (P = min(want, L − k − 1), request = P + k + 1 ≤ L) and the one
/// re-plan after a refusal. The end-to-end behaviour over the real Gateway data source is pinned in
/// <c>GatewayCeilingPagingTests</c>.
/// </summary>
public sealed class RawPageLimitTests
{
[Theory]
// want, k, L ⇒ page, request, noRoom
[InlineData(5000u, 0, 0, 5000u, 5001u, false)] // no limit: #692 unchanged (page + ties + lookahead)
[InlineData(5000u, 3, 0, 5000u, 5004u, false)]
[InlineData(0u, 3, 0, 0u, 0u, false)] // no limit, uncapped: one uncapped read
[InlineData(5000u, 0, 984, 983u, 984u, false)] // over the limit: the request fills the limit exactly
[InlineData(5000u, 10, 984, 973u, 984u, false)] // boundary ties take room from the page
[InlineData(10u, 10, 984, 10u, 21u, false)] // under the limit: unchanged
[InlineData(0u, 0, 984, 983u, 984u, false)] // uncapped: a limit-sized page
[InlineData(0u, 982, 984, 1u, 984u, false)]
[InlineData(5000u, 983, 984, 0u, 0u, true)] // the ties alone fill a read: never a 0 (uncapped) request
[InlineData(5000u, 5000, 984, 0u, 0u, true)] // never an underflow
[InlineData(5000u, 0, 1, 0u, 0u, true)]
public void Plan_fits_page_ties_and_lookahead_into_the_limit(uint want, int k, int limit, uint page, uint request, bool noRoom)
{
var plan = RawPageLimit.Plan(want, k, limit);
plan.PageCap.ShouldBe(page);
plan.RequestCap.ShouldBe(request);
plan.NoRoom.ShouldBe(noRoom);
if (limit > 0 && !noRoom) plan.RequestCap.ShouldBeLessThanOrEqualTo((uint)limit);
}
[Fact]
public async Task A_read_that_teaches_the_limit_is_re_planned_once_at_it()
{
var source = new LimitSource();
var asked = new List<uint>();
var (page, plan) = await RawPageLimit.ReadAsync(source, 5000u, 0, p =>
{
asked.Add(p.RequestCap);
if (p.RequestCap > 984) { source.Limit = 984; throw new HistoryReadLimitExceededException(1000, "ceiling"); }
return Task.FromResult("ok");
});
page.ShouldBe("ok");
plan.PageCap.ShouldBe(983u);
asked.ShouldBe([5001u, 984u]);
}
[Fact]
public async Task A_crowded_refusal_shrinks_the_page_by_the_pre_window_rows()
{
var source = new LimitSource { Limit = 984 };
var asked = new List<uint>();
var (_, plan) = await RawPageLimit.ReadAsync(source, 0u, 2, p =>
{
asked.Add(p.RequestCap);
if (asked.Count == 1) throw new HistoryReadLimitExceededException(1000, "crowded") { PreWindowRows = 21 };
return Task.FromResult("ok");
});
asked.ShouldBe([984u, 963u]);
plan.PageCap.ShouldBe(960u);
}
[Fact]
public async Task A_refusal_at_an_unchanged_limit_without_crowding_is_the_answer()
{
var source = new LimitSource { Limit = 984 };
var calls = 0;
await Should.ThrowAsync<HistoryReadLimitExceededException>(() => RawPageLimit.ReadAsync<string>(source, 5000u, 0, _ =>
{
calls++;
throw new HistoryReadLimitExceededException(1000, "tie cluster");
}));
calls.ShouldBe(1);
}
[Fact]
public async Task A_second_refusal_is_not_retried_again()
{
var source = new LimitSource();
var calls = 0;
await Should.ThrowAsync<HistoryReadLimitExceededException>(() => RawPageLimit.ReadAsync<string>(source, 0u, 0, _ =>
{
calls++;
source.Limit = 984 - calls; // even if the limit keeps moving
throw new HistoryReadLimitExceededException(1000, "refused") { PreWindowRows = 5 };
}));
calls.ShouldBe(2);
}
[Fact]
public async Task No_room_is_returned_without_a_read()
{
var source = new LimitSource { Limit = 10 };
var calls = 0;
var (page, plan) = await RawPageLimit.ReadAsync(source, 5u, 9, _ => { calls++; return Task.FromResult("x"); });
plan.NoRoom.ShouldBeTrue();
page.ShouldBeNull();
calls.ShouldBe(0);
}
[Fact]
public async Task Other_failures_are_not_retried()
{
var source = new LimitSource();
var calls = 0;
await Should.ThrowAsync<InvalidOperationException>(() => RawPageLimit.ReadAsync<string>(source, 5000u, 0, _ =>
{
calls++;
source.Limit = 984;
throw new InvalidOperationException("backend fault");
}));
calls.ShouldBe(1);
}
/// <summary>Only <see cref="IHistorianDataSource.MaxRawValuesPerRead"/> matters here.</summary>
private sealed class LimitSource : IHistorianDataSource
{
public int Limit;
public int MaxRawValuesPerRead => Limit;
public Task<HistoryReadResult> ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<HistoryReadResult> ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
HistoryAggregateType aggregate, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<HistoryReadResult> ReadAtTimeAsync(string fullReference, IReadOnlyList<DateTime> timestampsUtc,
CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<HistoricalEventsResult> ReadEventsAsync(string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
CancellationToken cancellationToken) => throw new NotSupportedException();
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
public void Dispose() { }
}
}