3c3513ea3e
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.
143 lines
5.9 KiB
C#
143 lines
5.9 KiB
C#
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() { }
|
||
}
|
||
}
|