7e8941fbdc
- NodeManagerHistoryReadRawCapFuzzTests: 10k seeds (env OTOPCUA_PAGING_FUZZ_ITERATIONS)
through a real SDK node manager against wire-faithful fakes of SqlServer / QuestDB /
Gateway that enforce the backend half of the cap; random series with ties, random
windows (empty, on/past a tie, between samples, forward/reverse/end-only, bounds),
N in {0,1,2,small,huge}, cap in {off,3..1000}, tie budget {3,8,65536}. Pages must
concatenate to the cap-off one-shot answer (checked against the series), terminate
within values + 2 (+ one empty step per oversized boundary cluster), never exceed
the cap, never ask the backend for 0 / over-cap; refusals only past the budget.
- HistoryPaging.AdvertiseMaxReturnDataValues writes HistoryServerCapabilities/
MaxReturnDataValues (i=11273), read back through the SDK master node manager.
- MinMaxRawValuesPerRead raised to 3: at 2 a resumed page has no room (value + its
boundary tie + lookahead) and every value cost an empty page + CP (seed 15).
- RawPageLimit rows pin the default-cap arithmetic (9 999 first page, 9 998 resumed).
Fuzz findings recorded as follow-ups: a bounded read whose data fits one page
returns cap + 1 (end bound fitted against N, not the cap; node manager, F2);
the Gateway walk-back's cost on a sparse series under a tiny tie budget is
pre-existing (seed 320: 82 reads uncapped, 93 capped).
Refs #736
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
152 lines
7.0 KiB
C#
152 lines
7.0 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)]
|
||
// R08:1.1 (#736): the server raw cap (default 10 000) is the limit every backend reports — an uncapped read pages at it.
|
||
[InlineData(0u, 0, 10_000, 9_999u, 10_000u, false)] // NumValuesPerNode = 0: a first page of cap − 1 + the lookahead row
|
||
[InlineData(0u, 1, 10_000, 9_998u, 10_000u, false)] // a resumed page re-reads its one boundary tie
|
||
[InlineData(100_000u, 0, 10_000, 9_999u, 10_000u, false)] // a client cap above the server cap is bounded by it
|
||
[InlineData(10_000u, 0, 10_000, 9_999u, 10_000u, false)] // exactly the cap: still cap − 1 (the lookahead row needs room)
|
||
[InlineData(500u, 0, 10_000, 500u, 501u, false)] // under the cap: unchanged
|
||
[InlineData(0u, 0, 3, 2u, 3u, false)] // the smallest usable cap (MinMaxRawValuesPerRead = 3): a first page of two
|
||
[InlineData(0u, 1, 3, 1u, 3u, false)] // … and a resumed page still carries one value beside its boundary tie
|
||
[InlineData(0u, 1, 2, 0u, 0u, true)] // at 2 a resumed page has NO room — every value would cost an empty page (why 3 is the floor)
|
||
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() { }
|
||
}
|
||
}
|