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