fix(historian): address code review on Raw HistoryRead paging

C1 (critical): a boundary tie cluster larger than NumValuesPerNode could
silently truncate a resumed read to GoodNoData, permanently dropping the
un-emitted ties — the (timestamp, skip) cursor cannot advance past a single
timestamp the fixed-(start,end,cap) backend keeps re-returning. Now detected
and failed LOUDLY per node with BadHistoryOperationUnsupported + a log naming
the tag/timestamp/cap; documented in Historian.md with the larger-cap remedy.
Regression test Raw_tie_cluster_larger_than_page_fails_loudly_not_silently.

I3: build HistoryData before Save() so a projection failure can never orphan a
stored continuation cursor.

N1 (YAGNI): drop the never-produced HistoryReadKind enum + Processed-only
Aggregate/IntervalTicks fields from HistoryContinuationState — only Raw pages.

N3: ComputeResumeCursor guards its documented non-empty precondition.

I1: document InMemoryHistoryContinuationStore's eventual-consistency (test double).

Build clean, 182/182 OpcUaServer tests pass.
This commit is contained in:
Joseph Doherty
2026-06-15 05:15:07 -04:00
parent 94c3ca60fc
commit bea0b482d4
6 changed files with 101 additions and 36 deletions
@@ -153,9 +153,9 @@ public sealed class HistoryPagingTests
{
var store = new InMemoryHistoryContinuationStore();
var state = new HistoryContinuationState(
HistoryReadKind.Raw, "WW.Tag", new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
"WW.Tag", new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc), BoundarySkipCount: 2,
NumValuesPerNode: 100, Aggregate: default, IntervalTicks: 0);
NumValuesPerNode: 100);
var cp = store.Save(session: null, state);
@@ -209,6 +209,6 @@ public sealed class HistoryPagingTests
}
private static HistoryContinuationState RawState() => new(
HistoryReadKind.Raw, "WW.Tag", DateTime.UtcNow, DateTime.UtcNow.AddHours(1),
BoundarySkipCount: 1, NumValuesPerNode: 50, Aggregate: default, IntervalTicks: 0);
"WW.Tag", DateTime.UtcNow, DateTime.UtcNow.AddHours(1),
BoundarySkipCount: 1, NumValuesPerNode: 50);
}