using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; /// /// The server-side resume state stored behind an opaque continuation point for a single paged /// HistoryRead-Raw read. Captures exactly enough to continue the SAME logical read from where the /// previous page stopped: the tagname, the original (inclusive) end of the window, the next start of /// the window, and the tie-safe boundary skip. (Only Raw pages server-side — Processed and AtTime /// carry no client count cap, so they are single-shot and never produce a continuation point; see /// .) /// /// The boundary fields ( + ) encode a /// tie-safe resume cursor: the next page reads from INCLUSIVE and /// then drops the first samples whose SourceTimestamp equals /// . This guarantees that samples sharing the page-boundary timestamp /// are neither re-returned (duplicate) nor skipped — see . /// /// /// This record carries no SDK types so the whole paging decision surface is a pure, allocation- /// cheap value that unit tests can drive directly. /// /// /// The resolved historian tagname (NOT the NodeId) to read from. /// /// Inclusive lower bound for the next page — the boundary timestamp the previous page stopped on. /// /// The original (inclusive) upper bound of the read window; unchanged across pages. /// /// How many samples whose SourceTimestamp equals were already returned on /// prior pages and must be dropped from the head of the next page (tie de-dup). /// /// The client's per-page cap; re-applied to every resumed page. /// /// Which read issued the cursor (Gitea #669 / #668). A cursor is only ever resumed by the read kind that issued it, so a /// raw point presented to an events or modified read (or any other mismatch) is a BadContinuationPointInvalid /// miss. : carries the registered historian /// event-source name and the boundary fields are over EventTimeUtc. /// /// /// only: the ModifiedId of the last returned modification. With /// (its source time) it is the exclusive resume key; /// is unused because the key is unique. /// /// /// only: the normalised window start, so a resumed page keeps the original /// window and direction (the modified read resumes by key, not by moving the window start). /// /// /// only (Gitea #675): the read is newest-first over /// (EndUtc, NextStartUtc]. A resumed events read takes its direction from here, never from the resume /// request's details. Raw keeps deriving it from the window (NextStartUtc > EndUtc). /// /// /// reverse reads on a forward-only backend only (Gitea #686): the segment width the /// walk-back ended on, so the next page starts at the observed density instead of re-discovering it. 0 = none. /// /// /// Walked reverse reads only (Gitea #691): with , a stretch (from, to) the previous /// page's walk proved holds no row, so the next page steps over it rather than re-walking it (to the 1970 floor, ~14 /// reads, at the end of a deep page-through). 0/0 = none. The cursor lives in the session's in-memory store (never /// serialized), so a new field with a "none" default is all the versioning it needs. /// /// The exclusive upper end of that stretch. /// /// with ReturnBounds = true only (Gitea #691): the end bound still owed — /// Part 11 returns it with the last page. null = bounds not requested. /// /// /// only (Gitea #692): the previous page was a slice of an oversized tie cluster at /// that still has un-emitted ties — the next page over-fetches the cluster directly /// instead of first re-reading N + skip + 1 rows that are known to be ties. /// internal sealed record HistoryContinuationState( string Tagname, DateTime NextStartUtc, DateTime EndUtc, int BoundarySkipCount, uint NumValuesPerNode, HistoryCursorKind Kind = HistoryCursorKind.Raw, long LastModifiedId = 0, DateTime WindowStartUtc = default, bool Reverse = false, long SegmentWidthTicks = 0, long EmptyFromTicks = 0, long EmptyToTicks = 0, HistoryEndBound? PendingEndBound = null, bool WithinTieCluster = false) { /// The walk-back hint this cursor carries (#686/#689/#691). public ReverseWalkHint WalkHint => new(SegmentWidthTicks, EmptyFromTicks, EmptyToTicks); } /// The HistoryRead kind that issued a . internal enum HistoryCursorKind { /// HistoryReadRaw (values). Raw, /// HistoryReadEvents (#669). Events, /// HistoryReadRaw with IsReadModified = true — the Part 11 modification trail (#668). Modified, } /// /// Pure server-side continuation-point paging decisions for the count-capped variable-history arms /// (Raw / Processed). The backend (the HistorianGateway read client) does NOT page — it returns up to /// NumValuesPerNode samples with a null continuation point — so paging is synthesised here, /// time-based: /// /// A page that returns EXACTLY the requested cap (NumValuesPerNode > 0) MAY have /// more behind it ⇒ emit a continuation point. /// A short page (fewer than the cap) is the last page ⇒ no continuation point. /// NumValuesPerNode == 0 means "all values, no limit" (OPC UA Part 11) ⇒ never page; /// return everything in one shot. /// /// All methods are static + pure so they unit-test without a server, a session, or the SDK. /// internal static class HistoryPaging { /// /// Decide whether a just-returned page is "full" and therefore MAY be followed by more data — /// the signal to emit a continuation point. A page is full when the client asked for a finite /// cap ( > 0) and the backend returned exactly that many /// samples. A short page (or an unlimited 0 request) is terminal. /// /// The number of samples the backend returned for this page. /// The client's per-page cap; 0 means "no limit". /// true when a continuation point should be emitted; otherwise false. public static bool IsFullPage(int returnedCount, uint numValuesPerNode) => numValuesPerNode > 0 && returnedCount >= numValuesPerNode; /// /// Build the resume cursor (next-start + boundary skip count) from the last sample of a full /// page, tie-safe against samples that share the boundary SourceTimestamp. /// /// The next page resumes from the LAST returned sample's SourceTimestamp inclusive /// (NOT advanced by a tick), and the returned counts how /// many samples in the page already carry that exact boundary timestamp. Resuming inclusively /// + dropping that many head samples guarantees: /// /// no sample is re-returned (the ones already emitted at the boundary are skipped), and /// no sample is skipped (any un-emitted ties at the boundary are still read because we /// start AT the boundary, not after it). /// /// A naive "+1 tick" advance would skip un-emitted ties; this carry-offset strategy does not. /// /// /// The page just returned (chronological, non-empty — guaranteed by the caller, /// which only pages a full page and a full page implies NumValuesPerNode > 0 samples). /// The boundary timestamp the next page resumes from (inclusive). /// How many head samples at the next /// page must drop (samples already emitted at the boundary timestamp). public static void ComputeResumeCursor( IReadOnlyList page, out DateTime nextStartUtc, out int boundarySkipCount) { // Enforce the documented precondition at the API boundary rather than relying on the caller's guard // (the only caller only pages a full, non-empty page, but this is a public static helper). if (page.Count == 0) throw new ArgumentOutOfRangeException(nameof(page), "ComputeResumeCursor requires a non-empty page."); // The boundary is the last returned sample's SourceTimestamp. A sample whose SourceTimestamp is // null (Bad/unset) cannot anchor a time cursor; fall back to MinValue so the next read covers the // whole remaining window rather than silently dropping data — duplicates are then de-duped by the // skip count below. var last = page[^1]; nextStartUtc = last.SourceTimestampUtc ?? DateTime.MinValue; // Count how many trailing samples in THIS page share the boundary timestamp — those are the ties // already emitted at the boundary that the next page must drop from its head. var skip = 0; for (var i = page.Count - 1; i >= 0; i--) { if ((page[i].SourceTimestampUtc ?? DateTime.MinValue) == nextStartUtc) skip++; else break; } boundarySkipCount = skip; } /// /// Drop the first samples of a freshly-read resumed page /// whose SourceTimestamp equals the boundary — the ties already /// emitted on the previous page. Samples past the boundary timestamp are always kept (only the /// exact-boundary head is trimmed), so a backend that returns fewer boundary ties than expected /// (data pruned between pages) still yields a correct, monotonic result. /// /// The page returned by the resumed backend read (chronological). /// The boundary timestamp the resume read started at (inclusive). /// How many head samples at to drop. /// The page with the already-emitted boundary ties trimmed from the head. public static IReadOnlyList TrimBoundaryDuplicates( IReadOnlyList resumedPage, DateTime boundaryUtc, int boundarySkipCount) { if (boundarySkipCount <= 0 || resumedPage.Count == 0) return resumedPage; var dropped = 0; var i = 0; while (i < resumedPage.Count && dropped < boundarySkipCount && (resumedPage[i].SourceTimestampUtc ?? DateTime.MinValue) == boundaryUtc) { i++; dropped++; } if (i == 0) return resumedPage; // Slice off the trimmed head; return a copy so the caller owns a plain list (no SDK coupling). var trimmed = new List(resumedPage.Count - i); for (var j = i; j < resumedPage.Count; j++) trimmed.Add(resumedPage[j]); return trimmed; } /// /// Page WITHIN a single oversized "tie cluster" — a set of raw samples that all share one /// SourceTimestamp and is larger than the client's per-page cap. /// /// The fixed-(start, end, cap) historian backend cannot skip/offset, so an oversized /// tie cluster defeats the (timestamp, skip) resume cursor that /// builds: every resume re-reads the first cap ties, the boundary-tie trim empties the /// page, and the cursor never advances. To page past it, the caller over-fetches the WHOLE /// cluster (a bounded start == end == T read) and hands it here. We then carve out the /// next ties starting at and compute a cursor /// that advances within the cluster, then steps off it when it is drained. /// /// /// Advance is lossless. When the slice drains the cluster (emitted == clusterCount) /// we resume from T + 1 tick with a fresh skip of 0. This skips no data because no /// value exists strictly between T and T + 1 tick — a tick /// (100 ns) is the type's resolution — so every remaining sample in the window has a timestamp /// of at least T + 1 tick. We do NOT resume inclusively at T here (the way /// does for cross-boundary ties): we have already over-fetched /// and emitted the entire T cluster, so resuming at T would needlessly re-read it. /// /// /// Short-page-with-CP exception. A page that fully drains the cluster but is SHORTER than /// (the cluster had fewer than cap remaining ties) STILL emits a /// continuation point when the window extends past T (T + 1 tick <= endUtc). This /// deliberately violates the usual "short page ⇒ terminal" rule (): /// the page is short only because the cluster ran out, not because the window did, so there may /// still be data after T that the next page must read. /// /// /// Self-heal. If meets or exceeds /// (e.g. a stale cursor against a cluster that shrank between reads), sliceStart clamps to /// and sliceCount is 0; since emitted then /// equals , the cursor advances/terminates rather than looping. /// /// /// The total number of ties at (the over-fetched cluster size). /// How many ties at were already emitted on prior pages. /// The client's per-page cap (NumValuesPerNode); must be > 0 to make progress. /// The single SourceTimestamp every tie in the cluster shares. /// The (inclusive) upper bound of the read window; unchanged across pages. /// The forward window is end-EXCLUSIVE (Part 11; the IHistorianDataSource.ReadRawAsync contract), so a drained /// cluster continues only while next is strictly before truncated to /// . (OpcUaServer-003 had kept next <= endUtc as "the conservative side", /// but it was not: resuming AT EndTime makes the next read a start == end point read, which returns the samples /// at EndTime as data — #692 review F1; on a microsecond backend EndTime is truncated first.) /// The index into the cluster the emitted slice starts at (clamped to the count). /// How many ties the emitted slice contains (may be 0 on a stale-skip self-heal). /// The next page's inclusive start: while the /// cluster still has un-emitted ties, boundaryT + 1 tick once it is drained and the window /// remains, or null when the window is exhausted (terminal — no continuation point). /// The next page's boundary skip count: the running emitted-tie total while the /// cluster drains, else 0 after advancing past it. /// /// true for a reverse read (Part 11: start > end, samples in descending time, window /// (end, start]). A drained cluster then steps DOWN to T − 1 tick, which is lossless for the /// same reason T + 1 tick is going forward, and the read continues only while that is still strictly /// after (the reverse window excludes end; a resume at exactly end /// would turn into a start == end point read and return the excluded sample). Forward is unchanged. /// /// /// The backend's storage timestamp resolution (IHistorianDataSource.TimestampResolutionTicks, >= 1). A /// drained cluster steps off by this many ticks rather than by one: a backend that /// truncates its inputs to its resolution (QuestDB, 10 ticks) would otherwise truncate T + 1 tick back /// to T and return the drained sample again, forever. Lossless for the same reason one tick is at tick /// precision: no stored timestamp lies strictly between T and T ± resolution. The default /// 1 keeps the tick-precision behaviour exactly. /// public static void SliceTieCluster( int clusterCount, int skip, uint cap, DateTime boundaryT, DateTime endUtc, out int sliceStart, out int sliceCount, out DateTime? nextStartUtc, out int nextSkip, bool reverse = false, long resolutionTicks = 1) { ArgumentOutOfRangeException.ThrowIfLessThan(resolutionTicks, 1L); sliceStart = Math.Min(skip, clusterCount); sliceCount = Math.Min((int)cap, clusterCount - sliceStart); var emitted = sliceStart + sliceCount; if (emitted < clusterCount) { nextStartUtc = boundaryT; nextSkip = emitted; } else if (reverse) { // Nothing can lie below DateTime.MinValue, so a boundary within one resolution step of it cannot step down. var more = boundaryT.Ticks >= resolutionTicks && boundaryT.AddTicks(-resolutionTicks) > endUtc; nextStartUtc = more ? boundaryT.AddTicks(-resolutionTicks) : null; nextSkip = 0; } else { var next = boundaryT.AddTicks(resolutionTicks); // The forward window is end-EXCLUSIVE, at the backend's resolution: resuming AT EndTime would turn the next read // into a start == end point read, which returns the samples at EndTime as data (#692 review F1). if (next.Ticks < endUtc.Ticks - endUtc.Ticks % resolutionTicks) { nextStartUtc = next; nextSkip = 0; } else { nextStartUtc = null; nextSkip = 0; } } } }