feat(historian): #691 HistoryRead-Raw ReturnBounds — Part 11 bounding values on every backend

The node manager composes the start/end bounds from each backend's existing raw
reads (one authority, HistoryBounds): at-or-before = a capped reverse read
(native, or ReverseRawReader's walk-back on a forward-only backend — never
AVEVA's synthetic boundary row), at-or-after = a capped forward read. Bounds
count toward NumValuesPerNode, the start bound leads the first page, the end
bound ends the last (carried in the continuation point). Missing bound =
Bad_BoundNotFound (null, at the bound time; one-sided reads ±1 s from the
previous value, Table 1 footnotes a/b). A modified read with ReturnBounds is
Bad_InvalidArgument (Part 11 6.5.3.3).

The .NET SDK's ReadRawModifiedDetails defaults ReturnBounds to TRUE; the
existing tests built details without setting it and now set it false.
Oracle: every row of Part 11 Table 1 over three backend shapes, plus paging
concatenation, ties, Bad bounds, microsecond truncation; conformance pins the
lookup primitives on SqlServer + QuestDB.
This commit is contained in:
Joseph Doherty
2026-10-12 16:25:00 -04:00
parent 88141d6ed5
commit 60febbf2dc
14 changed files with 918 additions and 11 deletions
@@ -5056,6 +5056,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
var work = new List<Func<Task>>(nodesToProcess.Count);
foreach (var handle in nodesToProcess)
{
if (details.IsReadModified && details.ReturnBounds)
{
// Part 11 §6.5.3.3: returnBounds "shall be set to FALSE" for a modified read, "otherwise the Server returns
// a Bad_InvalidArgument" (#691).
errors[handle.Index] = StatusCodes.BadInvalidArgument;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadInvalidArgument };
continue;
}
if (details.IsReadModified)
{
// Part 11 modified-value history (#668): the prior values every HistoryUpdate recorded. Gated by the
@@ -5070,7 +5079,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
work.Add(() => ServeRawPagedAsync(
handle, session, nodesToRead, results, errors,
details.StartTime, details.EndTime, details.NumValuesPerNode,
ct));
ct, details.ReturnBounds));
}
RunBounded(work, HistoryReadBatchParallelism);
@@ -6197,6 +6206,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <param name="startTimeUtc">The request window's (inclusive) lower bound, used for a fresh read.</param>
/// <param name="endUtc">The (inclusive) upper bound of the read window; unchanged across pages.</param>
/// <param name="numValuesPerNode">The client's per-page cap; <c>0</c> means "all values, no paging".</param>
/// <param name="ct">Cancellation (the per-request deadline).</param>
/// <param name="returnBounds">
/// Part 11 bounding values (#691, <see cref="HistoryBounds"/>): the start bound leads the first page, the end bound
/// ends the last, and both count toward <paramref name="numValuesPerNode"/>. A resumed read takes it from the cursor.
/// </param>
private async Task ServeRawPagedAsync(
NodeHandle handle,
ISession? session,
@@ -6206,7 +6220,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
DateTime startTimeUtc,
DateTime endUtc,
uint numValuesPerNode,
CancellationToken ct)
CancellationToken ct,
bool returnBounds = false)
{
var inboundCp = nodesToRead[handle.Index].ContinuationPoint;
@@ -6215,6 +6230,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
DateTime startUtc;
var boundarySkip = 0;
var walkHint = default(ReverseWalkHint); // #686/#689/#691: the walk-back's width + proven-empty stretch, page to page
HistoryEndBound? endBound = null; // #691: the end bound still owed (null = bounds not requested)
string tagname;
if (inboundCp is { Length: > 0 })
@@ -6236,6 +6252,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
endUtc = state.EndUtc;
numValuesPerNode = state.NumValuesPerNode;
walkHint = state.WalkHint;
endBound = state.PendingEndBound;
}
else
{
@@ -6261,6 +6278,78 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
else if (startUtc == DateTime.MinValue && endUtc != DateTime.MinValue)
(startUtc, endUtc) = (endUtc, new DateTime(0, DateTimeKind.Utc));
}
if (returnBounds) endBound = HistoryBounds.For(startUtc, endUtc);
}
var nowUtc = Clock.GetUtcNow().UtcDateTime;
var resolution = Math.Max(1L, HistorianDataSource.TimestampResolutionTicks);
// #691: the data is drained — the end bound ends this page when it has room (Part 11: bounds count toward
// NumValuesPerNode), else it is the whole next page. Returns the continuation point to emit, if any.
async Task<byte[]?> AppendEndBoundAsync(List<DataValueSnapshot> page, DateTime resumedAtUtc)
{
// The previous returned value stamps an open bound's placeholder (Part 11 Table 1 footnotes a/b): this
// page's last, else the last of an earlier page (carried in the cursor), else where the read resumed.
var previous = (page.Count > 0 ? page[^1].SourceTimestampUtc : null)
?? endBound!.LastReturnedUtc ?? resumedAtUtc;
if (numValuesPerNode == 0 || page.Count < numValuesPerNode)
{
page.Add(await HistoryBounds.EndBoundAsync(
HistorianDataSource, tagname, endBound!, previous, MaxTieClusterOverfetch, nowUtc, ct).ConfigureAwait(false));
return null;
}
return _historyContinuationStore.Save(session, new HistoryContinuationState(
tagname, previous, endUtc, 0, numValuesPerNode, SegmentWidthTicks: walkHint.WidthTicks,
EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks,
PendingEndBound: HistoryBounds.Carry(endBound!, page)! with { DataExhausted = true }));
}
// #691: a resumed bounded read whose data is drained (the end bound did not fit on the previous page, or the
// resume point already sits on the end time) owes only the end bound.
if (endBound is not null && inboundCp is { Length: > 0 }
&& (endBound.DataExhausted || HistoryBounds.DataWindowDrained(startUtc, endBound, resolution)))
{
var onlyBound = new List<DataValueSnapshot>(1);
var afterCp = await AppendEndBoundAsync(onlyBound, startUtc).ConfigureAwait(false);
results[handle.Index] = new SdkHistoryReadResult
{
StatusCode = StatusCodes.Good,
HistoryData = new ExtensionObject(ToHistoryDataFromSamples(onlyBound)),
ContinuationPoint = afterCp,
};
errors[handle.Index] = ServiceResult.Good;
return;
}
// #691: the start bound leads the first page and counts toward the cap; null when a sample sits on StartTime
// (that sample is the bound, and it is the data's first).
DataValueSnapshot? startBound = null;
var readCap = numValuesPerNode;
if (endBound is not null && inboundCp is not { Length: > 0 })
{
startBound = await HistoryBounds.StartBoundAsync(
HistorianDataSource, tagname, startUtc, endBound.Reverse, MaxTieClusterOverfetch, nowUtc, ct)
.ConfigureAwait(false);
if (startBound is not null && numValuesPerNode == 1)
{
// The page is the start bound alone; the data starts at StartTime on the next page.
var boundOnlyCp = _historyContinuationStore.Save(session, new HistoryContinuationState(
tagname, startUtc, endUtc, 0, numValuesPerNode, SegmentWidthTicks: walkHint.WidthTicks,
EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks,
PendingEndBound: HistoryBounds.Carry(endBound, [startBound])));
results[handle.Index] = new SdkHistoryReadResult
{
StatusCode = StatusCodes.Good,
HistoryData = new ExtensionObject(ToHistoryDataFromSamples([startBound])),
ContinuationPoint = boundOnlyCp,
};
errors[handle.Index] = ServiceResult.Good;
return;
}
if (startBound is not null && numValuesPerNode > 0) readCap = numValuesPerNode - 1;
}
// HistoryRead is NOT under the node-manager Lock — awaiting the async source is safe.
@@ -6272,8 +6361,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (startUtc > endUtc && !source.SupportsReverseRaw)
{
var page = await ReverseRawReader.ReadAsync(
source, tagname, startUtc, endUtc, numValuesPerNode, MaxTieClusterOverfetch,
Clock.GetUtcNow().UtcDateTime, walkHint, ct)
source, tagname, startUtc, endUtc, readCap, MaxTieClusterOverfetch,
nowUtc, walkHint, ct)
.ConfigureAwait(false);
walkHint = page.Hint;
sourceResult = new HistorianRead(page.Samples, ContinuationPoint: null);
@@ -6281,17 +6370,29 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
else
{
sourceResult = await source
.ReadRawAsync(tagname, startUtc, endUtc, numValuesPerNode, ct)
.ReadRawAsync(tagname, startUtc, endUtc, readCap, ct)
.ConfigureAwait(false);
}
var backendFull = HistoryPaging.IsFullPage(sourceResult.Samples.Count, numValuesPerNode);
var backendFull = HistoryPaging.IsFullPage(sourceResult.Samples.Count, readCap);
// On a resume read, drop the boundary ties already returned on the prior page.
var samples = inboundCp is { Length: > 0 }
? HistoryPaging.TrimBoundaryDuplicates(sourceResult.Samples, startUtc, boundarySkip)
: sourceResult.Samples;
// #691: a bounded read's data window excludes the end time (a sample there is the end bound). A backend whose
// forward end is inclusive (the gateway's own) returns it as data; dropping it also means the data is drained.
if (endBound is not null)
{
var inside = samples.Where(x => HistoryBounds.InDataWindow(x, endBound, resolution)).ToList();
if (inside.Count != samples.Count)
{
samples = inside;
backendFull = false;
}
}
// Oversized tie cluster: a resume read whose FULL backend page does not advance past the resume
// boundary timestamp `startUtc`. That happens when more samples share `startUtc` than the page
// cap — a tie cluster larger than NumValuesPerNode. The fixed-(start,end,cap) backend can only
@@ -6355,9 +6456,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
var clusterState = new HistoryContinuationState(
tagname, resumeAt, endUtc, nextSkip, numValuesPerNode, SegmentWidthTicks: walkHint.WidthTicks,
EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks);
EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks,
PendingEndBound: HistoryBounds.Carry(endBound, slice));
clusterCp = _historyContinuationStore.Save(session, clusterState);
}
else if (endBound is not null)
{
// #691: the data is drained — the end bound ends this page, or is the next one when this one is full.
clusterCp = await AppendEndBoundAsync(slice, startUtc).ConfigureAwait(false);
}
results[handle.Index] = new SdkHistoryReadResult
{
@@ -6371,6 +6478,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// The "full page" test is against the RAW backend count (before trimming): the backend honoured
// the cap, so a full backend page ⇒ there may be more even if we trimmed some boundary ties.
// #691: the start bound (first page only) leads the data.
if (startBound is not null)
{
var led = new List<DataValueSnapshot>(samples.Count + 2) { startBound };
led.AddRange(samples);
samples = led;
}
var historyData = ToHistoryDataFromSamples(samples);
byte[]? outboundCp = null;
@@ -6379,11 +6494,21 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
HistoryPaging.ComputeResumeCursor(samples, out var nextStart, out var skip);
var nextState = new HistoryContinuationState(
tagname, nextStart, endUtc, skip, numValuesPerNode, SegmentWidthTicks: walkHint.WidthTicks,
EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks);
EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks,
PendingEndBound: HistoryBounds.Carry(endBound, samples));
// Save may return null (no session on this request) ⇒ degrade to single-shot for this node.
// Built AFTER historyData so a failure projecting samples can never orphan a stored cursor.
outboundCp = _historyContinuationStore.Save(session, nextState);
}
else if (endBound is not null)
{
// #691: the data is drained — the end bound ends this page, or is the next one when this one is full.
var page = new List<DataValueSnapshot>(samples.Count + 1);
page.AddRange(samples);
outboundCp = await AppendEndBoundAsync(page, startUtc).ConfigureAwait(false);
samples = page;
historyData = ToHistoryDataFromSamples(samples);
}
results[handle.Index] = new SdkHistoryReadResult
{