From d33992034dc35ccc36c96cc2e78528545954bcdb Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 12 Oct 2026 16:25:07 -0400 Subject: [PATCH] fix(historian): #689 size reverse-walk segments from the page, not the tie budget ReverseRawReader aimed every segment at half of MaxTieClusterOverfetch (~32k rows by default) and carried that width page to page, so a paged 'latest N' read on a dense Gateway tag fetched ~32,770 rows per page (2-5 s live) to return N. The walk now lives in a generic ReverseWalk core: segments are aimed at ~N+8 rows (page budget 2N+16), a discovery segment (first without a hint / after an empty one) may read 1,024, and the tie budget is only the ceiling a single-timestamp cluster escalates to. A truncated read never re-enters the stretch below its newest row until the gap above it is bisected. Tests: per-page row counts on a dense tag (node manager + walk), randomized exactness fuzz (walk and node-manager paging), dense-tag-below-a-long-gap read bound. --- CLAUDE.md | 2 +- docs/Historian.md | 29 +- .../OtOpcUaNodeManager.cs | 2 +- .../ReverseRawReader.cs | 213 +++---------- .../ReverseWalk.cs | 297 ++++++++++++++++++ .../Historian/ServerHistorianOptions.cs | 4 +- .../NodeManagerHistoryReadPagingTests.cs | 105 ++++++- .../ReverseWalkTests.cs | 194 ++++++++++++ 8 files changed, 663 insertions(+), 183 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseWalk.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ReverseWalkTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 77393098..af88cdb5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1353,7 +1353,7 @@ historian tagname, which is the tag's driver `FullName`. - **SQL Server** (`Driver.Historian.SqlServer`): `hist.Sample` PK `(TagId, SourceTimeUtc)` — writes are insert-if-absent, so outbox redelivery is idempotent. Stores every value kind with its **real status**; per-sample aggregates (10, simple definitions) are pushed down to T-SQL with bucket math in **100 ns ticks**; the Part 13 time-weighted seven (TimeAverage/TimeAverage2/Total/Total2/Interpolative, #671 — ⚠️ `Total` is now the time-weighted integral, no longer `SUM` — plus the **duration-based** PercentGood/PercentBad, #680) stream window + 4 bound rows into the shared `TimeWeightedAggregator` (both SQL backends, identical results; reverse `Interpolative` anchored at the later time; window capped by `ServerHistorian:MaxTimeWeightedRawRows` ⇒ `BadTooManyOperations`, percentages included). ⚠️ **#680 status rules (client-visible):** TimeAverage/Total are Uncertain if any part of the interval uses an Uncertain value, is bridged across a Bad sample, is extrapolated past the last sample (⇒ a live tag's latest interval is Uncertain) or is not covered; TimeAverage2/Total2 keep the SDK time-based rule (Uncertain time counts as Good) but never extrapolate past the last sample — that interval is Partial, later ones `BadNoData` (a report-by-exception tag with nothing after its last sample reads `BadNoData` until its next sample arrives). `docs/Historian.md` §"Time-weighted aggregates". ⚠️ **#680 per-sample (client-visible):** `Count` counts **Good** samples only (value-based status; an all-Bad interval is generic Bad, value null) and an empty interval **inside the tag's data range** is `0`/Good (outside it still `BadNoData`); `Start`/`End` return the **raw** first/last sample of any quality — its own timestamp and status, value null when Bad; `Delta` is Uncertain only when the interval's first or last sample is unusable; ⚠️ #684: `Average`/`Minimum`/`Maximum`/`Range`/`*ActualTime` use **Good samples only** (was Good + Uncertain; an Uncertain-only interval is `BadNoData`), Min/Max-family status Uncertain on a Bad sample, an Uncertain value beyond the Good extreme (Part 13 — the SDK ignores that) or an Uncertain non-numeric sample, and only a **Good** String/DateTime sample refuses them (`BadAggregateInvalidInputs`; Delta still refuses any usable one); and every per-sample aggregate but `Average` sets **end-of-data Partial** on the interval holding the last sample (forward incl. its earlier edge, reverse strictly inside), never on a Bad result. The data-range lookup is a key seek on SQL Server and a `tag` symbol-index seek on QuestDB (#683 — `{p}sample.tag` is `SYMBOL INDEX`; the bootstrapper ADDs it to a pre-#683 table, idempotent), read BEFORE the bucket statement so a racing append can never yield a Good `Count` of 0 (a concurrent HistoryUpdate delete is the mirror case and still can, transiently). `docs/Historian.md` §"Aggregates". Docker-dev recipe, schema and aggregate semantics: `docs/Historian.md` §"SQL Server backend". Integration tests need `HISTORIAN_SQL_TEST_CONNECTION`. **Retention (#667):** opt-in `SqlServer:RetentionDays` (+ `PurgeInterval` 1h, `PurgeBatchSize` 4000) -> `HistorianRetentionService` purges old `hist.Sample` (per tag) / `hist.Event` in `DELETE TOP(n)` batches, Primary-gated on `IHistorizationRoleView`, one purger per DB via `sp_getapplock`; counter `otopcua.historian.retention.purged{table}`. Optional `IHistorianBackendProvider.CreateRetention` (default null). `docs/Historian.md` §Retention. - **QuestDB** (`Driver.Historian.QuestDb`, `ServerHistorian:QuestDb:*`, Npgsql over PG wire; docker-dev overlay `docker-compose.questdb.yml`): same kinds/status/aggregates/HistoryUpdate as SqlServer, soft delete by tombstone (INSERT only — never `UPDATE`/`DELETE`; every read filters `NOT deleted`). ⚠️ **Timestamps are µs** (`QuestDbConnection.Round`/`BindTimestamp`; `IHistorianDataSource.TimestampResolutionTicks` = 10 steps HistoryRead paging), **pre-1970 cannot be stored** (writer drops, HistoryUpdate `BadOutOfRange`), every write waits for WAL visibility, TTL is enforced on write commits only, set Npgsql `Maximum Pool Size` < QuestDB's 64. `docs/Historian.md` §"QuestDB backend". - ⚠️ **Reverse processed reads are Part 13/SDK-anchored (#677, client-visible):** every aggregate on SqlServer/QuestDB now anchors a reverse read at its later time — window `(end, start]`, partial interval earliest, each stamped with its later edge (was: earlier-anchored, stamped earlier edge); on forward reads Partial also marks the interval the data starts inside (not Average — the SDK's rule, forward-only); a non-default client `AggregateConfiguration` ⇒ `BadAggregateConfigurationRejected`; Gateway provisioning sends `IntegralDivisor = 1`. SDK-parity tests: `SdkAggregateParityTests`. `docs/Historian.md` §Aggregates. -- ⚠️ **Reverse / end-only RAW reads work on Gateway (#686, client-visible):** `IHistorianDataSource.SupportsReverseRaw` (SqlServer/QuestDB/Null `true`, Gateway `false`, interface default `false`, guard-tested) — the node manager sends `start > end` only to a backend that declares it; otherwise `ReverseRawReader` serves it from FORWARD reads: uncapped ⇒ ONE forward read reversed in memory; capped ⇒ a bounded walk back in segments (ms-aligned request bounds, width aimed from observed density and carried page to page in the continuation point, ≤ `MaxTieClusterOverfetch + 2` rows per read — a full read is re-read, never answered truncated; only one timestamp holding more than max(1, `MaxTieClusterOverfetch`) samples ⇒ `BadTooManyOperations`; walks no later than now + 1 day and never before the Unix epoch). The raw/events tie-path oversized-cluster refusal is now `BadTooManyOperations` too (was `BadHistoryOperationUnsupported`). Uncapped Gateway raw reads (forward too) used to send `max_values = 0`, which the client/gateway reject — now unbounded (`int.MaxValue`; a ceiling refusal ⇒ `BadTooManyOperations`), and a cancelled read is no longer a backend failure. Since #669 such reads failed the node on Gateway and counted as a backend fault (the gateway rejects start > end — `GatewayHistorianDataSource.ReadRawAsync` now refuses one unsent); before #669 an end-only Gateway read returned the OLDEST N ascending — now the latest N at/before EndTime, newest first. Events still differ (end-only forward / explicit reverse refused on Gateway, #675). `docs/Historian.md` §"Open-ended and reverse raw reads". +- ⚠️ **Reverse / end-only RAW reads work on Gateway (#686, client-visible):** `IHistorianDataSource.SupportsReverseRaw` (SqlServer/QuestDB/Null `true`, Gateway `false`, interface default `false`, guard-tested) — the node manager sends `start > end` only to a backend that declares it; otherwise `ReverseRawReader` serves it from FORWARD reads: uncapped ⇒ ONE forward read reversed in memory; capped ⇒ a bounded walk back in segments (ms-aligned request bounds, width aimed from observed density at ≈ N + 8 rows and carried page to page in the continuation point — #689: it used to aim at half `MaxTieClusterOverfetch`, ~32k rows per page on a dense tag; a read asks for `2N + 18` rows (a discovery segment ≤ 1,026), `MaxTieClusterOverfetch` is only the tie-cluster ceiling — a full read is re-read, never answered truncated; only one timestamp holding more than max(1, `MaxTieClusterOverfetch`) samples ⇒ `BadTooManyOperations`; walks no later than now + 1 day and never before the Unix epoch). The raw/events tie-path oversized-cluster refusal is now `BadTooManyOperations` too (was `BadHistoryOperationUnsupported`). Uncapped Gateway raw reads (forward too) used to send `max_values = 0`, which the client/gateway reject — now unbounded (`int.MaxValue`; a ceiling refusal ⇒ `BadTooManyOperations`), and a cancelled read is no longer a backend failure. Since #669 such reads failed the node on Gateway and counted as a backend fault (the gateway rejects start > end — `GatewayHistorianDataSource.ReadRawAsync` now refuses one unsent); before #669 an end-only Gateway read returned the OLDEST N ascending — now the latest N at/before EndTime, newest first. Events still differ (end-only forward / explicit reverse refused on Gateway, #675). `docs/Historian.md` §"Open-ended and reverse raw reads". - **Advertised aggregates (#682):** `IHistorianDataSource.SupportedAggregates` (Gateway 4 via `GatewayHistorianDataSource.ServedAggregates`, SqlServer/QuestDB 17 via `AggregateFamilies.Served`, Null **none**; a null set ⇒ all) is the one authority for `HistoryServerCapabilities/AggregateFunctions` (i=11201 — only its folder references are trimmed) and the up-front `BadAggregateNotSupported` in HistoryRead-Processed. ⚠️ `ServerCapabilities/AggregateFunctions` (i=2997) is deliberately NOT trimmed: it keeps the SDK's 37 because live `AggregateFilter` subscriptions still compute them all. ⚠️ Historian disabled ⇒ Processed is now `BadAggregateNotSupported`, not `GoodNoData`. `docs/Historian.md` §Advertised aggregates. - **Real status fix:** the recorder used to stamp every sample Good (`192`); it now carries the driver's status. ⚠️ On the **Gateway** backend this changes nothing stored: the gateway discards quality (C-002), so AVEVA still stores Good. - **Comms loss is historized as an outage marker** (`Empty` + `BadNoCommunication`, one per tag per outage) on SqlServer; dropped (`DroppedUnsupportedKind`) on Gateway. Outbox records are **format v2** — drain or delete `ContinuousHistorization:OutboxPath` before downgrading a node. diff --git a/docs/Historian.md b/docs/Historian.md index 605779cf..cac9ea6a 100644 --- a/docs/Historian.md +++ b/docs/Historian.md @@ -639,15 +639,26 @@ backend: `[⌊segLo⌋, ⌈segHi + 1 tick⌉]`, both rounded **outward to the millisecond**, and filtered on the samples' own timestamps — exact for an inclusive or exclusive forward end, and the row AVEVA fabricates at a query's (possibly rounded-up) start can never land inside the segment. The first segment is **1 minute** (or the width the previous - page ended on — it rides the continuation point); each next width is aimed at about half the row budget from the - density just observed, growing at most ×4. So "the last N values" of a live tag is normally one read, a dense tag - costs about one read per page, and sparse history is found in a few dozen tiny ones. - - **Never answered from a truncated read.** The row budget is `M = max(1, MaxTieClusterOverfetch)`; a segment read asks - for `M + 2` rows (room for the fabricated row and one to see past the segment). Rows stream ascending, so a read is - complete when it came back short or its last row is past the segment; otherwise it is discarded — re-read once with - room for the rows before the segment that crowded it (a neighbouring tie cluster, at most `2M + 4` rows), or narrowed - to the density it showed. Only **one timestamp holding more than `M` samples** is refused, **`BadTooManyOperations`** - (a request limit, not a backend fault); a cluster of exactly `M`, or neighbouring clusters each within `M`, is served. + page ended on — it rides the continuation point); each next width is aimed from the density just observed at about + half the **page budget** `2N + 16` (≈ N + 8 rows), growing at most ×4. So "the last N values" of a live tag is + normally one read of about N rows, a dense tag costs about one N-sized read per page, and sparse history is found in + a few dozen tiny ones. + - **Read sizes (#689).** A segment read asks for its budget + 2 rows (room for the fabricated row and one to see past + the segment). The budget is the page budget `2N + 16`, 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 + **1,024** rows. The tie budget `M = max(1, MaxTieClusterOverfetch)` is only a **ceiling**: a single timestamp holding + more rows than the segment budget escalates to `M`. + ⚠️ Before #689 every segment was aimed at half of `M` (≈ 32k rows by default) and that width was carried page to + page, so on a 1 Hz tag from about page 5 every page of a "latest N" read fetched ~32,770 rows and took 2–5 s to + return N. + - **Never answered from a truncated read.** Rows stream ascending, so a read is complete when it came back short or its + last row is past the segment; otherwise it is discarded — re-read once with room for the rows before the segment + that crowded it (a neighbouring tie cluster, at most twice the budget + 4 rows), or narrowed: to the density it + showed, and never again below the newest row it saw until what lies above that row is read (the gap above it is + bisected — a dense tag that went quiet days ago is found in about 20 reads rather than re-reading the same oldest + rows of its burst over and over). Only **one timestamp holding more than `M` samples** is refused, + **`BadTooManyOperations`** (a request limit, not a backend fault); a cluster of exactly `M`, or neighbouring + clusters each within `M`, is served. - **Bounds.** The walk starts no later than **now + 1 day** (a `StartTime = DateTime.MaxValue` read does not send year-9999 query starts) and never reads before the **Unix epoch** (AVEVA holds nothing earlier): a walked-back read returns only samples strictly after 1970-01-01T00:00:00Z and no later than now + 1 day. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 09b486f8..7cecc9b4 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -537,7 +537,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// paging over-fetches up to MaxTieClusterOverfetch + 1 ties at that single timestamp (a /// start == end read) and slices through them via . /// A cluster strictly larger than this still fails that node BadTooManyOperations (the absurd-burst - /// backstop; BadHistoryOperationUnsupported before #686). Also the per-read row budget of a reverse raw read + /// backstop; BadHistoryOperationUnsupported before #686). Also the tie-cluster ceiling (not the segment size, #689) of a reverse read /// walked back from forward reads (). Mirrors the configured /// ServerHistorianOptions.MaxTieClusterOverfetch; the Host sets it at StartAsync. The /// default (65536) survives when the historian section is absent. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseRawReader.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseRawReader.cs index 3ebea102..309e9e8b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseRawReader.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseRawReader.cs @@ -5,70 +5,29 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; /// One walked-back reverse raw page (). /// At most the requested cap, newest first. /// The segment width the walk ended on — the next page's starting width (0 = none). -internal readonly record struct ReverseRawPage(IReadOnlyList Samples, long WidthHintTicks); +internal readonly record struct ReverseRawPage(IReadOnlyList Samples, long WidthHintTicks) +{ + /// Everything the next page of the same read should resume with (its width is ). + public ReverseWalkHint Hint { get; init; } = new(WidthHintTicks); +} /// /// Gitea #686: serves a Part 11 reverse raw read — (lo, hi] newest-first, capped — on a historian backend that can /// only read forward ( = false: the Gateway, whose -/// ReadRaw streams ascending and rejects start > end). +/// ReadRaw streams ascending and rejects start > end). The walk itself — segment sizing (#689), the +/// truncation rules, edges, ties and bounds — is ; this adapter supplies the raw read (a read is +/// cut short when it returned its full cap) and keeps the backend's tie order. /// -/// -/// -/// Uncapped (maxValues = 0): ONE forward read of the whole window, filtered and reversed in memory — the -/// same rows, round trips and memory as an uncapped forward read (and as a native backend's uncapped reverse read). -/// -/// -/// 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 at about half the row budget from the density -/// just observed, growing at most ×4 per step — so a live tag's "latest N" is usually one read, a dense tag does not -/// throw away a full read per page, and sparse history is found in a few dozen tiny reads. -/// -/// -/// Never answer from a truncated read. The row budget is M = max(1, MaxTieClusterOverfetch); a segment -/// read asks for M + 2 rows (room for the row an AVEVA read fabricates at its query start, and one more to see -/// past the segment). Rows stream ascending, so a read is complete for its segment when it came back 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, the fabricated row) crowded it out it is re-read once with room for them -/// (at most 2M + 4 rows); otherwise the segment is narrowed (to about half the budget at the density the -/// truncated read showed) and re-read. Only a single timestamp holding more than M samples — a segment already -/// one timestamp-resolution wide that still does not fit — is refused: -/// (⇒ BadTooManyOperations, a request limit, never a backend fault). -/// -/// -/// 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 samples' own timestamps. That is -/// exact whether the backend's forward end is exclusive (the interface contract) or inclusive (the gateway) and -/// whether it truncates its bounds (QuestDB-style); and because the request start is already on a millisecond, a row -/// an AVEVA read fabricates at its (possibly rounded-up) query start can never land inside the segment (#687). -/// Adjacent segments share no timestamp, so nothing is returned twice. -/// -/// -/// Ties come back in the backend's forward order reversed — the same order the node manager's tie-cluster -/// over-fetch uses for a reverse read — so a resumed page's boundary-tie skip lines up. -/// -/// -/// Bounds. The walk never asks for data after now + (one day — the historian stores -/// nothing further ahead; a StartTime = DateTime.MaxValue read would otherwise send centuries-ahead query -/// starts), nor before (the Unix epoch — AVEVA holds nothing earlier, and an end-only read's year-1 -/// lower bound is outside the historian's time encoding). A walked-back read therefore returns only samples strictly -/// after the epoch and no later than now + one day. -/// -/// internal static class ReverseRawReader { /// The first segment's width when no hint is carried. - internal static readonly TimeSpan InitialSegment = TimeSpan.FromMinutes(1); + internal static readonly TimeSpan InitialSegment = ReverseWalk.InitialSegment; - /// The walk's exclusive lower limit (see the remarks). - internal static readonly DateTime Floor = DateTime.UnixEpoch; + /// The walk's exclusive lower limit. + internal static readonly DateTime Floor = ReverseWalk.Floor; - /// How far past "now" the walk starts at most (the same horizon as the Gateway's raw-aggregate look-ahead). - internal static readonly TimeSpan LookAhead = TimeSpan.FromDays(1); - - /// Request bounds are rounded outward to this (the gateway / AVEVA timestamp resolution). - private const long Alignment = TimeSpan.TicksPerMillisecond; + /// How far past "now" the walk starts at most. + internal static readonly TimeSpan LookAhead = ReverseWalk.LookAhead; /// Reads (lo, hi] newest-first from forward reads. /// A backend that reads forward. @@ -76,129 +35,49 @@ internal static class ReverseRawReader /// The later (inclusive) bound — the request's StartTime or a reverse resume point. /// The earlier (exclusive) bound. /// Cap; 0 = every sample in the window (one forward read). - /// Row budget per segment read (MaxTieClusterOverfetch; values below 1 count as 1). + /// The tie budget (MaxTieClusterOverfetch; values below 1 count as 1) — a ceiling only. /// Now, for the look-ahead clamp. /// Starting segment width (the previous page's ), 0 = none. /// Cancellation. /// The page, newest first, and the width to start the next page with. - /// One timestamp holds more than the row budget. + /// One timestamp holds more than the tie budget. + public static Task 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); + + /// Reads (lo, hi] newest-first from forward reads, resuming with the previous page's full hint. + /// A backend that reads forward. + /// Historian tag. + /// The later (inclusive) bound. + /// The earlier (exclusive) bound. + /// Cap; 0 = every sample in the window (one forward read). + /// The tie budget — a ceiling only. + /// Now, for the look-ahead clamp. + /// What the previous page carried (), default = none. + /// Cancellation. + /// The page, newest first, and the hint for the next page. + /// One timestamp holds more than the tie budget. public static async Task ReadAsync( IHistorianDataSource source, string tagname, DateTime hi, DateTime lo, uint maxValues, int maxTieCluster, - DateTime nowUtc, long widthHintTicks, CancellationToken ct) + DateTime nowUtc, ReverseWalkHint hint, CancellationToken ct) { - var resolution = Math.Max(1L, source.TimestampResolutionTicks); - var granularity = Math.Max(Alignment, resolution); var budget = Math.Max(1, maxTieCluster); - 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 new ReverseRawPage(result, widthHintTicks); - - if (maxValues == 0) - { - // One forward read of the whole window, reversed — the uncapped forward read's own cost and bound. - var all = (await source.ReadRawAsync(tagname, Utc(AlignDown(stopTicks, granularity)), - Utc(AlignUp(hiTicks + 1, granularity)), 0, ct).ConfigureAwait(false)).Samples; - for (var i = all.Count - 1; i >= 0; i--) - { - var ts = Ticks(all[i]); - if (ts > stopTicks && ts <= hiTicks) result.Add(all[i]); - } - - return new ReverseRawPage(result, widthHintTicks); - } - - var maxWidth = DateTime.MaxValue.Ticks; - var width = widthHintTicks > 0 ? Math.Clamp(widthHintTicks, resolution, maxWidth) : Math.Max(resolution, InitialSegment.Ticks); - var baseCap = budget + 2; - var maxCap = 2 * budget + 4; - var segHi = hiTicks; - - while (segHi > stopTicks && result.Count < maxValues) - { - var segLo = segHi - stopTicks <= width ? stopTicks : segHi - width; - var start = Utc(AlignDown(segLo, granularity)); - var end = Utc(AlignUp(segHi + 1, granularity)); - var cap = baseCap; - IReadOnlyList rows; - var narrowed = false; - - while (true) - { - ct.ThrowIfCancellationRequested(); - rows = (await source.ReadRawAsync(tagname, start, end, (uint)cap, ct).ConfigureAwait(false)).Samples; - if (rows.Count < cap || Ticks(rows[^1]) > segHi) break; // complete for (segLo, segHi] - - var leading = 0; - var inner = 0; - var firstInner = 0L; - foreach (var r in rows) + var (rows, next) = await ReverseWalk.WalkAsync( + async (start, end, cap, token) => { - var ts = Ticks(r); - if (ts <= segLo) leading++; - else if (ts <= segHi && inner++ == 0) firstInner = 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 + budget + 2; - if (leading > 0 && inner <= budget && roomier > cap && roomier <= maxCap) - { - cap = roomier; - continue; - } - - if (width <= resolution) - throw new HistoryReadLimitExceededException(budget, - $"A reverse raw read of '{tagname}' at {Utc(segHi):O} found more than {budget} samples at one " + - "timestamp; the backend reads forward only, so the latest of them cannot be selected. Increase " + - "ServerHistorian:MaxTieClusterOverfetch."); - - // Narrow to about half the budget at the density the truncated read's own samples showed (their span, not - // the segment's — the segment may open on an empty stretch), at least halving. - var lastTs = Ticks(rows[^1]); - var aimed = inner > 1 && lastTs > firstInner - ? (long)Math.Min((double)(lastTs - firstInner) * (budget / 2.0) / (inner - 1), width / 2.0) - : width / 4; - width = Math.Max(resolution, aimed); - narrowed = true; - break; - } - - if (narrowed) continue; // re-read the same segHi, narrower - - var count = 0; - for (var i = rows.Count - 1; i >= 0; i--) - { - var ts = Ticks(rows[i]); - if (ts <= segLo || ts > segHi) continue; - count++; - if (result.Count < maxValues) result.Add(rows[i]); - } - - // Re-aim at about half the budget from the density just seen; grow at most x4. - var grown = width > maxWidth / 4 ? maxWidth : width * 4; - width = count == 0 - ? grown - : (long)Math.Clamp((double)width * Math.Max(1.0, budget / 2.0) / count, resolution, grown); - segHi = segLo; - } - - return new ReverseRawPage(result, width); + var samples = (await source.ReadRawAsync(tagname, start, end, (uint)cap, token).ConfigureAwait(false)).Samples; + return new ForwardSegmentRead(samples, cap > 0 && samples.Count >= cap); + }, + 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 at one timestamp; the " + + "backend reads forward only, so the latest of them cannot be selected. Increase " + + "ServerHistorian:MaxTieClusterOverfetch."), + ct) + .ConfigureAwait(false); + return new ReverseRawPage(rows, next.WidthTicks) { Hint = next }; } private static long Ticks(DataValueSnapshot s) => (s.SourceTimestampUtc ?? DateTime.MinValue).Ticks; - - 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; - } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseWalk.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseWalk.cs new file mode 100644 index 00000000..94d1ecfa --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseWalk.cs @@ -0,0 +1,297 @@ +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). +internal readonly record struct ReverseWalkHint(long WidthTicks); + +/// 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 a single timestamp holding more than M rows — a one-resolution segment that still does not +/// fit at the tie budget — is refused. +/// +/// +/// 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. + /// 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) + { + 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 (bisecting the gap) until that stretch is read; then `knownWidth` (the density the + // truncated read showed) sizes the segment that ends on it. + var known = 0L; + var knownWidth = 0L; + var segHi = hiTicks; + + while (segHi > stopTicks && result.Count < want) + { + var w = width; + if (known > 0) w = Math.Min(w, Math.Max(1, (segHi - known) / 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 ? 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] + + 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 <= resolution) + { + // A one-resolution segment still over its budget: one timestamp's tie cluster. Escalate to the tie + // budget M before refusing — a cluster of up to M rows is served (#686). + 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 + } + 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) * (pageBudget / 2.0) / (inner - 1), maxWidth) + : resolution; + known = lastInner; + knownWidth = Math.Max(resolution, aimed); + width = Math.Max(resolution, Math.Min(aimed, segHi - segLo - 1)); + } + else + { + width = Math.Max(resolution, (segHi - segLo) / 4); // only rows before the segment: shrink it + } + + 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)); + + var grown = width > maxWidth / 4 ? maxWidth : width * 4; + if (count == 0) + { + // ×4 growth may overshoot into dense data; above a known row, bisect the rest of the gap instead. + width = known > 0 ? maxWidth : grown; + discovering = true; + } + 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; + } + + 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; + width = knownWidth; + discovering = false; + } + } + + return (result, new ReverseWalkHint(width)); + } + + /// 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) + { + 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 (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; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs index 15b08763..7c04110d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs @@ -82,8 +82,8 @@ public sealed class ServerHistorianOptions /// MaxTieClusterOverfetch + 1 ties at that single timestamp (a start == end read) and /// pages through them; this bounds how large a single-timestamp burst the server will buffer in /// memory before it gives up and surfaces BadTooManyOperations for that node (#686; was - /// BadHistoryOperationUnsupported). It is also the per-read row budget of a reverse raw read walked - /// back from forward reads on a backend that cannot read newest-first (the Gateway): one timestamp holding + /// BadHistoryOperationUnsupported). It is also the tie-cluster CEILING of a reverse read walked + /// back from forward reads on a backend that cannot read newest-first (the Gateway; segments are sized from the page, #689): one timestamp holding /// more samples than this is refused BadTooManyOperations. The default (65536) comfortably covers /// any realistic same-millisecond burst; a value below 1 is treated as 1. /// diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs index 05eaa14b..ae063d0d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs @@ -650,6 +650,95 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable await host.DisposeAsync(); } + /// + /// #689: a paged end-only read on a dense tag sizes each segment from the PAGE (N), not from + /// MaxTieClusterOverfetch. Live (AVEVA, 1 Hz, N = 3): from about page 5 every page read 32,770 rows and took + /// 2–5 s to return 2 rows, because the walk re-aimed its width at half the 65,536-row tie budget and carried it. + /// + [Fact] + public async Task Raw_reverse_paging_on_a_dense_forward_only_tag_reads_about_the_page_size_per_page() + { + // 100,000 samples 100 ms apart (~2.8 h): far more than the default tie budget would ever let a page read. + var series = Enumerable.Range(0, 100_000) + .Select(i => { var t = Epoch.AddMilliseconds(100L * i); return new DataValueSnapshot((double)i, StatusCodes.Good, t, t); }) + .ToArray(); + var source = new ForwardOnlyHistorianDataSource(series); + var (host, nm, nodeId) = await BootForwardOnlyAsync(source, "FwdDense689"); + nm.MaxTieClusterOverfetch.ShouldBe(65536, "the default budget — the one the live gate ran with"); + var end = Epoch.AddMilliseconds(100L * 99_999); + + byte[]? cp = null; + var expected = 99_999; + for (var page = 1; page <= 20; page++) + { + source.ResetCounts(); + var (values, error, next) = ReadRaw(nm, nodeId, DateTime.MinValue, end, max: 3, inboundCp: cp); + error.StatusCode.Code.ShouldBe(StatusCodes.Good); + values.ShouldNotBeEmpty(); + values[^1].ShouldBe((double)(expected - 2), $"page {page} continues exactly"); + expected = (int)values[^1]; + next.ShouldNotBeNull(); + cp = next; + + // The discriminating counts: rows read and the largest cap asked for, per page. Page 1 has no density yet and + // may read one discovery segment (a minute here, 600 rows); every page after it is sized from N. + var rowsBound = page == 1 ? ReverseWalk.DiscoveryRows + 2 : 64; + source.RowsReturned.ShouldBeLessThanOrEqualTo(rowsBound, $"page {page} read {source.RowsReturned} rows to return {values.Count}"); + source.MaxCapRequested.ShouldBeLessThanOrEqualTo((uint)rowsBound, $"page {page} asked for {source.MaxCapRequested} rows"); + source.ReadCount.ShouldBeLessThanOrEqualTo(3, $"page {page}"); + } + + await host.DisposeAsync(); + } + + /// + /// #689/#691: randomized end-to-end paging of end-only reads through the node manager on a forward-only backend — + /// bursts, gaps, sub-millisecond stamps and tie clusters (some larger than the page) — the union of the pages is + /// every sample exactly once, newest first, whatever N and the tie budget. + /// + [Fact] + public async Task Raw_reverse_paging_on_a_forward_only_backend_is_exact_on_random_data() + { + var (host, nm, nodeId) = await BootForwardOnlyAsync(new ForwardOnlyHistorianDataSource([]), "FwdFuzz"); + for (var seed = 0; seed < 150; seed++) + { + var rng = new Random(seed); + var series = new List(); + var t = Epoch.Ticks; + var v = 0; + var count = rng.Next(1, 300); + while (series.Count < count) + { + t += rng.Next(5) switch + { + 0 => rng.Next(1, 10), + 1 => rng.Next(1, 5) * TimeSpan.TicksPerMillisecond, + 2 => rng.Next(1, 90) * TimeSpan.TicksPerMinute, + _ => rng.Next(1, 2000) * TimeSpan.TicksPerMillisecond, + }; + var ties = rng.Next(6) == 0 ? rng.Next(2, 7) : 1; + for (var k = 0; k < ties; k++) + { + var ts = new DateTime(t, DateTimeKind.Utc); + series.Add(new DataValueSnapshot((double)v++, StatusCodes.Good, ts, ts)); + } + } + + // No fabricated start row: the Gateway source drops it (#687) — the node manager's tie-cluster point read relies on + // that. The walk itself is fuzzed WITH the row in ReverseWalkTests. + nm.HistorianDataSource = new ForwardOnlyHistorianDataSource(series, emitBoundaryRow: false); + nm.MaxTieClusterOverfetch = rng.Next(2) == 0 ? 8 : 65536; + var max = (uint)rng.Next(1, 12); + var end = new DateTime(t + rng.Next(0, 3) * TimeSpan.TicksPerSecond, DateTimeKind.Utc); + + var collected = await DrainAsync(nm, nodeId, DateTime.MinValue, end, max, maxPages: 2000); + + collected.ShouldBe(Enumerable.Range(0, series.Count).Reverse().Select(i => (double)i), $"seed {seed}, N = {max}"); + } + + await host.DisposeAsync(); + } + /// #686 review: a reverse read from StartTime = DateTime.MaxValue walks back from now + 1 day, not from /// year 9999. [Fact] @@ -978,9 +1067,15 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable /// Reads whose row count equalled the cap (a full, possibly truncated, read). public int FullReads { get; private set; } + /// Rows returned across every read since the last (#689). + public long RowsReturned { get; private set; } + + /// The largest maxValuesPerNode asked for since the last (#689). + public uint MaxCapRequested { get; private set; } + public void ResetCounts() { - lock (_gate) FullReads = 0; + lock (_gate) { FullReads = 0; RowsReturned = 0; MaxCapRequested = 0; } Interlocked.Exchange(ref _readCount, 0); } @@ -1016,8 +1111,12 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable : new DataValueSnapshot(prior.Value, prior.StatusCode, stamp, stamp)); rows.AddRange(series.Where(s => Ts(s) >= startUtc && Ts(s) <= endUtc)); var page = maxValuesPerNode == 0 ? rows : rows.Take((int)maxValuesPerNode).ToList(); - if (maxValuesPerNode > 0 && page.Count == maxValuesPerNode) - lock (_gate) FullReads++; + lock (_gate) + { + if (maxValuesPerNode > 0 && page.Count == maxValuesPerNode) FullReads++; + RowsReturned += page.Count; + if (maxValuesPerNode > MaxCapRequested) MaxCapRequested = maxValuesPerNode; + } int seen; while (page.Count > (seen = Volatile.Read(ref _maxRowsReturned)) && Interlocked.CompareExchange(ref _maxRowsReturned, page.Count, seen) != seen) { } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ReverseWalkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ReverseWalkTests.cs new file mode 100644 index 00000000..3cb0370d --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ReverseWalkTests.cs @@ -0,0 +1,194 @@ +using Opc.Ua; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// Gitea #689 / #691: the reverse walk-back ( through ) driven +/// directly, without a server: a randomized exactness check against a brute-force answer, and the sizing properties. +/// +public sealed class ReverseWalkTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static readonly DateTime T0 = new(2026, 10, 12, 18, 0, 0, DateTimeKind.Utc); + + private static DataValueSnapshot At(long ticks, double v) => + new(v, StatusCodes.Good, new DateTime(ticks, DateTimeKind.Utc), new DateTime(ticks, DateTimeKind.Utc)); + + /// + /// A randomized walk over AVEVA-shaped forward reads (inclusive end, a fabricated row at the query start — optionally + /// stamped at the start rounded UP to the millisecond — counted against the cap): every page is exactly the brute-force + /// newest-first answer over (lo, hi], ties in the backend's forward order reversed, whatever the carried width + /// — and a timestamp over the tie budget is the only refusal. + /// + [Fact] + public async Task A_walked_page_is_exactly_the_newest_N_over_the_window_on_random_data() + { + var failures = new List(); + for (var seed = 0; seed < 400; seed++) + { + var rng = new Random(seed); + var series = RandomSeries(rng, out var maxCluster); + var source = new AvevaShapedSource(series, roundUp: rng.Next(2) == 0); + var tieBudget = rng.Next(4) switch { 0 => 1, 1 => 3, 2 => 8, _ => 65536 }; + var want = (uint)rng.Next(1, 40); + var span = Ts(series[^1]) - Ts(series[0]); + var hi = Ts(series[0]) + (long)(rng.NextDouble() * 1.2 * span) + rng.Next(0, 3); + var lo = Ts(series[0]) - TimeSpan.TicksPerSecond + (long)(rng.NextDouble() * (hi - Ts(series[0])) * 0.8); + if (rng.Next(5) == 0) lo = 0; // an end-only read + var hint = rng.Next(3) switch + { + 0 => default, + 1 => new ReverseWalkHint(rng.Next(1, 10)), + _ => new ReverseWalkHint((long)(rng.NextDouble() * TimeSpan.TicksPerHour)), + }; + + var expected = series.Where(s => Ts(s) > Math.Max(lo, DateTime.UnixEpoch.Ticks) && Ts(s) <= hi) + .Select((s, i) => (s, i)).OrderByDescending(x => Ts(x.s)).ThenByDescending(x => x.i) + .Take((int)want).Select(x => (double)x.s.Value!).ToList(); + + try + { + var page = await ReverseRawReader.ReadAsync(source, "T", new DateTime(hi, DateTimeKind.Utc), + new DateTime(lo, DateTimeKind.Utc), want, tieBudget, T0.AddDays(1), hint, Ct); + var got = page.Samples.Select(s => (double)s.Value!).ToList(); + if (!got.SequenceEqual(expected)) + failures.Add($"seed {seed}: got [{string.Join(",", got)}] expected [{string.Join(",", expected)}]"); + if (page.Hint.WidthTicks <= 0) failures.Add($"seed {seed}: no width carried"); + } + catch (HistoryReadLimitExceededException) + { + if (maxCluster <= tieBudget) failures.Add($"seed {seed}: refused with no cluster over {tieBudget} (max {maxCluster})"); + } + + if (source.MaxCap > 2L * Math.Max(Math.Max(tieBudget, ReverseWalk.DiscoveryRows), 2 * want + 16) + 4) + failures.Add($"seed {seed}: asked for {source.MaxCap} rows"); + if (source.BadRequests > 0) failures.Add($"seed {seed}: {source.BadRequests} invalid requests"); + } + + failures.ShouldBeEmpty(); + } + + /// + /// #689: the carried width sizes a resumed page from N, not from the tie budget — the live symptom was 32,770 rows + /// per page for N = 3 on a 1 Hz tag once the width had grown over a few pages. + /// + [Fact] + public async Task A_resumed_page_on_a_dense_tag_reads_about_N_rows_with_the_default_tie_budget() + { + var series = Enumerable.Range(0, 200_000).Select(i => At(T0.Ticks + i * TimeSpan.TicksPerSecond, i)).ToList(); + var source = new AvevaShapedSource(series); + var hi = new DateTime(Ts(series[^1]), DateTimeKind.Utc); + var hint = default(ReverseWalkHint); + + for (var page = 1; page <= 30; page++) + { + source.Reset(); + var result = await ReverseRawReader.ReadAsync(source, "T", hi, DateTime.MinValue, 3, 65536, hi.AddHours(1), hint, Ct); + result.Samples.Count.ShouldBe(3); + hint = result.Hint; + hi = result.Samples[^1].SourceTimestampUtc!.Value.AddTicks(-1); + if (page > 1) source.RowsReturned.ShouldBeLessThanOrEqualTo(30, $"page {page}"); + } + } + + /// + /// #689: a dense tag that stopped long ago. The walk's ×4 growth across the empty stretch overshoots into far more + /// rows than one read takes; the truncated read only shows the OLDEST of them, so the walk must not keep re-entering + /// that stretch — it bisects the gap above the newest row it saw. + /// + [Fact] + public async Task A_dense_tag_below_a_long_empty_stretch_is_found_in_a_bounded_number_of_reads() + { + // 10 Hz for 3 hours, ending two days before "now". + var start = T0.AddDays(-2).AddHours(-3); + var series = Enumerable.Range(0, 108_000).Select(i => At(start.Ticks + i * TimeSpan.TicksPerMillisecond * 100, i)).ToList(); + var source = new AvevaShapedSource(series); + + var page = await ReverseRawReader.ReadAsync(source, "T", T0, DateTime.MinValue, 3, 65536, T0, default(ReverseWalkHint), Ct); + + page.Samples.Select(s => (double)s.Value!).ShouldBe([107_999d, 107_998d, 107_997d]); + source.Reads.ShouldBeLessThanOrEqualTo(25); + source.RowsReturned.ShouldBeLessThanOrEqualTo(25 * (ReverseWalk.DiscoveryRows + 2)); + } + + private static long Ts(DataValueSnapshot s) => s.SourceTimestampUtc!.Value.Ticks; + + /// Random ascending series: bursts, gaps, sub-millisecond stamps and tie clusters. + private static List RandomSeries(Random rng, out int maxCluster) + { + var list = new List(); + var t = T0.Ticks - rng.Next(0, 3) * TimeSpan.TicksPerDay; + var count = rng.Next(1, 400); + maxCluster = 1; + var v = 0; + while (list.Count < count) + { + t += rng.Next(6) switch + { + 0 => rng.Next(1, 10), // sub-millisecond + 1 => rng.Next(1, 5) * TimeSpan.TicksPerMillisecond, // dense + 2 => rng.Next(1, 3) * TimeSpan.TicksPerSecond, + 3 => rng.Next(1, 120) * TimeSpan.TicksPerMinute, // a gap + _ => rng.Next(1, 1000) * TimeSpan.TicksPerMillisecond, + }; + var ties = rng.Next(8) == 0 ? rng.Next(2, 12) : 1; + maxCluster = Math.Max(maxCluster, ties); + for (var k = 0; k < ties; k++) list.Add(At(t, v++)); + } + + return list; + } + + /// AVEVA-shaped forward-only raw source (see the fuzz test's summary). + private sealed class AvevaShapedSource(IReadOnlyList series, bool roundUp = false) : IHistorianDataSource + { + public long MaxCap { get; private set; } + public long RowsReturned { get; private set; } + public int Reads { get; private set; } + public int BadRequests { get; private set; } + + public void Reset() { MaxCap = 0; RowsReturned = 0; Reads = 0; } + + public bool SupportsReverseRaw => false; + + public Task ReadRawAsync(string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, + CancellationToken cancellationToken) + { + Reads++; + if (startUtc > endUtc || maxValuesPerNode == 0 || startUtc < DateTime.UnixEpoch) BadRequests++; + MaxCap = Math.Max(MaxCap, maxValuesPerNode); + var stamp = roundUp && startUtc.Ticks % TimeSpan.TicksPerMillisecond != 0 + ? new DateTime(startUtc.Ticks - startUtc.Ticks % TimeSpan.TicksPerMillisecond + TimeSpan.TicksPerMillisecond, DateTimeKind.Utc) + : startUtc; + var prior = series.LastOrDefault(s => s.SourceTimestampUtc <= startUtc); + var rows = new List + { + prior is null + ? new DataValueSnapshot(-1d, StatusCodes.BadNoData, stamp, stamp) + : new DataValueSnapshot(-2d, prior.StatusCode, stamp, stamp), + }; + rows.AddRange(series.Where(s => s.SourceTimestampUtc >= startUtc && s.SourceTimestampUtc <= endUtc)); + var page = rows.Take((int)Math.Min(int.MaxValue, maxValuesPerNode)).ToList(); + RowsReturned += page.Count; + return Task.FromResult(new HistorianRead(page, null)); + } + + public Task ReadProcessedAsync(string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, + HistoryAggregateType aggregate, CancellationToken cancellationToken) => throw new NotSupportedException(); + + public Task ReadAtTimeAsync(string fullReference, IReadOnlyList timestampsUtc, + CancellationToken cancellationToken) => throw new NotSupportedException(); + + public Task ReadEventsAsync(string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, + CancellationToken cancellationToken) => throw new NotSupportedException(); + + public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot(); + + public void Dispose() { } + } +}