Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseRawReader.cs
T
Joseph Doherty 5897764024
v2-ci / build (pull_request) Successful in 5m6s
v2-ci / unit-tests (pull_request) Failing after 29m54s
fix(historian): fail fast for missing tags and reduce gateway read cost
2026-10-13 04:31:52 -04:00

137 lines
9.4 KiB
C#

using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>One walked-back reverse raw page (<see cref="ReverseRawReader"/>).</summary>
/// <param name="Samples">At most the requested cap, newest first.</param>
/// <param name="WidthHintTicks">The segment width the walk ended on — the next page's starting width (0 = none).</param>
internal readonly record struct ReverseRawPage(IReadOnlyList<DataValueSnapshot> Samples, long WidthHintTicks)
{
/// <summary>Everything the next page of the same read should resume with (its width is <see cref="WidthHintTicks"/>).</summary>
public ReverseWalkHint Hint { get; init; } = new(WidthHintTicks);
}
/// <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>). The walk itself — segment sizing (#689), the
/// truncation rules, edges, ties and bounds — is <see cref="ReverseWalk"/>; this adapter supplies the raw read (a read is
/// cut short when it returned its full cap) and keeps the backend's tie order.
/// </summary>
internal static class ReverseRawReader
{
/// <summary>The first segment's width when no hint is carried.</summary>
internal static readonly TimeSpan InitialSegment = ReverseWalk.InitialSegment;
/// <summary>The walk's exclusive lower limit.</summary>
internal static readonly DateTime Floor = ReverseWalk.Floor;
/// <summary>How far past "now" the walk starts at most.</summary>
internal static readonly TimeSpan LookAhead = ReverseWalk.LookAhead;
/// <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 (one forward read).</param>
/// <param name="maxTieCluster">The tie budget (<c>MaxTieClusterOverfetch</c>; values below 1 count as 1) — a ceiling only.</param>
/// <param name="nowUtc">Now, for the look-ahead clamp.</param>
/// <param name="widthHintTicks">Starting segment width (the previous page's <see cref="ReverseRawPage.WidthHintTicks"/>), 0 = none.</param>
/// <param name="ct">Cancellation.</param>
/// <returns>The page, newest first, and the width to start the next page with.</returns>
/// <exception cref="HistoryReadLimitExceededException">One timestamp holds more than the tie budget.</exception>
public static Task<ReverseRawPage> ReadAsync(
IHistorianDataSource source, string tagname, DateTime hi, DateTime lo, uint maxValues, int maxTieCluster,
DateTime nowUtc, long widthHintTicks, CancellationToken ct) =>
ReadAsync(source, tagname, hi, lo, maxValues, maxTieCluster, nowUtc, new ReverseWalkHint(widthHintTicks), ct);
/// <summary>Reads <c>(lo, hi]</c> newest-first from forward reads, resuming with the previous page's full hint.</summary>
/// <param name="source">A backend that reads forward.</param>
/// <param name="tagname">Historian tag.</param>
/// <param name="hi">The later (inclusive) bound.</param>
/// <param name="lo">The earlier (exclusive) bound.</param>
/// <param name="maxValues">Cap; <c>0</c> = every sample in the window (one forward read).</param>
/// <param name="maxTieCluster">The tie budget — a ceiling only.</param>
/// <param name="nowUtc">Now, for the look-ahead clamp.</param>
/// <param name="hint">What the previous page carried (<see cref="ReverseRawPage.Hint"/>), default = none.</param>
/// <param name="ct">Cancellation.</param>
/// <returns>The page, newest first, and the hint for the next page.</returns>
/// <exception cref="HistoryReadLimitExceededException">One timestamp holds more than the tie budget.</exception>
/// <remarks>
/// Gitea #694: every segment read fits the backend's <see cref="IHistorianDataSource.MaxRawValuesPerRead"/> (a strict
/// gateway's learned ceiling) — the walk's tie budget is bounded by it so its segments are sized for it, and a
/// crowded re-read asking more is cut to it (and counts as truncated, never as complete). A walk that TEACHES the
/// backend its limit (the first read after start-up) and is refused for it is re-run once with segments sized for the
/// new limit — unless <paramref name="retryOnLearnedLimit"/> is false: the HistoryRead page path passes false, so the
/// refusal reaches <see cref="RawPageLimit"/>, which shrinks the PAGE to the limit as well (re-running the walk for a
/// page of N far above the limit cost thousands of segment reads). The bound lookups (a page of one) keep the re-run.
/// The page size itself is never cut here: a short answer would read as the last page. A segment refused because rows
/// just before it crowded the backend's budget (<see cref="HistoryReadLimitExceededException.PreWindowRows"/>) is
/// re-read once that many rows smaller and counts as truncated when full. An uncapped walk (one forward read of the
/// whole window) is never cut: it is answered in full or refused.
/// </remarks>
/// <param name="retryOnLearnedLimit">Re-run the walk once when it taught the backend its limit (see the remarks).</param>
public static async Task<ReverseRawPage> ReadAsync(
IHistorianDataSource source, string tagname, DateTime hi, DateTime lo, uint maxValues, int maxTieCluster,
DateTime nowUtc, ReverseWalkHint hint, CancellationToken ct, bool retryOnLearnedLimit = true)
{
var before = source.MaxRawValuesPerRead;
try
{
return await WalkAsync(before).ConfigureAwait(false);
}
catch (HistoryReadLimitExceededException) when (retryOnLearnedLimit && maxValues > 0
&& source.MaxRawValuesPerRead is var now and > 0 && now != before)
{
// fall through (outside the catch): the walk taught the backend its limit — re-run it sized for that limit once.
// Not an uncapped walk: it is one read of the whole window, which the limit does not change.
}
return await WalkAsync(source.MaxRawValuesPerRead).ConfigureAwait(false);
async Task<ReverseRawPage> WalkAsync(int limit)
{
// A segment asks for at most its budget + 2 rows (ReverseWalk), so the budget is bounded 2 under the limit.
var budget = Math.Max(1, maxTieCluster);
if (limit > 0) budget = Math.Min(budget, Math.Max(1, limit - 2));
var (rows, next) = await ReverseWalk.WalkAsync(
async (start, end, cap, token) =>
{
var asked = limit > 0 && cap > limit ? limit : cap; // cap 0 (the whole window) is never cut
IReadOnlyList<DataValueSnapshot> samples;
for (var attempt = 0; ; attempt++)
{
try
{
samples = (await source.ReadRawAsync(tagname, start, end, (uint)asked, token).ConfigureAwait(false)).Samples;
break;
}
catch (HistoryReadLimitExceededException ex) when (asked > 0 && ex.PreWindowRows > 0 && attempt < 2)
{
// #695: the discovery request may be smaller than the pre-window cluster. Size against
// the ceiling, not that request; a second pass can discover more crowding rows.
var ceilingRoom = source.MaxRawValuesPerRead is var learned and > 0 ? learned : asked;
var smaller = Math.Min(asked - 1, ceilingRoom - ex.PreWindowRows - 1);
if (smaller < 1) throw;
asked = smaller;
}
}
return new ForwardSegmentRead<DataValueSnapshot>(samples, asked > 0 && samples.Count >= asked);
},
Ticks, tieOrder: null, hi, lo, maxValues, budget, source.TimestampResolutionTicks, nowUtc, hint,
at => new HistoryReadLimitExceededException(budget,
$"A reverse raw read of '{tagname}' at {at:O} found more than {budget} samples within one millisecond; the " +
"backend reads forward only, so the latest of them cannot be selected. Increase " +
(budget < Math.Max(1, maxTieCluster)
? "the backend's row ceiling (HistorianGateway Historian:ReadLimits:MaxValuesCeiling)."
: "ServerHistorian:MaxTieClusterOverfetch.")),
ct, useDiscoveryBudgetForGaps: limit > 0)
.ConfigureAwait(false);
return new ReverseRawPage(rows, next.WidthTicks) { Hint = next };
}
}
private static long Ticks(DataValueSnapshot s) => (s.SourceTimestampUtc ?? DateTime.MinValue).Ticks;
}