chore(historian): review cleanups for the raw cap

- GatewayHistorianDataSource.ReadRawWindowAsync keeps the cap + 1 probe budget
  (budgetCap) separate from the client's cap: a strict-ceiling or crowding
  refusal now reports Limit = the server cap that applied and advises setting
  NumValuesPerNode at or below it (it said "lower NumValuesPerNode" with
  Limit = cap + 1 to a client that sent 0).
- Fuzz: the oracle-source over-cap assertion was vacuous (cap 0 never counts);
  it now checks no reverse window reached the forward-only shape. Class docs
  name the post-floor cap set (3 ... 1000) and distinguish NumValuesPerNode
  1 / 2 from the smallest server cap.
- HistoryPaging.IsFullPage doc says it judges the REQUEST cap (page + ties +
  lookahead), which is finite under any limit; AdvertiseMaxReturnDataValues
  swallows any exception (returns false for the caller to log), as its doc
  promised.

Refs #736

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 10:41:37 -04:00
parent 7e8941fbdc
commit 3e62683517
3 changed files with 35 additions and 26 deletions
@@ -13,7 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// bugs that the hand-written cases missed (silent truncation, an endless cursor, an empty page between every value), so
/// the cap is proved the only way that has worked: random series (bursts, gaps, sub-millisecond stamps, tie clusters),
/// random windows (empty, on a tie, one step past a tie, between samples, forward / reverse / end-only, with and without
/// bounds) and random caps — <c>NumValuesPerNode</c> 0 / 1 / 2 / small / huge, the server cap 2 … 1000 or off, the
/// bounds) and random caps — <c>NumValuesPerNode</c> 0 / 1 / 2 / small / huge, the server cap 3 … 1000 or off, the
/// tie-cluster budget 3 / 8 / 65536 — paged through a REAL SDK node manager against a wire-faithful fake of each backend
/// (SqlServer: tick precision, native reverse, <c>TOP</c>; QuestDB: µs truncation of bounds AND stamps, native reverse,
/// <c>LIMIT</c>; Gateway: forward only, <c>[start, end)</c> after its own boundary-row filtering, walked back by the
@@ -26,8 +26,8 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// must never see a request for 0 or more than the cap (the "bounded by the same cap" invariant); and a
/// <c>BadTooManyOperations</c> is accepted only when a tie cluster (or, on the Gateway shape, one millisecond) is
/// larger than the budget that the cap and <c>MaxTieClusterOverfetch</c> leave — after an exact prefix. The run is
/// non-vacuous by construction: it asserts that empty windows, mid-tie windows, reverse reads, caps of 1 and 2 and
/// every shape occurred, and that most iterations were exact.
/// non-vacuous by construction: it asserts that empty windows, mid-tie windows, reverse reads, <c>NumValuesPerNode</c>
/// of 1 and 2, the smallest server cap (3) and every shape occurred, and that most iterations were exact.
/// </para>
/// <para>
/// Iterations: <c>OTOPCUA_PAGING_FUZZ_ITERATIONS</c> (default 10 000, the lane gate). Seeds are stable, so a failure
@@ -106,7 +106,7 @@ public sealed class NodeManagerHistoryReadRawCapFuzzTests(BoundsServerFixture fx
truthCp.ShouldBeNull($"oracle (no cap, N = 0) is one page: {tag}");
if (!bounds)
Render(truth).ShouldBe(Render(Oracle(series, start, end, direction, resolution)), $"oracle vs series: {tag}");
truthSource.OverCapRequests.ShouldBe(0, $"the uncapped oracle source must never be asked for more than it allows: {tag}");
truthSource.BadRequests.ShouldBe(0, $"a reverse window reached a forward-only backend on the oracle read: {tag}");
var windowTies = LargestTieCluster(truth);
if (truth.Count == 0) Count("empty");