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
@@ -5898,10 +5898,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// Compute in uint so +1 can never wrap: Math.Max(1,…) also guards against a zero/negative
// config value slipping through (Validate() warns but does not block startup).
var overfetchCap = (uint)Math.Max(1, MaxTieClusterOverfetch) + 1u;
var cluster = (await HistorianDataSource
IReadOnlyList<DataValueSnapshot> cluster = (await HistorianDataSource
.ReadRawAsync(tagname, startUtc, startUtc, overfetchCap, ct)
.ConfigureAwait(false)).Samples;
// A start == end point read returns the ties in the backend's FORWARD order, but a reverse read
// emitted them (and counted boundarySkip) in descending order — the mirror of forward. Slice the
// cluster in the read's own direction, or the skip would index from the wrong end and re-emit ties.
var reverse = startUtc > endUtc;
if (reverse) cluster = cluster.Reverse().ToList();
// Absurd burst: more ties than we're willing to buffer in memory. Preserve today's loud-fail
// for that node rather than over-fetch an unbounded cluster; the operator's remedy is a
// larger ServerHistorian:MaxTieClusterOverfetch (or NumValuesPerNode).
@@ -5916,9 +5922,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
return;
}
// Direction comes from the window itself (Part 11: start > end is a reverse read). A resumed reverse
// read keeps start > end, because a reverse resume point is always strictly after endUtc.
HistoryPaging.SliceTieCluster(
cluster.Count, boundarySkip, numValuesPerNode, startUtc, endUtc,
out var sliceStart, out var sliceCount, out var nextStartUtc, out var nextSkip);
out var sliceStart, out var sliceCount, out var nextStartUtc, out var nextSkip,
reverse: reverse);
var slice = new List<DataValueSnapshot>(sliceCount);
for (var i = sliceStart; i < sliceStart + sliceCount; i++) slice.Add(cluster[i]);