using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// What one walked-back page tells the next page of the same read (carried in the continuation point).
/// The segment width the walk ended on, aimed at the page's density — the next page's first width (0 = none).
///
/// With : a stretch (EmptyFrom, EmptyTo) (both exclusive) a complete segment read
/// proved holds no row (#691) — typically from the oldest row the page saw down to the walk's floor. A resumed page
/// steps straight over it instead of re-walking it. Both 0 = none.
///
/// The exclusive upper end of the empty stretch.
internal readonly record struct ReverseWalkHint(long WidthTicks, long EmptyFromTicks = 0, long EmptyToTicks = 0)
{
/// Whether an empty stretch is carried.
public bool HasEmptyStretch => EmptyToTicks > EmptyFromTicks && EmptyToTicks > 0;
}
/// One forward read of a segment: the rows (ascending by time) and whether a cap cut the read short.
/// Row type.
/// Rows in ascending time order (ties in any order).
/// A cap stopped the read — more rows may follow the last one.
internal readonly record struct ForwardSegmentRead(IReadOnlyList Rows, bool Truncated);
///
/// Gitea #686/#689: the walk-back that serves a Part 11 reverse read — (lo, hi] newest-first, capped — from a
/// backend that can only read FORWARD. Shared by raw values () and events; the row type
/// only has to expose a timestamp.
///
///
///
/// Uncapped (want = 0): ONE forward read of the whole window, filtered and reversed in memory.
///
///
/// Capped: walk back in segments. Starting at hi, read one segment (segLo, segHi] FORWARD, keep
/// it newest-first, and step to the next older segment until the cap is met or lo is reached. The first
/// segment is wide, or the width the previous page of the same read ended on (carried in
/// the continuation point). After each read the next width is re-aimed from the density just observed at about half
/// the PAGE budget 2N + 16 (≈ N + 8 rows), growing at most ×4 per step (#689 — it used to aim at half the tie
/// budget, ~32k rows, whatever N was) — so a live tag's "latest N" is usually one read of about N rows, and sparse
/// history is found in a few dozen tiny reads.
///
///
/// Read sizes. A segment read asks for its budget + 2 rows (room for a row an AVEVA read fabricates at its query
/// start, and one to see past the segment). The budget is the page budget, except a discovery segment — the
/// first of a page with no carried width, or one after an empty segment (whose ×4 growth may overshoot into dense
/// data) — which may read up to . The tie budget M = max(1, MaxTieClusterOverfetch)
/// is only a CEILING: a single timestamp holding more rows than the segment budget escalates to M.
///
///
/// Never answer from a truncated read. Rows stream ascending, so a read is complete for its segment when it was
/// not cut short OR its last row is already past segHi. An incomplete read is discarded: when rows BEFORE the
/// segment (a neighbouring tie cluster inside the alignment slack, a fabricated row) crowded it out it is re-read once
/// with room for them (at most twice the budget + 4); otherwise the segment is narrowed — to the density the truncated
/// read showed, and never again below the newest row it saw (known: rows exist there, so walking past it before
/// finding what lies above it would re-read the same dense stretch over and over; the gap above it is bisected
/// instead). Only more than M rows within one millisecond — a segment no wider than the 1 ms request
/// granularity that still does not fit at the tie budget — is refused (per millisecond, not per timestamp: narrower
/// requests are the same request, and the Gateway stores milliseconds).
///
///
/// Proving the rest empty (#691). Walking down to the floor through empty history used to take one read per ×4
/// growth step (~14 reads from a minute to the epoch) — on every page that ran out of rows. After two consecutive empty
/// segments the walk asks once per page for the WHOLE remainder (lo, segHi] at the page budget: a complete
/// answer ends the walk in one read; a truncated one (deep history) is discarded and the walk carries on as before. And
/// the stretch a page's last complete segment proved empty below its oldest row rides the continuation point
/// (), so the next page steps over it — the rows of a page are often the
/// last ones before a stretch the previous page already read.
///
///
/// Edges. A segment is requested as [⌊segLo⌋, ⌈segHi + 1 tick⌉], both rounded outward to the millisecond
/// (the gateway's / AVEVA's resolution), and filtered to (segLo, segHi] on the rows' own timestamps — exact
/// whether the backend's forward end is exclusive or inclusive and whether it truncates its bounds; a row fabricated at
/// the (millisecond) query start can never land inside the segment (#687). Adjacent segments share no timestamp.
///
///
/// Ties come back in the backend's forward order reversed, or — with a tie order given (events, whose gateway
/// order within a timestamp is arbitrary) — sorted by it and reversed, so a resumed page's boundary-tie skip lines up.
///
///
/// Bounds. The walk never asks for data after now + nor before (the
/// Unix epoch — AVEVA holds nothing earlier). A walked-back read returns only rows strictly after the epoch and no
/// later than now + one day.
///
///
internal static class ReverseWalk
{
/// The first segment's width when no hint is carried.
internal static readonly TimeSpan InitialSegment = TimeSpan.FromMinutes(1);
/// The walk's exclusive lower limit.
internal static readonly DateTime Floor = DateTime.UnixEpoch;
/// How far past "now" the walk starts at most.
internal static readonly TimeSpan LookAhead = TimeSpan.FromDays(1);
/// Slack added to a page's row budget (#689): room for density variation between segments.
internal const int PageSlack = 16;
/// The most rows a discovery segment (see the remarks) may read before it counts as truncated (#689).
internal const int DiscoveryRows = 1024;
/// Request bounds are rounded outward to this (the gateway / AVEVA timestamp resolution).
private const long Alignment = TimeSpan.TicksPerMillisecond;
/// The rows a segment is sized for: 2N + , never above the tie budget.
/// The page cap N (> 0).
/// The tie budget M (>= 1).
/// The page budget.
internal static int PageBudget(uint want, int tieBudget) => (int)Math.Clamp(2L * want + PageSlack, 1L, Math.Max(1, tieBudget));
/// Reads (lo, hi] newest-first from forward reads.
/// Row type.
/// One forward read [start, end] asking for at most cap rows (0 = all).
/// A row's timestamp, in ticks.
/// Order within one timestamp (null = the backend's forward order).
/// The later (inclusive) bound.
/// The earlier (exclusive) bound.
/// Cap; 0 = every row in the window (one forward read).
/// The tie budget M (values below 1 count as 1).
/// The backend's timestamp resolution (>= 1).
/// Now, for the look-ahead clamp.
/// What the previous page of the same read carried (default = none).
/// Builds the exception for a timestamp holding more than M rows.
/// Cancellation.
/// Use ceiling-sized discovery and retain truncated remainder rows (#696, strict raw backends only).
/// At most rows, newest first, and the hint for the next page.
public static async Task<(List Rows, ReverseWalkHint Next)> WalkAsync(
Func>> read,
Func ticksOf,
IComparer? tieOrder,
DateTime hi, DateTime lo, uint want, int tieBudget, long resolutionTicks, DateTime nowUtc,
ReverseWalkHint hint, Func tooManyAtOneTimestamp, CancellationToken ct, bool useDiscoveryBudgetForGaps = false)
{
var resolution = Math.Max(1L, resolutionTicks);
var granularity = Math.Max(Alignment, resolution);
var budget = Math.Max(1, tieBudget);
var result = new List();
var horizon = nowUtc.Ticks > DateTime.MaxValue.Ticks - LookAhead.Ticks ? DateTime.MaxValue.Ticks : nowUtc.Ticks + LookAhead.Ticks;
var hiTicks = Math.Min(hi.Ticks, horizon);
var stopTicks = Math.Max(lo.Ticks, Floor.Ticks);
if (hiTicks <= stopTicks) return (result, hint);
if (want == 0)
{
// One forward read of the whole window, reversed — the uncapped forward read's own cost and bound.
var all = (await read(Utc(AlignDown(stopTicks, granularity)), Utc(AlignUp(hiTicks + 1, granularity)), 0, ct)
.ConfigureAwait(false)).Rows;
AppendNewestFirst(all, ticksOf, tieOrder, stopTicks, hiTicks, result, int.MaxValue);
return (result, hint);
}
var maxWidth = DateTime.MaxValue.Ticks;
var discovering = hint.WidthTicks <= 0;
var width = discovering ? Math.Max(resolution, InitialSegment.Ticks) : Math.Clamp(hint.WidthTicks, resolution, maxWidth);
var pageBudget = PageBudget(want, budget);
var discoveryBudget = Math.Min(budget, Math.Max(pageBudget, DiscoveryRows));
// `known` > 0: a truncated read saw rows up to this timestamp (below segHi), and nothing is known of (known, segHi].
// Segments stay above it until that stretch is read; then `knownWidth` (the density the truncated read showed)
// sizes the segment that ends on it. Searching the gap: first a top-anchored segment at that density (data running
// right up to segHi); if it is empty, the data stops somewhere above `known`, so GALLOP up from it — read
// (known + lowerBy, segHi] with lowerBy growing ×4, `known` following what each truncated read saw — until a read
// comes back empty (the top is now bracketed below its segLo), then BISECT that bracket. Any complete read holding
// rows ends the search. A burst that ended long before segHi is found in a few reads instead of bisecting the whole
// gap down to the millisecond, and a long dense stretch is crossed geometrically, not a budget at a time (#691.4
// review). `noGallop` falls back to plain top-anchored halving if a galloping read is crowded by rows before it.
var known = 0L;
var knownWidth = 0L;
var lowerBy = 0L;
var bisecting = false;
var noGallop = false;
var segHi = hiTicks;
// #691: the remainder probe (once per page, after two empty segments) and the empty stretches.
var emptyRun = 0;
var probed = false;
var carried = hint.HasEmptyStretch ? (hint.EmptyFromTicks, hint.EmptyToTicks) : (0L, 0L);
(long From, long To) proven = (0L, 0L);
while (segHi > stopTicks && result.Count < want)
{
if (carried.Item2 > carried.Item1 && segHi > carried.Item1 && segHi < carried.Item2)
{
segHi = carried.Item1; // (from, segHi] lies inside a stretch a previous page proved empty
continue;
}
var probe = !probed && known == 0 && emptyRun >= 2 && segHi - stopTicks > width;
var w = probe ? segHi - stopTicks : width;
var lowered = false;
var bisected = false;
if (known > 0)
{
var gap = segHi - known;
lowered = lowerBy > 0 && lowerBy < gap;
bisected = !lowered && (bisecting || lowerBy > 0) && gap > 2 * Math.Max(granularity, knownWidth);
// Never narrower than the request granularity: requests are rounded outward to it, so a narrower segment
// re-sends the same request — and a width left at one tick (a tie cluster's) stepped segHi down a tick per
// read, ~10,000 identical reads (#691.4 re-review). Keep the Min: a truncated read crowded by rows before
// the segment must still shrink the segment.
w = lowered ? gap - lowerBy
: bisected ? gap / 2
: Math.Min(Math.Max(w, granularity), gap <= 2 * granularity ? gap : gap / 2);
}
var segLo = segHi - stopTicks <= w ? stopTicks : segHi - w;
var start = Utc(AlignDown(segLo, granularity));
var end = Utc(AlignUp(segHi + 1, granularity));
var segBudget = (discovering && !probe) || (useDiscoveryBudgetForGaps && (probe || known > 0))
? discoveryBudget : pageBudget;
var cap = segBudget + 2;
IReadOnlyList rows;
var narrowed = false;
while (true)
{
ct.ThrowIfCancellationRequested();
var segment = await read(start, end, cap, ct).ConfigureAwait(false);
rows = segment.Rows;
if (!segment.Truncated || rows.Count == 0 || ticksOf(rows[^1]) > segHi) break; // complete for (segLo, segHi]
if (probe)
{
// #696: strict-ceiling discovery retains the rows/density instead of discarding the remainder probe.
probed = true;
if (!useDiscoveryBudgetForGaps)
{
narrowed = true;
break;
}
probe = false;
}
var leading = 0;
var inner = 0;
var firstInner = 0L;
var lastInner = 0L;
foreach (var r in rows)
{
var ts = ticksOf(r);
if (ts <= segLo) leading++;
else if (ts <= segHi)
{
if (inner++ == 0) firstInner = ts;
lastInner = ts;
}
}
// Crowded out by rows before the segment, with the segment itself possibly within budget: re-read once
// with room for them.
var roomier = leading + segBudget + 2;
if (leading > 0 && inner <= segBudget && roomier > cap && roomier <= 2 * segBudget + 4)
{
cap = roomier;
continue;
}
if (segHi - segLo <= granularity)
{
// A segment no wider than the request granularity (1 ms — narrower requests are the same request) still
// over its budget: a tie cluster, or a burst inside one millisecond. Escalate to the tie budget M before
// refusing — up to M rows within one millisecond are served (#686); more are refused, per millisecond
// (#691.4 re-review — exact for the millisecond-resolution Gateway).
if (segBudget < budget)
{
segBudget = budget;
cap = segBudget + 2;
continue;
}
throw tooManyAtOneTimestamp(Utc(segHi));
}
// Narrow — the segment shrinks every time, so this terminates.
if (inner > 0 && lastInner == segHi)
{
width = resolution; // a tie cluster at the top of the segment: read that one timestamp
lowerBy = 0;
bisecting = false;
}
else if (inner > 0 && (lowered || bisected))
{
// Data reaches past this segment's bottom: move `known` up to what this read saw; a gallop goes further.
known = lastInner;
if (lowered) lowerBy = lowerBy > maxWidth / 4 ? maxWidth : lowerBy * 4;
}
else if (inner > 0)
{
// Rows exist at lastInner; what lies above it is unknown. Aim at about half the page budget at the
// density the truncated rows showed (their span, not the segment's — it may open on an empty stretch).
var aimed = inner > 1 && lastInner > firstInner
? (long)Math.Min((double)(lastInner - firstInner) * ((useDiscoveryBudgetForGaps ? segBudget : pageBudget) / 2.0) / (inner - 1), maxWidth)
: resolution;
known = lastInner;
knownWidth = Math.Max(resolution, aimed);
width = Math.Max(resolution, Math.Min(aimed, segHi - segLo - 1));
lowerBy = 0;
bisecting = false;
}
else
{
// Only rows before the segment: shrink it — top-anchored from here on (a gallop would re-send the same
// crowded read).
width = Math.Max(resolution, Math.Min(width, segHi - segLo) / 4);
if (lowered || bisected) noGallop = true;
lowerBy = 0;
bisecting = false;
}
discovering = false;
narrowed = true;
break;
}
if (narrowed) continue; // re-read the same segHi, narrower
var count = AppendNewestFirst(rows, ticksOf, tieOrder, segLo, segHi, result, (int)Math.Min(int.MaxValue, want),
out var oldest);
// This complete read proves (segLo, oldest) empty — or the whole segment when it held nothing.
proven = count > 0 ? (segLo, oldest) : (segLo, segHi + 1);
if (probe)
{
probed = true;
segHi = segLo; // the whole remainder is read
continue;
}
emptyRun = count == 0 ? emptyRun + 1 : 0;
var grown = width > maxWidth / 4 ? maxWidth : width * 4;
if (count == 0)
{
discovering = true; // ×4 growth may overshoot into dense data
if (known > 0)
{
// An empty top-anchored segment: gallop up from `known`. An empty galloping read: the top is bracketed
// in (known, segLo] — bisect it (an empty bisecting read keeps bisecting).
if (lowered || bisected)
{
lowerBy = 0;
bisecting = true;
}
else if (noGallop)
{
width = maxWidth; // halve the gap, top-anchored
}
else
{
lowerBy = Math.Max(knownWidth, granularity);
}
}
else
{
width = grown;
}
}
else
{
// Re-aim at about half the page budget from the density just seen.
width = (long)Math.Clamp((double)(segHi - segLo) * Math.Max(1.0, pageBudget / 2.0) / count, resolution, grown);
discovering = false;
known = 0;
lowerBy = 0;
bisecting = false;
noGallop = false;
}
segHi = segLo;
if (known > 0 && segHi <= known)
{
// Everything above the newest row the truncated read saw is read: the next segment ends on it, at its density.
known = 0;
lowerBy = 0;
bisecting = false;
noGallop = false;
width = knownWidth;
discovering = false;
}
}
// Carry the stretch the last complete read proved empty, joined to the one carried in when they touch; when they
// do not, keep the one reaching the floor (it saves a whole walk), else the newer.
var next = carried;
if (proven.To > proven.From)
{
if (carried.Item2 > carried.Item1 && proven.From < carried.Item2 && carried.Item1 < proven.To)
next = (Math.Min(proven.From, carried.Item1), Math.Max(proven.To, carried.Item2));
else if (!(carried.Item2 > carried.Item1 && carried.Item1 <= stopTicks) || proven.From <= stopTicks)
next = proven;
}
return (result, new ReverseWalkHint(width, next.Item1, next.Item2));
}
/// Appends the rows in (segLo, segHi] newest-first (until ) and counts them.
private static int AppendNewestFirst(
IReadOnlyList rows, Func ticksOf, IComparer? tieOrder, long segLo, long segHi, List result, int limit) =>
AppendNewestFirst(rows, ticksOf, tieOrder, segLo, segHi, result, limit, out _);
/// As above, also returning the oldest in-range timestamp (meaningless when none).
private static int AppendNewestFirst(
IReadOnlyList rows, Func ticksOf, IComparer? tieOrder, long segLo, long segHi, List result, int limit,
out long oldest)
{
oldest = long.MaxValue;
IReadOnlyList ordered = rows;
if (tieOrder is not null)
{
var copy = rows.ToList();
copy.Sort((a, b) =>
{
var c = ticksOf(a).CompareTo(ticksOf(b));
return c != 0 ? c : tieOrder.Compare(a, b);
});
ordered = copy;
}
var count = 0;
for (var i = ordered.Count - 1; i >= 0; i--)
{
var ts = ticksOf(ordered[i]);
if (ts <= segLo || ts > segHi) continue;
count++;
if (ts < oldest) oldest = ts;
if (result.Count < limit) result.Add(ordered[i]);
}
return count;
}
private static DateTime Utc(long ticks) => new(ticks, DateTimeKind.Utc);
private static long AlignDown(long ticks, long step) => ticks - ticks % step;
private static long AlignUp(long ticks, long step)
{
var down = AlignDown(ticks, step);
if (down == ticks) return ticks;
return down > DateTime.MaxValue.Ticks - step ? DateTime.MaxValue.Ticks : down + step;
}
}
///
/// Orders events within one timestamp by (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.
///
internal sealed class EventIdTieOrder : IComparer
{
/// The shared instance.
public static readonly EventIdTieOrder Instance = new();
///
public int Compare(HistoricalEvent? x, HistoricalEvent? y) => string.CompareOrdinal(x?.EventId, y?.EventId);
}