fix(historian): #686 serve reverse/end-only raw reads on forward-only backends (Gateway)

#669 turned an end-only capped HistoryRead-Raw into a reverse read (start > end)
for every backend; the gateway rejects start > end (InvalidArgument), so on the
default Gateway backend every end-only capped and every explicit reverse raw read
failed the node and was counted as a backend health failure.

- IHistorianDataSource.SupportsReverseRaw (default false; SqlServer/QuestDB/Null
  true, Gateway false; guard test pins that every production backend declares it).
- OtOpcUaNodeManager sends a reverse window only to a backend that declares it;
  otherwise ReverseRawReader walks (lo, hi] back in bounded forward segment reads
  (1 min, x4 while sparse; each <= MaxTieClusterOverfetch + 1 rows; a full read is
  re-read narrower, never answered truncated; filtered on sample timestamps, which
  also drops AVEVA's fabricated query-start rows; stops at the Unix epoch). A
  one-timestamp tie cluster over the bound => BadTooManyOperations.
- ServeRawPagedAsync maps HistoryReadLimitExceededException to BadTooManyOperations.
- GatewayHistorianDataSource.ReadRawAsync refuses start > end unsent, without
  recording a health failure.
This commit is contained in:
Joseph Doherty
2026-10-12 14:42:27 -04:00
parent 70fb1f1fda
commit 410c8d8976
11 changed files with 490 additions and 3 deletions
@@ -17,6 +17,12 @@ public interface IHistorianDataSource : IDisposable
/// <summary>
/// Read raw historical samples for a single tag over a time range.
/// </summary>
/// <remarks>
/// Forward (<c>startUtc &lt; endUtc</c>) is <c>[start, end)</c> ascending; <c>startUtc == endUtc</c> is a point read.
/// <c>startUtc &gt; endUtc</c> is the Part 11 reverse read <c>(end, start]</c> newest-first — but only a backend that
/// declares <see cref="SupportsReverseRaw"/> is ever sent one (Gitea #686); the server serves a reverse read on any
/// other backend from bounded forward reads.
/// </remarks>
/// <param name="fullReference">The full reference of the tag to read.</param>
/// <param name="startUtc">The start of the time range in UTC.</param>
/// <param name="endUtc">The end of the time range in UTC.</param>
@@ -30,6 +36,16 @@ public interface IHistorianDataSource : IDisposable
uint maxValuesPerNode,
CancellationToken cancellationToken);
/// <summary>
/// Whether this backend can serve a reverse (newest-first) raw read itself — <see cref="ReadRawAsync"/> with
/// <c>startUtc &gt; endUtc</c> (Gitea #686). The server checks it before every raw read. When <c>false</c> it never
/// sends <c>start &gt; end</c>: it serves the reverse read — an explicit one, a resumed reverse page, and the
/// end-only capped read Part 11 reads backward from EndTime — from a series of bounded FORWARD reads walking back
/// from the later bound (<c>ReverseRawReader</c> in the OPC UA server). Default <c>false</c>, the safe direction: a
/// source that only reads forward is still answered correctly. A backend declares it itself (a guard test pins that).
/// </summary>
bool SupportsReverseRaw => false;
/// <summary>
/// Read processed (interval-bucketed) samples — average / min / max / count / etc.
/// A bucket with no source data returns a sample whose
@@ -55,6 +55,11 @@ public sealed class NullHistorianDataSource : IHistorianDataSource
int maxEvents,
CancellationToken cancellationToken) => Task.FromResult(EmptyEvents);
/// <inheritdoc />
/// <remarks><c>true</c> (Gitea #686): there is no history, so the empty result is the correct answer in either
/// direction — no need for the server to walk back through forward reads to find nothing.</remarks>
public bool SupportsReverseRaw => true;
/// <inheritdoc />
/// <remarks><c>false</c> (Gitea #675): there is no history to read in either direction, and an explicit reverse read
/// is answered <c>BadHistoryOperationUnsupported</c> like any backend that cannot read newest-first.</remarks>
@@ -76,10 +76,18 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
}
/// <inheritdoc />
/// <remarks>
/// A reverse window (<c>start &gt; end</c>) is refused with <see cref="NotSupportedException"/> before anything is sent
/// and is not recorded as a health failure (Gitea #686): the gateway rejects it <c>InvalidArgument</c>, and the server
/// never asks — <see cref="SupportsReverseRaw"/> is <c>false</c>, so it walks a reverse read back with forward reads.
/// </remarks>
public async Task<HistoryReadResult> ReadRawAsync(
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
CancellationToken cancellationToken)
{
if (startUtc > endUtc)
throw new NotSupportedException("The HistorianGateway reads raw history oldest-first only (start > end).");
try
{
// The gateway seam caps with an int; OPC UA hands us a uint, so clamp to int range.
@@ -235,6 +243,14 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
}
}
/// <inheritdoc />
/// <remarks>
/// <c>false</c> (Gitea #686): the gateway's <c>ReadRaw</c> streams ascending only and rejects <c>end &lt; start</c>
/// (<c>InvalidArgument</c>). The server serves reverse and end-only capped raw reads from bounded forward reads
/// instead, and never sends this source a reverse window (<see cref="ReadRawAsync"/> refuses one unsent).
/// </remarks>
public bool SupportsReverseRaw => false;
/// <inheritdoc />
/// <remarks>
/// <c>false</c>: the gateway's <c>ReadEvents</c> streams ascending only (Gitea #675). The server therefore refuses an
@@ -355,6 +355,10 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
// ------------------------------------------------------------------------------------------ Events
/// <inheritdoc />
/// <remarks>Reverse raw reads are served natively (<c>(end, start]</c>, <c>ORDER BY … DESC</c>; #669/#686).</remarks>
public bool SupportsReverseRaw => true;
/// <inheritdoc />
/// <remarks>Reverse events reads are served natively (<c>ORDER BY … DESC</c>, Gitea #675).</remarks>
public bool SupportsReverseEvents => true;
@@ -312,6 +312,10 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
// ------------------------------------------------------------------------------------------ Events
/// <inheritdoc />
/// <remarks>Reverse raw reads are served natively (<c>(end, start]</c>, <c>ORDER BY … DESC</c>; #669/#686).</remarks>
public bool SupportsReverseRaw => true;
/// <inheritdoc />
/// <remarks>Reverse events reads are served natively (<c>ORDER BY … DESC</c>, Gitea #675).</remarks>
public bool SupportsReverseEvents => true;
@@ -6219,6 +6219,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// Part 11: with NumValuesPerNode > 0 one bound may be left unset. Only a StartTime ⇒ page FORWARD from
// it to an open upper bound; only an EndTime ⇒ page BACKWARD from it (a reverse read, start > end).
// Without this an unset EndTime (MinValue) looked like a reverse read to a lower bound of "now".
// Every backend serves the backward read (#686): natively when it declares SupportsReverseRaw, otherwise
// walked back from bounded forward reads below — unlike the events path, which keeps it forward there.
if (numValuesPerNode > 0)
{
if (endUtc == DateTime.MinValue && startUtc != DateTime.MinValue)
@@ -6229,9 +6231,20 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
// HistoryRead is NOT under the node-manager Lock — awaiting the async source is safe.
var sourceResult = await HistorianDataSource
.ReadRawAsync(tagname, startUtc, endUtc, numValuesPerNode, ct)
.ConfigureAwait(false);
// A reverse window (explicit, resumed, or the end-only read above) goes to the backend only when it declares it
// can read newest-first; otherwise it is walked back from bounded forward reads (#686 — the Gateway rejects
// start > end, which failed the node and counted as a backend fault).
var source = HistorianDataSource;
var sourceResult = startUtc > endUtc && !source.SupportsReverseRaw
? new HistorianRead(
await ReverseRawReader.ReadAsync(
source, tagname, startUtc, endUtc, numValuesPerNode,
(int)Math.Min(int.MaxValue, (long)Math.Max(1, MaxTieClusterOverfetch) + 1), ct)
.ConfigureAwait(false),
ContinuationPoint: null)
: await source
.ReadRawAsync(tagname, startUtc, endUtc, numValuesPerNode, ct)
.ConfigureAwait(false);
var backendFull = HistoryPaging.IsFullPage(sourceResult.Samples.Count, numValuesPerNode);
@@ -6348,6 +6361,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 (#686: a reverse read walked back from forward reads met a tie cluster over the row
// budget) — BadTooManyOperations for THIS node, as on the processed path; not a backend fault.
_logger.LogWarning("OtOpcUaNodeManager: HistoryReadRaw 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 — Bad for THIS node only.
@@ -0,0 +1,120 @@
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>
/// Gitea #686: serves a Part 11 reverse raw read — <c>(lo, hi]</c> newest-first, capped — on a historian backend that can
/// only read forward (<see cref="IHistorianDataSource.SupportsReverseRaw"/> = <c>false</c>: the Gateway, whose
/// <c>ReadRaw</c> streams ascending and rejects <c>start &gt; end</c>).
/// </summary>
/// <remarks>
/// <para>
/// <b>Walk back in segments.</b> Starting at <c>hi</c>, read one segment <c>(segLo, segHi]</c> FORWARD, keep it
/// newest-first, and step to the next older segment until the cap is met or <c>lo</c> is reached. A segment starts
/// at <see cref="InitialSegment"/> and grows ×4 while segments come back sparse, so "the latest N" of a live tag is
/// usually one read, and sparse history decades back is found in a few dozen tiny reads rather than one read of
/// all history.
/// </para>
/// <para>
/// <b>Never answer from a truncated read.</b> Each segment read asks for at most <c>segmentCap</c> rows (the server's
/// <c>MaxTieClusterOverfetch + 1</c> over-fetch bound). A read that comes back FULL may have been cut short — its
/// newest rows are exactly the ones a reverse read needs — so it is discarded and the segment re-read at a quarter of
/// the width. A full read of a segment already one timestamp-resolution wide (a single-timestamp tie cluster larger
/// than the bound) cannot be served and throws <see cref="HistoryReadLimitExceededException"/>
/// (⇒ <c>BadTooManyOperations</c>, a request limit, never a backend fault).
/// </para>
/// <para>
/// <b>Edges, for any forward contract.</b> A segment is requested as <c>[segLo, segHi + resolution]</c> and filtered to
/// <c>(segLo, segHi]</c> on the samples' own timestamps, so it is exact whether the backend's forward end is exclusive
/// (the interface contract) or inclusive (the gateway), and whether it truncates its bounds (QuestDB-style). The
/// filter also drops the rows an AVEVA <c>Full</c> read fabricates at the query start (the boundary / no-data row,
/// #687) and at the query end. Adjacent segments share no timestamp, so nothing is returned twice.
/// </para>
/// <para>
/// <b>Ties</b> come back in the backend's forward order reversed — the same order the node manager's tie-cluster
/// over-fetch uses for a reverse read — so a resumed page's boundary-tie skip lines up.
/// </para>
/// <para>
/// <b>Floor.</b> The walk stops at <see cref="Floor"/> (the Unix epoch; its earliest request bound is one tick
/// before it): the AVEVA historian holds nothing earlier, and an end-only read's open lower bound (year 1) would
/// otherwise cost dozens of empty reads and send a bound outside the historian's time encoding. Samples before the
/// floor are not returned by a walked-back read; a window entirely before it is empty.
/// </para>
/// </remarks>
internal static class ReverseRawReader
{
/// <summary>The first segment's width.</summary>
internal static readonly TimeSpan InitialSegment = TimeSpan.FromMinutes(1);
/// <summary>The earliest instant a walk reads (see the remarks).</summary>
internal static readonly DateTime Floor = DateTime.UnixEpoch;
/// <summary>Reads <c>(lo, hi]</c> newest-first from forward reads.</summary>
/// <param name="source">A backend that reads forward.</param>
/// <param name="tagname">Historian tag.</param>
/// <param name="hi">The later (inclusive) bound — the request's StartTime or a reverse resume point.</param>
/// <param name="lo">The earlier (exclusive) bound.</param>
/// <param name="maxValues">Cap; <c>0</c> = every sample in the window.</param>
/// <param name="segmentCap">Rows one segment read may return before it is treated as truncated (≥ 2).</param>
/// <param name="ct">Cancellation.</param>
/// <returns>At most <paramref name="maxValues"/> samples, newest first.</returns>
/// <exception cref="HistoryReadLimitExceededException">A single-timestamp tie cluster exceeds the segment cap.</exception>
public static async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IHistorianDataSource source, string tagname, DateTime hi, DateTime lo, uint maxValues, int segmentCap,
CancellationToken ct)
{
var resolution = Math.Max(1L, source.TimestampResolutionTicks);
var cap = (uint)Math.Max(2, segmentCap);
var result = new List<DataValueSnapshot>();
var width = Math.Max(resolution, InitialSegment.Ticks);
var segHiTicks = hi.Ticks;
// The walk's exclusive lower edge. Clamped to one tick before the floor, so a sample exactly at the floor is still
// in the window (and the row a backend fabricates at that query start is still filtered out).
var stopTicks = lo.Ticks < Floor.Ticks ? Floor.Ticks - 1 : lo.Ticks;
while (segHiTicks > stopTicks && (maxValues == 0 || result.Count < maxValues))
{
ct.ThrowIfCancellationRequested();
var segLoTicks = segHiTicks - stopTicks <= width ? stopTicks : segHiTicks - width;
var requestEndTicks = segHiTicks > DateTime.MaxValue.Ticks - resolution
? DateTime.MaxValue.Ticks
: segHiTicks + resolution;
var rows = (await source.ReadRawAsync(
tagname,
new DateTime(segLoTicks, DateTimeKind.Utc),
new DateTime(requestEndTicks, DateTimeKind.Utc),
cap, ct)
.ConfigureAwait(false)).Samples;
if (rows.Count >= cap)
{
// Possibly truncated: its newest rows — the ones needed — may be missing. Narrow and re-read.
if (width <= resolution)
throw new HistoryReadLimitExceededException(cap - 1,
$"A reverse raw read of '{tagname}' at {new DateTime(segHiTicks, DateTimeKind.Utc):O} found more than " +
$"{cap - 1} samples at one timestamp; the backend reads forward only, so the latest of them cannot be " +
"selected. Increase ServerHistorian:MaxTieClusterOverfetch.");
width = Math.Max(resolution, width / 4);
continue;
}
var inSegment = 0;
for (var i = rows.Count - 1; i >= 0; i--)
{
var ts = (rows[i].SourceTimestampUtc ?? DateTime.MinValue).Ticks;
if (ts <= segLoTicks || ts > segHiTicks) continue;
inSegment++;
if (maxValues == 0 || result.Count < maxValues) result.Add(rows[i]);
}
// Sparse ⇒ widen; otherwise keep the width that fit.
if (inSegment <= cap / 8)
width = width > DateTime.MaxValue.Ticks / 4 ? DateTime.MaxValue.Ticks : width * 4;
segHiTicks = segLoTicks;
}
return result;
}
}