fix(opcua): reverse HistoryRead-Raw paging no longer stops after the first page

A reverse read (start > end) with a small NumValuesPerNode resumed through
the tie-cluster path, which drained the cluster and stepped FORWARD to
T + 1 tick - outside a reverse window - so the read ended with GoodNoData
after one sample. SliceTieCluster now steps down to T - 1 tick for a
reverse read (terminal at the excluded end), and the node manager slices
the over-fetched cluster in the read's own direction so the skip count is
not applied from the wrong end. Forward paging is unchanged.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-09 14:20:00 -04:00
parent 821763a09d
commit 8d51380ff1
4 changed files with 134 additions and 7 deletions
@@ -205,14 +205,29 @@ internal static class HistoryPaging
/// remains, or <c>null</c> when the window is exhausted (terminal — no continuation point).</param>
/// <param name="nextSkip">The next page's boundary skip count: the running emitted-tie total while the
/// cluster drains, else <c>0</c> after advancing past it.</param>
/// <param name="reverse">
/// <c>true</c> for a reverse read (Part 11: <c>start &gt; end</c>, samples in descending time, window
/// <c>(end, start]</c>). A drained cluster then steps DOWN to <c>T − 1 tick</c>, which is lossless for the
/// same reason <c>T + 1 tick</c> is going forward, and the read continues only while that is still strictly
/// after <paramref name="endUtc"/> (the reverse window excludes <c>end</c>; a resume at exactly <c>end</c>
/// would turn into a <c>start == end</c> point read and return the excluded sample). Forward is unchanged.
/// </param>
public static void SliceTieCluster(
int clusterCount, int skip, uint cap, DateTime boundaryT, DateTime endUtc,
out int sliceStart, out int sliceCount, out DateTime? nextStartUtc, out int nextSkip)
out int sliceStart, out int sliceCount, out DateTime? nextStartUtc, out int nextSkip,
bool reverse = false)
{
sliceStart = Math.Min(skip, clusterCount);
sliceCount = Math.Min((int)cap, clusterCount - sliceStart);
var emitted = sliceStart + sliceCount;
if (emitted < clusterCount) { nextStartUtc = boundaryT; nextSkip = emitted; }
else if (reverse)
{
// DateTime.MinValue cannot step down; nothing can lie below it anyway.
var more = boundaryT > DateTime.MinValue && boundaryT.AddTicks(-1) > endUtc;
nextStartUtc = more ? boundaryT.AddTicks(-1) : null;
nextSkip = 0;
}
else
{
var next = boundaryT.AddTicks(1);