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