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:
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user