fix(opcua): event paging review fixes (#669): open-ended end, node-bound cursors, save after projection

This commit is contained in:
Joseph Doherty
2026-10-11 08:30:36 -04:00
parent f9fd495292
commit acce99de9a
2 changed files with 158 additions and 24 deletions
@@ -5155,7 +5155,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// Single-use take. A miss (unknown / evicted / malformed / a RAW cursor) ⇒ BadContinuationPointInvalid
// for THIS node only.
var state = _historyContinuationStore.TryTake(session, inboundCp!);
if (state is null || !state.IsEvents)
// A point is bound to the node that issued it: another node's source name is a miss too.
if (state is null || !state.IsEvents || state.Tagname != (sourceName ?? string.Empty))
{
errors[handle.Index] = StatusCodes.BadContinuationPointInvalid;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadContinuationPointInvalid };
@@ -5169,8 +5170,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
numValuesPerNode = state.NumValuesPerNode;
}
// Part 11: an unset EndTime (MinValue) with a StartTime means "page forward from StartTime". Normalise it
// to an open upper bound, or the cursor would store MinValue and a backend would swap the reversed
// resume window. A resumed read is always forward (the cursor is only ever stored for a forward read),
// so its direction comes from the cursor, not from the resume request's details.
if (!resumed && endUtc == DateTime.MinValue && startUtc != DateTime.MinValue) endUtc = DateTime.MaxValue;
// Reverse event reads (start > end) are not paged: the cursor below is forward-only.
var forward = !(details.EndTime != DateTime.MinValue && details.StartTime > details.EndTime);
var forward = resumed || startUtc <= endUtc;
// NOT under the node-manager Lock — awaiting the async source is safe here.
// NumValuesPerNode==0 means "no limit — return ALL values" per OPC UA Part 4/11, so it is translated
@@ -5191,7 +5197,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (i > 0) events = events.Skip(i).ToList();
}
byte[]? outboundCp = null;
// The cursor to store, if any. Saved only AFTER the page is projected, so a projection throw can
// never orphan a stored cursor.
HistoryContinuationState? pendingCursor = null;
if (truncated && resumed
&& (events.Count == 0 || events[^1].EventTimeUtc == startUtc))
@@ -5227,8 +5235,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
if (nextStartUtc is { } resumeAt)
{
outboundCp = _historyContinuationStore.Save(session, new HistoryContinuationState(
sourceName ?? string.Empty, resumeAt, endUtc, nextSkip, numValuesPerNode, IsEvents: true));
pendingCursor = new HistoryContinuationState(
sourceName ?? string.Empty, resumeAt, endUtc, nextSkip, numValuesPerNode, IsEvents: true);
}
}
else if (truncated && events.Count > 0)
@@ -5237,11 +5245,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
var boundary = events[^1].EventTimeUtc;
var skip = 0;
for (var i = events.Count - 1; i >= 0 && events[i].EventTimeUtc == boundary; i--) skip++;
outboundCp = _historyContinuationStore.Save(session, new HistoryContinuationState(
sourceName ?? string.Empty, boundary, endUtc, skip, numValuesPerNode, IsEvents: true));
pendingCursor = new HistoryContinuationState(
sourceName ?? string.Empty, boundary, endUtc, skip, numValuesPerNode, IsEvents: true);
}
var historyEvent = ProjectEvents(events, selectClauses);
var outboundCp = pendingCursor is null ? null : _historyContinuationStore.Save(session, pendingCursor);
results[handle.Index] = new SdkHistoryReadResult
{
// No events ⇒ GoodNoData (the notifier is historized, the window just held no events).
@@ -5932,7 +5941,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// Resume read: take the stored cursor. A miss (unknown / evicted / malformed point) ⇒
// BadContinuationPointInvalid for THIS node.
var state = _historyContinuationStore.TryTake(session, inboundCp);
if (state is null || state.IsEvents)
if (state is null || state.IsEvents
|| !TryGetHistorizedTagname(handle.NodeId, out var boundTag) || boundTag != state.Tagname)
{
errors[handle.Index] = StatusCodes.BadContinuationPointInvalid;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadContinuationPointInvalid };