fix(historian): #691.4 walk reverse events reads back on forward-only backends (Gateway)

A reverse or end-only capped events read on a backend without
SupportsReverseEvents (Gateway, Null) was refused
BadHistoryOperationUnsupported (explicit reverse) or answered with the
OLDEST N ascending (end-only, #675). It is now served newest-first by
the same ReverseWalk as raw reads: page-sized ms-aligned segments,
never answered from a read the source flagged truncated, ties sorted by
EventId (the gateway orders by event time only - the fake shuffles
them per call), one extra event walked to signal 'more', uncapped = one
forward read reversed, and the width + proven-empty stretch carried in
the events cursor. The oversized-cluster over-fetch keeps exactly the
events at T in the same order. A tie cluster over the budget is
BadTooManyOperations. Docs note the gateway's EventReadMaxRows must be
unlimited (it truncates silently).
This commit is contained in:
Joseph Doherty
2026-10-12 16:48:05 -04:00
parent 186b7d5ea5
commit d277f34a96
9 changed files with 251 additions and 82 deletions
@@ -5248,6 +5248,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
var endUtc = details.EndTime;
var numValuesPerNode = details.NumValuesPerNode;
var boundarySkip = 0;
var walkHint = default(ReverseWalkHint); // #691.4: a walked-back read's width + proven-empty stretch
bool reverse;
if (resumed)
@@ -5271,35 +5272,30 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// The direction is the CURSOR's (Gitea #675), never re-derived from the resume request's details: a
// reverse read resumes reverse, a forward one forward.
reverse = state.Reverse;
walkHint = state.WalkHint;
}
else
{
// Part 11: with one bound unset, 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 the read would
// look reversed. An unset StartTime with an EndTime and a count cap means "page BACKWARD from EndTime"
// (#675, mirroring raw): the reverse window (MinValue, EndTime] — but only on a backend that can read
// newest-first. Elsewhere (Gateway, the default backend) and without a count cap an end-only read keeps its
// historical forward [MinValue, EndTime) meaning rather than becoming a refused reverse read.
// (#675, mirroring raw): the reverse window (MinValue, EndTime] — on every backend since #691.4 (a backend
// that cannot read newest-first is walked back below). Without a count cap an end-only read keeps its
// forward [MinValue, EndTime) meaning.
if (endUtc == DateTime.MinValue && startUtc != DateTime.MinValue)
endUtc = new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc); // Utc kind: a proto Timestamp conversion rejects Unspecified
else if (numValuesPerNode > 0 && startUtc == DateTime.MinValue && endUtc != DateTime.MinValue
&& source.SupportsReverseEvents)
else if (numValuesPerNode > 0 && startUtc == DateTime.MinValue && endUtc != DateTime.MinValue)
(startUtc, endUtc) = (endUtc, new DateTime(0, DateTimeKind.Utc));
// Part 11: StartTime later than EndTime is a reverse read — events newest-first over (end, start].
reverse = startUtc > endUtc;
}
// An explicit reverse read (or a reverse cursor resumed after the source was swapped) on a backend that cannot
// read newest-first: refuse it BEFORE calling the source — never the earliest events in ascending order as if
// they were the answer. Checked here rather than by catching an exception, so a NotSupportedException thrown
// from inside a backend (a driver library, the projection) stays a backend fault.
if (reverse && !source.SupportsReverseEvents)
{
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
return;
}
// A reverse read on a backend that cannot read newest-first (Gateway, Null) is never sent there (#675 refused it
// here); since #691.4 it is walked back from bounded forward reads — the raw walk (#686), with ties ordered by
// EventId because the gateway orders by event time only. Decided by the capability, not by catching an
// exception, so a NotSupportedException thrown from inside a backend stays a backend fault.
var walked = reverse && !source.SupportsReverseEvents;
// 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
@@ -5309,8 +5305,39 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
var fetch = EventMaxEvents(numValuesPerNode);
if (numValuesPerNode > 0 && boundarySkip > 0)
fetch = (int)Math.Min(int.MaxValue - 1L, (long)fetch + boundarySkip);
var sourceResult = await source.ReadEventsAsync(
sourceName, startUtc, endUtc, fetch, reverse, ct).ConfigureAwait(false);
HistoricalEventsResult sourceResult;
if (walked)
{
// Walk for one more than the fetch: finding it is the "more events exist" signal a source gives with its
// continuation point (the walk itself cannot flag truncation).
var want = numValuesPerNode == 0 ? 0u : (uint)Math.Min(uint.MaxValue - 1L, (long)fetch + 1);
var budget = Math.Max(1, MaxTieClusterOverfetch);
var walkSource = sourceName;
var (rows, next) = await ReverseWalk.WalkAsync<HistoricalEvent>(
async (s, e, cap, token) =>
{
var r = await source.ReadEventsAsync(walkSource, s, e, cap == 0 ? int.MaxValue : cap, reverse: false, token)
.ConfigureAwait(false);
return new ForwardSegmentRead<HistoricalEvent>(r.Events, r.ContinuationPoint is not null);
},
ev => ev.EventTimeUtc.Ticks, EventIdTieOrder.Instance, startUtc, endUtc, want, budget,
source.TimestampResolutionTicks, Clock.GetUtcNow().UtcDateTime, walkHint,
at => new HistoryReadLimitExceededException(budget,
$"A reverse events read of '{walkSource}' at {at:O} found more than {budget} events at one " +
"timestamp; the backend reads forward only, so the latest of them cannot be selected. Increase " +
"ServerHistorian:MaxTieClusterOverfetch."),
ct)
.ConfigureAwait(false);
walkHint = next;
var more = want > 0 && rows.Count > fetch;
if (more) rows.RemoveRange(fetch, rows.Count - fetch);
sourceResult = new HistoricalEventsResult(rows, more ? [0x01] : null);
}
else
{
sourceResult = await source.ReadEventsAsync(
sourceName, startUtc, endUtc, fetch, reverse, ct).ConfigureAwait(false);
}
// Truncation is the SOURCE's word (non-null continuation point) — never inferred from the count, so a
// page that happens to be exactly cap-sized but complete never emits a point.
@@ -5350,6 +5377,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
sourceName, startUtc, startUtc.AddTicks(resolution), overfetchCap, reverse: false, ct)
.ConfigureAwait(false)).Events;
// A walked read (#691.4) emitted its ties sorted by EventId; the gateway returns them in any order and its
// end is inclusive — keep exactly the cluster, in the walk's order, so the skip indexes the same events.
if (walked)
cluster = cluster.Where(e => e.EventTimeUtc == startUtc)
.OrderBy(e => e, EventIdTieOrder.Instance).ToList();
// The over-fetch reads the cluster FORWARD (ascending EventId), but a reverse read emitted its ties — and
// counted boundarySkip — in descending order. Slice in the read's own direction, as raw does.
if (reverse) cluster = cluster.Reverse().ToList();
@@ -5378,7 +5411,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
pendingCursor = new HistoryContinuationState(
sourceName ?? string.Empty, resumeAt, endUtc, nextSkip, numValuesPerNode, HistoryCursorKind.Events,
Reverse: reverse);
Reverse: reverse, SegmentWidthTicks: walkHint.WidthTicks, EmptyFromTicks: walkHint.EmptyFromTicks,
EmptyToTicks: walkHint.EmptyToTicks);
}
}
else if (truncated && events.Count > 0)
@@ -5390,7 +5424,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
for (var i = events.Count - 1; i >= 0 && events[i].EventTimeUtc == boundary; i--) skip++;
pendingCursor = new HistoryContinuationState(
sourceName ?? string.Empty, boundary, endUtc, skip, numValuesPerNode, HistoryCursorKind.Events,
Reverse: reverse);
Reverse: reverse, SegmentWidthTicks: walkHint.WidthTicks, EmptyFromTicks: walkHint.EmptyFromTicks,
EmptyToTicks: walkHint.EmptyToTicks);
}
var historyEvent = ProjectEvents(events, selectClauses);
@@ -5411,6 +5446,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
errors[handle.Index] = StatusCodes.BadTimeout;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout };
}
catch (HistoryReadLimitExceededException ex)
{
// A request limit (#691.4: a walked-back reverse read met a tie cluster over the budget) — BadTooManyOperations
// for THIS node, as on the raw path; not a backend fault.
_logger.LogWarning("OtOpcUaNodeManager: HistoryReadEvents limit exceeded for node {NodeId}: {Message}", handle.NodeId, ex.Message);
errors[handle.Index] = StatusCodes.BadTooManyOperations;
results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTooManyOperations };
}
catch (Exception ex)
{
// One node's backend failure must not throw out of the batch — surface Bad for THIS node
@@ -1,3 +1,5 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>What one walked-back page tells the next page of the same read (carried in the continuation point).</summary>
@@ -364,3 +366,16 @@ internal static class ReverseWalk
return down > DateTime.MaxValue.Ticks - step ? DateTime.MaxValue.Ticks : down + step;
}
}
/// <summary>
/// Orders events within one timestamp by <see cref="HistoricalEvent.EventId"/> (ordinal) — the tie order a walked-back
/// events read (#691.4) and its oversized-cluster over-fetch share, because the Gateway orders by event time only.
/// </summary>
internal sealed class EventIdTieOrder : IComparer<HistoricalEvent>
{
/// <summary>The shared instance.</summary>
public static readonly EventIdTieOrder Instance = new();
/// <inheritdoc />
public int Compare(HistoricalEvent? x, HistoricalEvent? y) => string.CompareOrdinal(x?.EventId, y?.EventId);
}