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); }