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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -146,6 +146,45 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Degenerate tie cluster larger than the page cap: a single timestamp carrying MORE ties
|
||||
/// than <c>NumValuesPerNode</c> cannot be paged past by a (timestamp, skip) cursor — the fixed-(start,
|
||||
/// end,cap) backend keeps returning the same first <c>cap</c> ties. Rather than silently truncate to
|
||||
/// GoodNoData (permanently dropping the un-emitted ties), the resume read FAILS LOUDLY for that node
|
||||
/// with <c>BadHistoryOperationUnsupported</c>. (Regression for the data-loss path the carry-offset
|
||||
/// cursor cannot resolve; the operator's remedy is a larger NumValuesPerNode.)</summary>
|
||||
[Fact]
|
||||
public async Task Raw_tie_cluster_larger_than_page_fails_loudly_not_silently()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore();
|
||||
|
||||
// 6 samples ALL sharing one timestamp (Epoch+2s) — a tie cluster of 6 with a page cap of 4.
|
||||
var t = Epoch.AddSeconds(2);
|
||||
var series = Enumerable.Range(0, 6)
|
||||
.Select(i => new DataValueSnapshot((double)i, StatusCodes.Good, t, t)).ToArray();
|
||||
nm.HistorianDataSource = new SeriesHistorianDataSource(series);
|
||||
|
||||
nm.EnsureVariable("eq-1/burst", parentFolderNodeId: null, displayName: "Burst", dataType: "Double",
|
||||
writable: false, historianTagname: "WW.Burst");
|
||||
var nodeId = nm.TryGetVariable("eq-1/burst")!.NodeId;
|
||||
|
||||
// Page 1: a full page of the first 4 ties, with a continuation point.
|
||||
var (r1, e1, cp1) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 4, inboundCp: null);
|
||||
e1.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
r1.ShouldBe(new[] { 0.0, 1.0, 2.0, 3.0 });
|
||||
cp1.ShouldNotBeNull();
|
||||
|
||||
// Page 2: the cursor cannot advance past the oversized cluster ⇒ a clear error, NOT a silent
|
||||
// GoodNoData that would drop samples 4 and 5.
|
||||
var (r2, e2, cp2) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 4, inboundCp: cp1);
|
||||
e2.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
r2.ShouldBeEmpty();
|
||||
cp2.ShouldBeNull();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>NumValuesPerNode == 0 ("all values") never pages — the whole series returns in one shot
|
||||
/// with a null continuation point.</summary>
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user