fix(opcua): fit the raw end bound against the page's effective cap

H1 fuzz finding (routed to Lane B because AppendEndBoundAsync lives in
OtOpcUaNodeManager.cs): a bounded HistoryRead-Raw page sized BELOW
NumValuesPerNode by the backend's row ceiling (RawPageLimit.Plan) took
the end bound whenever its data fitted, because the fit was judged
against NumValuesPerNode instead of the page's effective cap — a full
ceiling-sized page came back with cap + 1 values. The bound is now
fitted against the page allowance (the ceiling-adjusted data cap plus
the start bound a first page already carries); when the page is full
the bound goes to the next page through the continuation point.

Test: NodeManagerHistoryReadBoundsTests
.A_full_ceiling_sized_page_carries_the_end_bound_on_the_next_page
(ceiling 4 ⇒ 3 data rows; first page = start bound + 3 with a CP, second
page = the end bound alone; control: no ceiling ⇒ one page of 5).

Refs #732 #736

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 10:43:21 -04:00
parent d90f35b6be
commit 8a1116e658
2 changed files with 44 additions and 6 deletions
@@ -292,6 +292,35 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) :
Render(values, T("5:00"), T("5:05")).ShouldBe("5:00, 5:02, 5:03, 5:05");
}
/// <summary>
/// #736 (H1 fuzz) — a backend row ceiling sizes the page BELOW NumValuesPerNode (RawPageLimit: ceiling 4 ⇒ 3 data
/// rows per page). When the data exactly fills that page, the end bound must go to the NEXT page through the
/// continuation point, not be appended to a page that is already at its effective cap: the fit used to be
/// judged against NumValuesPerNode (10 here), so the page came back with cap + 1 values.
/// </summary>
[Fact]
public async Task A_full_ceiling_sized_page_carries_the_end_bound_on_the_next_page()
{
var source = new SeriesSource(TableData(), reverse: true, inclusiveForwardEnd: false, resolution: 1, ceiling: 4);
var (first, error, cp) = await ReadAsync(source, T("5:01"), T("5:06"), max: 10, bounds: true, cp: null);
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
// Start bound 5:00 + the three data rows the ceiling allows = the page's effective cap, exactly.
Render(first, T("5:01"), T("5:06")).ShouldBe("5:00, 5:02, 5:03, 5:05");
cp.ShouldNotBeNull("the page is full, so the end bound is owed on the next page");
var (second, error2, cp2) = await ReadAsync(source, T("5:01"), T("5:06"), max: 10, bounds: true, cp);
error2.StatusCode.Code.ShouldBe(StatusCodes.Good);
Render(second, T("5:01"), T("5:06")).ShouldBe("5:06");
cp2.ShouldBeNull();
// Control: without a ceiling the same read fits on ONE page of NumValuesPerNode — the fit keys on the
// effective cap, not on a smaller constant.
var (whole, _, cpWhole) = await ReadAsync(Source(Shape.Reverse, TableData()), T("5:01"), T("5:06"), max: 10, bounds: true, cp: null);
Render(whole, T("5:01"), T("5:06")).ShouldBe("5:00, 5:02, 5:03, 5:05, 5:06");
cpWhole.ShouldBeNull();
}
/// <summary>
/// #691 review: no data AND no bound found ⇒ <c>GoodNoData</c> with no values — the answer a tag with no history (and a
/// disabled historian) gave before bounds (§6.5.3.2: bounds make the result Good only "if one or both exist"). Only
@@ -468,7 +497,7 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) :
/// a point read is the instant. <paramref name="resolution"/> &gt; 1 truncates every window bound to it first (QuestDB
/// µs); the series must already be stored at that resolution.
/// </summary>
private sealed class SeriesSource(IReadOnlyList<DataValueSnapshot> series, bool reverse, bool inclusiveForwardEnd, long resolution)
private sealed class SeriesSource(IReadOnlyList<DataValueSnapshot> series, bool reverse, bool inclusiveForwardEnd, long resolution, int ceiling = 0)
: IHistorianDataSource
{
public int ReverseWindowsOnForwardOnly;
@@ -477,6 +506,9 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) :
public long TimestampResolutionTicks => resolution;
/// <summary>A strict row ceiling (0 = none), so a page is sized below NumValuesPerNode (RawPageLimit).</summary>
public int MaxRawValuesPerRead => ceiling;
private DateTime Trunc(DateTime t) => new(t.Ticks - t.Ticks % resolution, DateTimeKind.Utc);
public Task<HistorianRead> ReadRawAsync(