From d33992034dc35ccc36c96cc2e78528545954bcdb Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 12 Oct 2026 16:25:07 -0400 Subject: [PATCH 1/7] 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() { } + } +} From 186b7d5ea5b1badb0b8d363f80351528c2d7f116 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 12 Oct 2026 16:32:00 -0400 Subject: [PATCH 2/7] fix(historian): #691.5 a reverse walk proves the rest empty once and carries it page to page The last pages of a deep reverse page-through on a forward-only backend each walked down to 1970 one x4 growth step at a time (~14-16 reads, ~2.5 s live). After two empty segments the walk now asks once per page for the whole remainder at the page budget (a truncated answer is discarded and the walk carries on), and the stretch the page's last complete read proved empty rides the continuation point (HistoryContinuationState.EmptyFromTicks/EmptyToTicks, session-held, so no versioning) so the next page steps over it. Fuzz now pages with the walk's own hints and with true random empty stretches. --- CLAUDE.md | 2 +- docs/Historian.md | 10 +- .../HistoryPaging.cs | 15 ++- .../OtOpcUaNodeManager.cs | 14 +-- .../ReverseWalk.cs | 81 ++++++++++++++-- .../NodeManagerHistoryReadPagingTests.cs | 32 ++++++ .../ReverseWalkTests.cs | 97 ++++++++++++++++--- 7 files changed, 222 insertions(+), 29 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index af88cdb5..ce8f2845 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 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". +- ⚠️ **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; #691: after two empty segments it asks once for the whole remainder, and the stretch a page proved empty rides the continuation point — the last pages of a deep page-through no longer each walk ~14 reads to 1970). 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 cac9ea6a..82b68546 100644 --- a/docs/Historian.md +++ b/docs/Historian.md @@ -659,11 +659,19 @@ backend: 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. + - **Proving the rest empty (#691).** Walking down through empty history to the floor used to cost one read per ×4 + growth step — ~14–16 reads (~2.5 s live) on **every** last page of a deep page-through. Now, after two consecutive + empty segments, the walk asks **once per page** for the whole remainder down to the floor 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. And + the stretch the page's last complete read proved empty below its oldest row (often all the way to the floor) rides + the continuation point, so the next page steps straight over it. Data backfilled into such a stretch between pages + is not seen by that read — paging was never a snapshot (the forward path misses data inserted behind its cursor too). - **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. - Paging is unchanged — a reverse cursor resumes as a fresh walk from its resume point. + Paging is otherwise unchanged — a reverse cursor resumes as a fresh walk from its resume point, carrying the width and + the proven-empty stretch (session-held cursor state, never serialized, so the new fields need no versioning). - **Null** reports `true` (empty in either direction). > ⚠️ **Client-visible (#686).** Since #669 an end-only capped raw read, and any explicit reverse raw read, on the Gateway diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs index fe825f0e..595affbb 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs @@ -55,6 +55,13 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; /// 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. internal sealed record HistoryContinuationState( string Tagname, DateTime NextStartUtc, @@ -65,7 +72,13 @@ internal sealed record HistoryContinuationState( long LastModifiedId = 0, DateTime WindowStartUtc = default, bool Reverse = false, - long SegmentWidthTicks = 0); + long SegmentWidthTicks = 0, + long EmptyFromTicks = 0, + long EmptyToTicks = 0) +{ + /// 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 diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 7cecc9b4..6b6c5dca 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -6184,7 +6184,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { DateTime startUtc; var boundarySkip = 0; - long segmentWidth = 0; // #686: the walk-back's width, carried page to page + var walkHint = default(ReverseWalkHint); // #686/#689/#691: the walk-back's width + proven-empty stretch, page to page string tagname; if (inboundCp is { Length: > 0 }) @@ -6205,7 +6205,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 boundarySkip = state.BoundarySkipCount; endUtc = state.EndUtc; numValuesPerNode = state.NumValuesPerNode; - segmentWidth = state.SegmentWidthTicks; + walkHint = state.WalkHint; } else { @@ -6243,9 +6243,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { var page = await ReverseRawReader.ReadAsync( source, tagname, startUtc, endUtc, numValuesPerNode, MaxTieClusterOverfetch, - Clock.GetUtcNow().UtcDateTime, segmentWidth, ct) + Clock.GetUtcNow().UtcDateTime, walkHint, ct) .ConfigureAwait(false); - segmentWidth = page.WidthHintTicks; + walkHint = page.Hint; sourceResult = new HistorianRead(page.Samples, ContinuationPoint: null); } else @@ -6324,7 +6324,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (nextStartUtc is { } resumeAt) { var clusterState = new HistoryContinuationState( - tagname, resumeAt, endUtc, nextSkip, numValuesPerNode, SegmentWidthTicks: segmentWidth); + tagname, resumeAt, endUtc, nextSkip, numValuesPerNode, SegmentWidthTicks: walkHint.WidthTicks, + EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks); clusterCp = _historyContinuationStore.Save(session, clusterState); } @@ -6347,7 +6348,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { HistoryPaging.ComputeResumeCursor(samples, out var nextStart, out var skip); var nextState = new HistoryContinuationState( - tagname, nextStart, endUtc, skip, numValuesPerNode, SegmentWidthTicks: segmentWidth); + tagname, nextStart, endUtc, skip, numValuesPerNode, SegmentWidthTicks: walkHint.WidthTicks, + EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks); // Save may return null (no session on this request) ⇒ degrade to single-shot for this node. // Built AFTER historyData so a failure projecting samples can never orphan a stored cursor. outboundCp = _historyContinuationStore.Save(session, nextState); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseWalk.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseWalk.cs index 94d1ecfa..f68f167e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseWalk.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseWalk.cs @@ -2,7 +2,17 @@ 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); +/// +/// 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. @@ -46,6 +56,15 @@ internal readonly record struct ForwardSegmentRead(IReadOnlyList Rows, boo /// fit at the tie budget — is refused. /// /// +/// 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 @@ -141,14 +160,27 @@ internal static class ReverseWalk var knownWidth = 0L; 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) { - var w = width; + 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; 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 segBudget = discovering && !probe ? discoveryBudget : pageBudget; var cap = segBudget + 2; IReadOnlyList rows; var narrowed = false; @@ -160,6 +192,14 @@ internal static class ReverseWalk rows = segment.Rows; if (!segment.Truncated || rows.Count == 0 || ticksOf(rows[^1]) > segHi) break; // complete for (segLo, segHi] + if (probe) + { + // Deep history below: the probe saw only its oldest rows. Carry on walking from the same segHi. + probed = true; + narrowed = true; + break; + } + var leading = 0; var inner = 0; var firstInner = 0L; @@ -226,8 +266,18 @@ internal static class ReverseWalk 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 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) { @@ -253,13 +303,31 @@ internal static class ReverseWalk } } - return (result, new ReverseWalkHint(width)); + // 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) + 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) { @@ -278,6 +346,7 @@ internal static class ReverseWalk 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]); } 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 ae063d0d..b25afb52 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs @@ -739,6 +739,38 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable await host.DisposeAsync(); } + /// + /// #691: the last pages of a deep reverse page-through no longer each walk down to 1970 (live: ~14–16 reads, ~2.5 s + /// per page). The stretch a page proved empty below its oldest row rides the continuation point, and a page that runs + /// out of rows asks once for the whole remainder — so every page here is a couple of reads. + /// + [Fact] + public async Task Raw_reverse_page_through_on_a_forward_only_backend_does_not_rewalk_to_the_epoch_on_each_last_page() + { + var source = new ForwardOnlyHistorianDataSource(MakeSeries(count: 25, stepSeconds: 1)); + var (host, nm, nodeId) = await BootForwardOnlyAsync(source, "FwdDeep691"); + nm.Clock = new FixedClock(Epoch.AddHours(1)); + + var collected = new List(); + byte[]? cp = null; + var page = 0; + do + { + source.ResetCounts(); + var (values, error, next) = ReadRaw(nm, nodeId, DateTime.MinValue, Epoch.AddHours(1), max: 10, inboundCp: cp); + error.StatusCode.Code.ShouldBe(StatusCodes.Good); + collected.AddRange(values); + cp = next; + page++; + source.ReadCount.ShouldBeLessThanOrEqualTo(page == 1 ? 4 : 1, $"page {page}"); + } + while (cp is not null && page < 10); + + collected.ShouldBe(Enumerable.Range(0, 25).Reverse().Select(i => (double)i)); + source.MinRequestedStart.ShouldBeGreaterThanOrEqualTo(DateTime.UnixEpoch); + await host.DisposeAsync(); + } + /// #686 review: a reverse read from StartTime = DateTime.MaxValue walks back from now + 1 day, not from /// year 9999. [Fact] diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ReverseWalkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ReverseWalkTests.cs index 3cb0370d..d44c20c2 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ReverseWalkTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ReverseWalkTests.cs @@ -46,23 +46,44 @@ public sealed class ReverseWalkTests 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 + if (rng.Next(2) == 0) { - 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"); + // A TRUE empty stretch (#691): between two adjacent distinct timestamps, or from the floor to the first row. + var k = rng.Next(series.Count); + var from = k == 0 ? (rng.Next(2) == 0 ? 0 : DateTime.UnixEpoch.Ticks) : Ts(series[k - 1]); + var to = Ts(series[k]); + if (to > from) hint = hint with { EmptyFromTicks = from, EmptyToTicks = to }; } - catch (HistoryReadLimitExceededException) + + // Page on: each next page resumes just below the previous page's oldest timestamp with the hint it returned — + // so the hints the walk itself produces (width, proven-empty stretch) are exercised too. + for (var pageNo = 0; pageNo < 6 && hi > Math.Max(lo, DateTime.UnixEpoch.Ticks); pageNo++) { - if (maxCluster <= tieBudget) failures.Add($"seed {seed}: refused with no cluster over {tieBudget} (max {maxCluster})"); + 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} page {pageNo}: got [{string.Join(",", got)}] expected [{string.Join(",", expected)}]"); + break; + } + + if (page.Hint.WidthTicks <= 0) failures.Add($"seed {seed}: no width carried"); + if (got.Count < want) break; + hint = page.Hint; + hi = Ts(page.Samples[^1]) - 1; + } + catch (HistoryReadLimitExceededException) + { + if (maxCluster <= tieBudget) failures.Add($"seed {seed}: refused with no cluster over {tieBudget} (max {maxCluster})"); + break; + } } if (source.MaxCap > 2L * Math.Max(Math.Max(tieBudget, ReverseWalk.DiscoveryRows), 2 * want + 16) + 4) @@ -116,6 +137,54 @@ public sealed class ReverseWalkTests source.RowsReturned.ShouldBeLessThanOrEqualTo(25 * (ReverseWalk.DiscoveryRows + 2)); } + /// + /// #691: the end of a walk proves the rest of the window empty in about one read — after two empty segments the walk + /// asks once for the whole remainder down to the floor — instead of one read per ×4 growth step to 1970 (~14 reads). + /// + [Fact] + public async Task Running_out_of_rows_proves_the_rest_of_the_window_empty_in_a_few_reads() + { + var series = Enumerable.Range(0, 5).Select(i => At(T0.Ticks + i * TimeSpan.TicksPerSecond, i)).ToList(); + var source = new AvevaShapedSource(series); + + var page = await ReverseRawReader.ReadAsync(source, "T", T0.AddSeconds(4), DateTime.MinValue, 10, 65536, T0, + default(ReverseWalkHint), Ct); + + page.Samples.Select(s => (double)s.Value!).ShouldBe([4d, 3d, 2d, 1d, 0d]); + source.Reads.ShouldBeLessThanOrEqualTo(4, "one segment holding the rows, two empty ones, then the remainder at once"); + } + + /// + /// #691: a page's last complete read proves the stretch below its oldest row empty, and the continuation carries it: + /// the next page steps straight over it to the floor instead of walking it again. + /// + [Fact] + public async Task A_resumed_page_steps_over_the_stretch_the_previous_page_proved_empty() + { + var series = Enumerable.Range(0, 25).Select(i => At(T0.Ticks + i * TimeSpan.TicksPerSecond, i)).ToList(); + var source = new AvevaShapedSource(series); + var now = T0.AddHours(1); + + // Page 1: an hour of nothing above the rows; the remainder probe reads all 25 and proves (1970, row 0) empty. + var p1 = await ReverseRawReader.ReadAsync(source, "T", now, DateTime.MinValue, 10, 65536, now, default(ReverseWalkHint), Ct); + p1.Samples.Select(s => (double)s.Value!).ShouldBe(Enumerable.Range(15, 10).Reverse().Select(i => (double)i)); + p1.Hint.HasEmptyStretch.ShouldBeTrue(); + p1.Hint.EmptyFromTicks.ShouldBeLessThanOrEqualTo(DateTime.UnixEpoch.Ticks); + p1.Hint.EmptyToTicks.ShouldBe(Ts(series[0])); + + var hint = p1.Hint; + var hi = p1.Samples[^1].SourceTimestampUtc!.Value.AddTicks(-1); + for (var page = 2; page <= 3; page++) + { + source.Reset(); + var p = await ReverseRawReader.ReadAsync(source, "T", hi, DateTime.MinValue, 10, 65536, now, hint, Ct); + p.Samples.Count.ShouldBe(page == 2 ? 10 : 5); + source.Reads.ShouldBe(1, $"page {page} reads its rows and steps over the proven-empty stretch to the floor"); + hint = p.Hint; + hi = p.Samples[^1].SourceTimestampUtc!.Value.AddTicks(-1); + } + } + private static long Ts(DataValueSnapshot s) => s.SourceTimestampUtc!.Value.Ticks; /// Random ascending series: bursts, gaps, sub-millisecond stamps and tie clusters. From d277f34a96d23422c90c716b3233a5defbbcf50b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 12 Oct 2026 16:48:05 -0400 Subject: [PATCH 3/7] fix(historian): #691.4 walk reverse events reads back on forward-only backends (Gateway) A reverse or end-only capped events read on a backend without SupportsReverseEvents (Gateway, Null) was refused BadHistoryOperationUnsupported (explicit reverse) or answered with the OLDEST N ascending (end-only, #675). It is now served newest-first by the same ReverseWalk as raw reads: page-sized ms-aligned segments, never answered from a read the source flagged truncated, ties sorted by EventId (the gateway orders by event time only - the fake shuffles them per call), one extra event walked to signal 'more', uncapped = one forward read reversed, and the width + proven-empty stretch carried in the events cursor. The oversized-cluster over-fetch keeps exactly the events at T in the same order. A tie cluster over the budget is BadTooManyOperations. Docs note the gateway's EventReadMaxRows must be unlimited (it truncates silently). --- CLAUDE.md | 2 +- docs/Historian.md | 50 +++--- .../Historian/IHistorianDataSource.cs | 12 +- .../Historian/NullHistorianDataSource.cs | 4 +- .../GatewayHistorianDataSource.cs | 12 +- .../OtOpcUaNodeManager.cs | 81 ++++++--- .../ReverseWalk.cs | 15 ++ .../GatewayEventBoundsTests.cs | 2 +- ...NodeManagerHistoryReadEventsPagingTests.cs | 155 ++++++++++++++---- 9 files changed, 251 insertions(+), 82 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ce8f2845..e8a24343 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 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; #691: after two empty segments it asks once for the whole remainder, and the stretch a page proved empty rides the continuation point — the last pages of a deep page-through no longer each walk ~14 reads to 1970). 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; #691: after two empty segments it asks once for the whole remainder, and the stretch a page proved empty rides the continuation point — the last pages of a deep page-through no longer each walk ~14 reads to 1970). 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 follow since #691.4: a reverse/end-only capped events read on Gateway/Null (`SupportsReverseEvents` false) is walked back the same way (ties sorted by EventId — the gateway orders by time only; one extra event found ⇒ continuation point) — it used to be refused `BadHistoryOperationUnsupported` (explicit reverse) or answered with the OLDEST N ascending (end-only); needs the gateway's `RuntimeDb:EventReadMaxRows` 0/unlimited (it truncates silently). `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 82b68546..c14a98ff 100644 --- a/docs/Historian.md +++ b/docs/Historian.md @@ -677,8 +677,7 @@ backend: > ⚠️ **Client-visible (#686).** Since #669 an end-only capped raw read, and any explicit reverse raw read, on the Gateway > backend failed the node (`BadHistoryOperationUnsupported`) and was counted as a historian backend failure (the gateway > refused `start > end`). Before #669 an end-only read on Gateway returned the **oldest** N values ascending. Both now return -> the Part 11 answer — the latest values at or before `EndTime`, newest first. **Events differ:** an end-only events read on -> Gateway still keeps its forward meaning and an explicit reverse events read is refused (see below). +> the Part 11 answer — the latest values at or before `EndTime`, newest first. **Events** follow since #691.4 (see below). > > ⚠️ **Also fixed with #686: uncapped raw reads on Gateway.** `NumValuesPerNode = 0` ("all values") used to be forwarded as > `max_values = 0`, which the client package and the gateway reject — every uncapped raw read (forward too) failed the node @@ -763,30 +762,43 @@ says is complete carries no continuation point. `NumValuesPerNode == 0` stays un that many more rows than the cap, so every page short of the last is a FULL `NumValuesPerNode` page (#675; resumed pages used to come back short by the skip). - A tie cluster larger than the cap is paged within by over-fetching the half-open window `[T, T + resolution)` up to - `MaxTieClusterOverfetch`; beyond that bound the node answers `BadHistoryOperationUnsupported` (raise the bound). + `MaxTieClusterOverfetch`; beyond that bound the node answers `BadTooManyOperations` (raise the bound). - **Tie exactness depends on the backend.** SqlServer and QuestDB order by `(EventTimeUtc, EventId)`, so the skip count is exact. - The Gateway backend has no stable intra-timestamp order (and only a truncation flag), so ties at a page boundary are - best-effort there. + The Gateway backend has no stable intra-timestamp order (and only a truncation flag), so ties at a page boundary of a + **forward** read are best-effort there; a walked-back **reverse** read (below) sorts them by `EventId` itself and is exact. - **Reverse event reads page newest-first (#675).** `StartTime > EndTime` reads the Part 11 reverse window `(EndTime, StartTime]` with events in DESCENDING `(EventTimeUtc, EventId)` order, so a cap keeps the **latest** N and a continuation point is issued when the source flags more. The direction is stored **in the cursor**: a reverse point resumes reverse (and a forward one forward) whatever the resume request's details say. Reverse tie clusters are over-fetched forward and sliced from the newest end, exactly as Raw does. Before #675 a capped reverse read silently returned the EARLIEST N, ascending, Good, with no continuation point. -- **End-only events reads: backward on SqlServer/QuestDB, forward on Gateway (#675).** `StartTime` unset - (`DateTime.MinValue`) with an `EndTime` and `NumValuesPerNode > 0` pages backward from `EndTime` over `(MinValue, EndTime]` — - the same rule as Raw — **only on a backend that reports `SupportsReverseEvents`** (SqlServer, QuestDB). On Gateway (the - default backend) and Null it keeps its pre-#675 forward `[MinValue, EndTime)` meaning, paged oldest-first, rather than - becoming a refused reverse read. Start-only reads page forward to an open upper bound (`DateTime.MaxValue`, UTC) everywhere. - Without a cap an end-only read is forward `[MinValue, EndTime)` everywhere. -- **Backends.** The server checks `IHistorianDataSource.SupportsReverseEvents` and then calls - `ReadEventsAsync(…, bool reverse, …)`. SqlServer and QuestDB serve reverse natively (`… > lo AND … <= hi ORDER BY time DESC, - id DESC`) and report `true`. **Gateway and Null report `false`**: an explicit reverse read (`StartTime > EndTime`) is answered - `BadHistoryOperationUnsupported` **without calling the backend** — the gateway streams ascending only and rejects `end < start`, - and draining the whole window to keep its tail would be exactly the unbounded read the cap prevents. Because the refusal is a - capability check, not a caught exception, a `NotSupportedException` thrown from inside a backend (a driver library) is logged - as a backend fault like any other. Every implementation (and any future wrapper) must declare both members itself — the - interface defaults (`false` / throw) would silently disable reverse reads; `HistorianDataSourceReverseEventsGuardTests` pins it. +- **End-only events reads page backward on every backend (#675, #691.4).** `StartTime` unset (`DateTime.MinValue`) with an + `EndTime` and `NumValuesPerNode > 0` pages backward from `EndTime` over `(MinValue, EndTime]` — the same rule as Raw. + Start-only reads page forward to an open upper bound (`DateTime.MaxValue`, UTC) everywhere. Without a cap an end-only read + is forward `[MinValue, EndTime)` everywhere. +- **Backends.** The server checks `IHistorianDataSource.SupportsReverseEvents`. SqlServer and QuestDB serve reverse natively + (`… > lo AND … <= hi ORDER BY time DESC, id DESC`, the `ReadEventsAsync(…, bool reverse, …)` overload) and report `true`. + **Gateway and Null report `false`** — the gateway streams ascending only and rejects `end < start` — and are never sent a + reverse read: since **#691.4** the server walks it back from bounded **forward** reads with the raw walk (#686/#689/#691: + page-sized ms-aligned segments filtered to `(segLo, segHi]`, a read the source flagged truncated is never answered from, + the now + 1 day / 1970 bounds, the remainder probe and the proven-empty stretch carried in the cursor). Event specifics: + - **Ties** are sorted by `EventId` (ordinal) on every read — the gateway orders by `EventTimeUtc` only, in any order within a + timestamp — so the page is DESCENDING `(EventTimeUtc, EventId)` and a resumed page's boundary skip lines up; the + oversized-cluster over-fetch keeps exactly the events at `T` (the gateway's end is inclusive) in the same order. + - **"More exists"** is found by walking for one event past the page (the walk has no source truncation flag to hand on). + - **Uncapped** reverse reads are one forward read of the window, sorted and reversed. + - A timestamp holding more than `MaxTieClusterOverfetch` events ⇒ **`BadTooManyOperations`** for that node. + - ⚠️ **Gateway prerequisite:** the gateway's `RuntimeDb:EventReadMaxRows` must be `0` (its default, unlimited) or larger than + the walk's biggest read (`2N + 18` rows, a discovery read 1,026, a tie-cluster read up to `2·MaxTieClusterOverfetch + 4`). + That cap truncates **silently** (the stream just ends), so a segment it cut would look complete and drop its newest events — + the same blindness a forward read has to it, but on the end a reverse read is asking for. + + ⚠️ **Client-visible (#691.4).** On Gateway (the default backend) an explicit reverse events read used to be refused + `BadHistoryOperationUnsupported` (#675), and an end-only capped events read returned the **oldest** N events ascending. Both + now return the latest events newest-first. Because the capability is checked, not a caught exception, a + `NotSupportedException` thrown from inside a backend (a driver library) is still logged as a backend fault. Every + implementation (and any future wrapper) must declare both members itself — the interface defaults (`false` / throw) would + silently send a native-reverse backend through the walk; `HistorianDataSourceReverseEventsGuardTests` pins it. - **Gateway open upper bound.** The `DateTime.MaxValue` (UTC) bound of a start-only forward read is pinned offline through the published client's proto `Timestamp` conversion (captured by an in-process gRPC server) and the FILETIME encoding (`GatewayEventBoundsTests`). What a live AVEVA Historian does with a year-9999 bound is still unverified (needs the VPN). diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs index 926881c1..3b9c1b97 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs @@ -120,9 +120,9 @@ public interface IHistorianDataSource : IDisposable /// /// Whether this backend can serve a reverse (newest-first) events read — the reverse overload of /// (Gitea #675). The server - /// checks it BEFORE calling the source: an explicit reverse read (StartTime > EndTime) on a backend that - /// cannot is answered BadHistoryOperationUnsupported without a call, and an end-only capped read, which Part 11 - /// reads backward, keeps its forward [MinValue, EndTime) meaning there. Default false; an + /// checks it BEFORE calling the source: a reverse read (explicit StartTime > EndTime, or an end-only capped + /// read, which Part 11 reads backward) on a backend that cannot is never sent the reverse overload — since Gitea + /// #691.4 the server walks it back from bounded FORWARD reads instead (#675 refused it). Default false; an /// implementation (or a wrapper) declares it itself — a guard test pins that. /// bool SupportsReverseEvents => false; @@ -144,9 +144,9 @@ public interface IHistorianDataSource : IDisposable /// /// The default forwards a forward read to the five-argument overload and throws /// for a reverse one — a backend that cannot read newest-first (Gateway, - /// Null). The server never relies on that throw: it consults first and - /// answers BadHistoryOperationUnsupported without calling, rather than returning the earliest events in - /// ascending order as if they were the answer. A backend that serves reverse must override BOTH members. + /// Null). The server never relies on that throw: it consults first and walks + /// such a read back from forward reads (#691.4), never returning the earliest events in ascending order as if + /// they were the answer. A backend that serves reverse natively must override BOTH members. /// /// /// The source name to filter events, or null to return events from all sources. diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/NullHistorianDataSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/NullHistorianDataSource.cs index 769f1d4e..9d97b317 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/NullHistorianDataSource.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/NullHistorianDataSource.cs @@ -61,8 +61,8 @@ public sealed class NullHistorianDataSource : IHistorianDataSource public bool SupportsReverseRaw => true; /// - /// false (Gitea #675): there is no history to read in either direction, and an explicit reverse read - /// is answered BadHistoryOperationUnsupported like any backend that cannot read newest-first. + /// false (Gitea #675): there is no history to read in either direction; the server walks a reverse + /// read back from (empty) forward reads like any backend that cannot read newest-first (#691.4). public bool SupportsReverseEvents => false; /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs index 5685ed32..b25790be 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs @@ -393,8 +393,9 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis /// /// - /// false: the gateway's ReadEvents streams ascending only (Gitea #675). The server therefore refuses an - /// explicit reverse events read without calling, and keeps an end-only capped read forward. + /// false: the gateway's ReadEvents streams ascending only (Gitea #675). The server never sends it a + /// reverse read; since #691.4 it walks one back from bounded forward reads (ties sorted by EventId — the gateway + /// orders by event time only). /// public bool SupportsReverseEvents => false; @@ -404,10 +405,9 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis /// /// - /// A reverse (newest-first) read is refused with — the server reports - /// BadHistoryOperationUnsupported (Gitea #675). The gateway's ReadEvents streams ascending only and - /// rejects end < start, so the only way to serve "the latest N" would be to drain the whole window and - /// keep its tail, an unbounded read the cap exists to prevent. Nothing is sent. + /// A reverse (newest-first) read is refused with (Gitea #675) — the gateway's + /// ReadEvents streams ascending only and rejects end < start. The server never calls this with + /// reverse: it walks a reverse read back from bounded forward reads (#691.4). Nothing is sent. /// public Task ReadEventsAsync( string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, bool reverse, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 6b6c5dca..57ab3fc8 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -5248,6 +5248,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 var endUtc = details.EndTime; var numValuesPerNode = details.NumValuesPerNode; var boundarySkip = 0; + var walkHint = default(ReverseWalkHint); // #691.4: a walked-back read's width + proven-empty stretch bool reverse; if (resumed) @@ -5271,35 +5272,30 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // The direction is the CURSOR's (Gitea #675), never re-derived from the resume request's details: a // reverse read resumes reverse, a forward one forward. reverse = state.Reverse; + walkHint = state.WalkHint; } else { // Part 11: with one bound unset, an unset EndTime (MinValue) with a StartTime means "page FORWARD from // StartTime" — normalise it to an open upper bound, or the cursor would store MinValue and the read would // look reversed. An unset StartTime with an EndTime and a count cap means "page BACKWARD from EndTime" - // (#675, mirroring raw): the reverse window (MinValue, EndTime] — but only on a backend that can read - // newest-first. Elsewhere (Gateway, the default backend) and without a count cap an end-only read keeps its - // historical forward [MinValue, EndTime) meaning rather than becoming a refused reverse read. + // (#675, mirroring raw): the reverse window (MinValue, EndTime] — on every backend since #691.4 (a backend + // that cannot read newest-first is walked back below). Without a count cap an end-only read keeps its + // forward [MinValue, EndTime) meaning. if (endUtc == DateTime.MinValue && startUtc != DateTime.MinValue) endUtc = new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc); // Utc kind: a proto Timestamp conversion rejects Unspecified - else if (numValuesPerNode > 0 && startUtc == DateTime.MinValue && endUtc != DateTime.MinValue - && source.SupportsReverseEvents) + else if (numValuesPerNode > 0 && startUtc == DateTime.MinValue && endUtc != DateTime.MinValue) (startUtc, endUtc) = (endUtc, new DateTime(0, DateTimeKind.Utc)); // Part 11: StartTime later than EndTime is a reverse read — events newest-first over (end, start]. reverse = startUtc > endUtc; } - // An explicit reverse read (or a reverse cursor resumed after the source was swapped) on a backend that cannot - // read newest-first: refuse it BEFORE calling the source — never the earliest events in ascending order as if - // they were the answer. Checked here rather than by catching an exception, so a NotSupportedException thrown - // from inside a backend (a driver library, the projection) stays a backend fault. - if (reverse && !source.SupportsReverseEvents) - { - errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported; - results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported }; - return; - } + // A reverse read on a backend that cannot read newest-first (Gateway, Null) is never sent there (#675 refused it + // here); since #691.4 it is walked back from bounded forward reads — the raw walk (#686), with ties ordered by + // EventId because the gateway orders by event time only. Decided by the capability, not by catching an + // exception, so a NotSupportedException thrown from inside a backend stays a backend fault. + var walked = reverse && !source.SupportsReverseEvents; // NOT under the node-manager Lock — awaiting the async source is safe here. // NumValuesPerNode==0 means "no limit — return ALL values" per OPC UA Part 4/11, so it is translated @@ -5309,8 +5305,39 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 var fetch = EventMaxEvents(numValuesPerNode); if (numValuesPerNode > 0 && boundarySkip > 0) fetch = (int)Math.Min(int.MaxValue - 1L, (long)fetch + boundarySkip); - var sourceResult = await source.ReadEventsAsync( - sourceName, startUtc, endUtc, fetch, reverse, ct).ConfigureAwait(false); + HistoricalEventsResult sourceResult; + if (walked) + { + // Walk for one more than the fetch: finding it is the "more events exist" signal a source gives with its + // continuation point (the walk itself cannot flag truncation). + var want = numValuesPerNode == 0 ? 0u : (uint)Math.Min(uint.MaxValue - 1L, (long)fetch + 1); + var budget = Math.Max(1, MaxTieClusterOverfetch); + var walkSource = sourceName; + var (rows, next) = await ReverseWalk.WalkAsync( + async (s, e, cap, token) => + { + var r = await source.ReadEventsAsync(walkSource, s, e, cap == 0 ? int.MaxValue : cap, reverse: false, token) + .ConfigureAwait(false); + return new ForwardSegmentRead(r.Events, r.ContinuationPoint is not null); + }, + ev => ev.EventTimeUtc.Ticks, EventIdTieOrder.Instance, startUtc, endUtc, want, budget, + source.TimestampResolutionTicks, Clock.GetUtcNow().UtcDateTime, walkHint, + at => new HistoryReadLimitExceededException(budget, + $"A reverse events read of '{walkSource}' at {at:O} found more than {budget} events at one " + + "timestamp; the backend reads forward only, so the latest of them cannot be selected. Increase " + + "ServerHistorian:MaxTieClusterOverfetch."), + ct) + .ConfigureAwait(false); + walkHint = next; + var more = want > 0 && rows.Count > fetch; + if (more) rows.RemoveRange(fetch, rows.Count - fetch); + sourceResult = new HistoricalEventsResult(rows, more ? [0x01] : null); + } + else + { + sourceResult = await source.ReadEventsAsync( + sourceName, startUtc, endUtc, fetch, reverse, ct).ConfigureAwait(false); + } // Truncation is the SOURCE's word (non-null continuation point) — never inferred from the count, so a // page that happens to be exactly cap-sized but complete never emits a point. @@ -5350,6 +5377,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 sourceName, startUtc, startUtc.AddTicks(resolution), overfetchCap, reverse: false, ct) .ConfigureAwait(false)).Events; + // A walked read (#691.4) emitted its ties sorted by EventId; the gateway returns them in any order and its + // end is inclusive — keep exactly the cluster, in the walk's order, so the skip indexes the same events. + if (walked) + cluster = cluster.Where(e => e.EventTimeUtc == startUtc) + .OrderBy(e => e, EventIdTieOrder.Instance).ToList(); + // The over-fetch reads the cluster FORWARD (ascending EventId), but a reverse read emitted its ties — and // counted boundarySkip — in descending order. Slice in the read's own direction, as raw does. if (reverse) cluster = cluster.Reverse().ToList(); @@ -5378,7 +5411,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { pendingCursor = new HistoryContinuationState( sourceName ?? string.Empty, resumeAt, endUtc, nextSkip, numValuesPerNode, HistoryCursorKind.Events, - Reverse: reverse); + Reverse: reverse, SegmentWidthTicks: walkHint.WidthTicks, EmptyFromTicks: walkHint.EmptyFromTicks, + EmptyToTicks: walkHint.EmptyToTicks); } } else if (truncated && events.Count > 0) @@ -5390,7 +5424,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 for (var i = events.Count - 1; i >= 0 && events[i].EventTimeUtc == boundary; i--) skip++; pendingCursor = new HistoryContinuationState( sourceName ?? string.Empty, boundary, endUtc, skip, numValuesPerNode, HistoryCursorKind.Events, - Reverse: reverse); + Reverse: reverse, SegmentWidthTicks: walkHint.WidthTicks, EmptyFromTicks: walkHint.EmptyFromTicks, + EmptyToTicks: walkHint.EmptyToTicks); } var historyEvent = ProjectEvents(events, selectClauses); @@ -5411,6 +5446,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 errors[handle.Index] = StatusCodes.BadTimeout; results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTimeout }; } + catch (HistoryReadLimitExceededException ex) + { + // A request limit (#691.4: a walked-back reverse read met a tie cluster over the budget) — BadTooManyOperations + // for THIS node, as on the raw path; not a backend fault. + _logger.LogWarning("OtOpcUaNodeManager: HistoryReadEvents limit exceeded for node {NodeId}: {Message}", handle.NodeId, ex.Message); + errors[handle.Index] = StatusCodes.BadTooManyOperations; + results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadTooManyOperations }; + } catch (Exception ex) { // One node's backend failure must not throw out of the batch — surface Bad for THIS node diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseWalk.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseWalk.cs index f68f167e..738c6de5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseWalk.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseWalk.cs @@ -1,3 +1,5 @@ +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). @@ -364,3 +366,16 @@ internal static class ReverseWalk 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); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayEventBoundsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayEventBoundsTests.cs index 5f8ad206..548b4f14 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayEventBoundsTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayEventBoundsTests.cs @@ -35,7 +35,7 @@ public sealed class GatewayEventBoundsTests { var fake = new FakeHistorianGatewayClient(); IHistorianDataSource ds = new GatewayHistorianDataSource(fake, NullLogger.Instance); - ds.SupportsReverseEvents.ShouldBeFalse("the server must keep end-only reads forward on the default backend"); + ds.SupportsReverseEvents.ShouldBeFalse("the server must walk reverse reads back from forward reads on the default backend (#691.4)"); await Should.ThrowAsync(() => ds.ReadEventsAsync("Pump1", DateTime.UtcNow, DateTime.UtcNow.AddHours(-1), 10, reverse: true, Ct)); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsPagingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsPagingTests.cs index 17c446ac..89be1c67 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsPagingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsPagingTests.cs @@ -333,12 +333,16 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable } [Fact] - public async Task A_backend_without_reverse_support_answers_BadHistoryOperationUnsupported_not_the_earliest_events() + public async Task A_backend_without_reverse_support_walks_a_reverse_read_back_never_answering_the_earliest_events() { + // #691.4 (was refused BadHistoryOperationUnsupported since #675): served newest-first from forward reads. var (host, nm, node, src) = await SetupAsync(Distinct(10)); - nm.HistorianDataSource = new ForwardOnlyEventSource(src); - ReadEvents(nm, node, max: 3, inboundCp: null, end: Early, start: Late).Error.StatusCode.Code - .ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + var fwd = new ForwardOnlyEventSource(src); + nm.HistorianDataSource = fwd; + var r = ReadEvents(nm, node, max: 3, inboundCp: null, end: Early, start: Late); + r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good); + r.Ids.ShouldBe(["e0009", "e0008", "e0007"]); + r.Cp.ShouldNotBeNull(); // Forward reads on the same backend are unaffected. DrainAsync(nm, node, max: 4).ShouldBe(src.All.Select(e => e.EventId)); await host.DisposeAsync(); @@ -349,30 +353,115 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable // --- #675 review follow-ups: the reverse-events capability ------------------------------------------------ + // --- #691.4: reverse events on a backend that reads forward only (the Gateway) are walked back ------------------ + [Fact] - public async Task End_only_capped_read_on_a_backend_without_reverse_support_pages_forward_as_before() + public async Task End_only_capped_read_on_a_forward_only_backend_pages_backward_from_EndTime() { - // Gateway (the default backend) cannot read newest-first: an end-only read keeps its pre-#675 forward - // [MinValue, EndTime) meaning there rather than turning into a refused reverse read. - var (host, nm, node, src) = await SetupAsync(Distinct(25)); - var fwd = new NonReverseSource(src); - nm.HistorianDataSource = fwd; + // Part 11 (as raw, #686): the latest events at or before EndTime, newest first — on Gateway this used to keep its + // pre-#675 forward [MinValue, EndTime) meaning, i.e. the OLDEST events. The backend is never sent a reverse read. + var (host, nm, node, _) = await SetupAsync([]); + var gw = new GatewayShapedEventSource(Distinct(25)); + nm.HistorianDataSource = gw; var ids = DrainAsync(nm, node, max: 10, start: DateTime.MinValue, end: Epoch.AddSeconds(20)); - ids.ShouldBe(Enumerable.Range(0, 20).Select(i => $"e{i:0000}")); - fwd.ReverseCalls.ShouldBe(0); + ids.ShouldBe(Enumerable.Range(0, 21).Reverse().Select(i => $"e{i:0000}")); + gw.ReverseCalls.ShouldBe(0); + gw.BadRequests.ShouldBe(0); await host.DisposeAsync(); } [Fact] - public async Task Explicit_reverse_read_on_a_backend_without_reverse_support_is_refused_without_calling_it() + public async Task Explicit_reverse_read_on_a_forward_only_backend_pages_the_reverse_window_newest_first() { - var (host, nm, node, src) = await SetupAsync(Distinct(10)); - var fwd = new NonReverseSource(src); - nm.HistorianDataSource = fwd; - ReadEvents(nm, node, max: 3, inboundCp: null, end: Early, start: Late).Error.StatusCode.Code - .ShouldBe(StatusCodes.BadHistoryOperationUnsupported); - fwd.ReverseCalls.ShouldBe(0, "the capability is checked BEFORE the source is called"); - fwd.ForwardCalls.ShouldBe(0); + // (EndTime, StartTime]: StartTime inclusive, EndTime exclusive. + var (host, nm, node, _) = await SetupAsync([]); + var gw = new GatewayShapedEventSource(Distinct(30)); + nm.HistorianDataSource = gw; + var ids = DrainAsync(nm, node, max: 4, start: Epoch.AddSeconds(25), end: Epoch.AddSeconds(5)); + ids.ShouldBe(Enumerable.Range(6, 20).Reverse().Select(i => $"e{i:0000}")); + gw.ReverseCalls.ShouldBe(0); + await host.DisposeAsync(); + } + + [Fact] + public async Task Reverse_ties_on_a_forward_only_backend_page_in_a_stable_order_although_the_backend_shuffles_them() + { + // The gateway orders events by EventTimeUtc only, so ties come back in any order — this fake shuffles them on every + // call. The walk sorts ties by EventId (descending, newest-first) on every read, and the oversized-cluster over-fetch + // does the same, so a boundary-tie skip always lines up. Cap 3 cuts through a 7-way cluster. + var events = new List { Ev("a0", 0) }; + for (var i = 0; i < 7; i++) events.Add(Ev($"t{i}", 5)); + events.Add(Ev("z0", 9)); + var (host, nm, node, _) = await SetupAsync([]); + nm.HistorianDataSource = new GatewayShapedEventSource(events, shuffleSeed: 7); + DrainAsync(nm, node, max: 3, start: DateTime.MinValue, end: Epoch.AddSeconds(30)).ShouldBe( + ["z0", "t6", "t5", "t4", "t3", "t2", "t1", "t0", "a0"]); + await host.DisposeAsync(); + } + + [Fact] + public async Task Uncapped_reverse_read_on_a_forward_only_backend_is_one_forward_read() + { + var (host, nm, node, _) = await SetupAsync([]); + var gw = new GatewayShapedEventSource(Distinct(30)); + nm.HistorianDataSource = gw; + var r = ReadEvents(nm, node, max: 0, inboundCp: null, end: Epoch.AddSeconds(5), start: Epoch.AddSeconds(25)); + r.Ids.ShouldBe(Enumerable.Range(6, 20).Reverse().Select(i => $"e{i:0000}")); + r.Cp.ShouldBeNull(); + gw.ForwardCalls.ShouldBe(1); + await host.DisposeAsync(); + } + + [Fact] + public async Task Reverse_paging_on_a_forward_only_backend_is_exact_on_random_data() + { + var (host, nm, node, _) = await SetupAsync([]); + for (var seed = 0; seed < 80; seed++) + { + var rng = new Random(seed); + var events = new List(); + var t = Epoch.Ticks; + var n = rng.Next(1, 120); + while (events.Count < n) + { + t += rng.Next(4) 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(5) == 0 ? rng.Next(2, 8) : 1; + for (var k = 0; k < ties; k++) + { + var at = new DateTime(t, DateTimeKind.Utc); + events.Add(new HistoricalEvent($"{Guid.NewGuid():N}", "src", at, at, "m", 500)); + } + } + + nm.HistorianDataSource = new GatewayShapedEventSource(events, shuffleSeed: seed); + nm.MaxTieClusterOverfetch = rng.Next(2) == 0 ? 8 : 65536; + var max = (uint)rng.Next(1, 9); + var end = new DateTime(t + rng.Next(0, 3) * TimeSpan.TicksPerSecond, DateTimeKind.Utc); + var expected = events.OrderByDescending(e => e.EventTimeUtc).ThenByDescending(e => e.EventId, StringComparer.Ordinal) + .Select(e => e.EventId).ToList(); + + var all = new List(); + byte[]? cp = null; + var pages = 0; + do + { + var r = ReadEvents(nm, node, max, cp, end, DateTime.MinValue); + r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good, $"seed {seed}"); + all.AddRange(r.Ids); + cp = r.Cp; + (++pages).ShouldBeLessThan(2000); + } + while (cp is not null); + + all.ShouldBe(expected, $"seed {seed}, N = {max}"); + } + await host.DisposeAsync(); } @@ -391,29 +480,39 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable await host.DisposeAsync(); } - /// Declares the reverse overload (so a call would be visible) but reports no reverse capability. - private sealed class NonReverseSource(IHistorianDataSource inner) : IHistorianDataSource + /// + /// Shaped like the Gateway's SQL event read (#691.4): forward only ( false — a + /// reverse call is counted and refused), [start, end] INCLUSIVE, ordered by event time only with ties in an + /// arbitrary order (shuffled on every call when a seed is given), capped with a truncation flag. + /// + private sealed class GatewayShapedEventSource(List events, int? shuffleSeed = null) : IHistorianDataSource { + private readonly Random? _rng = shuffleSeed is { } s ? new Random(s) : null; public int ReverseCalls { get; private set; } public int ForwardCalls { get; private set; } + public int BadRequests { get; private set; } public bool SupportsReverseEvents => false; public Task ReadEventsAsync(string? n, DateTime s, DateTime e, int m, CancellationToken ct) { ForwardCalls++; - return inner.ReadEventsAsync(n, s, e, m, ct); + if (s > e || s < DateTime.UnixEpoch) BadRequests++; + var window = events.Where(x => x.EventTimeUtc >= s && x.EventTimeUtc <= e) + .OrderBy(x => x.EventTimeUtc).ThenBy(_ => _rng?.Next() ?? 0).ToList(); + var cap = m <= 0 ? int.MaxValue : m; + return Task.FromResult(new HistoricalEventsResult(window.Take(cap).ToList(), window.Count > cap ? new byte[] { 1 } : null)); } public Task ReadEventsAsync(string? n, DateTime s, DateTime e, int m, bool reverse, CancellationToken ct) { - if (reverse) { ReverseCalls++; return inner.ReadEventsAsync(n, s, e, m, true, ct); } + if (reverse) { ReverseCalls++; throw new NotSupportedException("forward only"); } return ReadEventsAsync(n, s, e, m, ct); } public Task ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => - inner.ReadRawAsync(r, s, e, m, ct); + NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct); public Task ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) => - inner.ReadProcessedAsync(r, s, e, i, a, ct); + NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct); public Task ReadAtTimeAsync(string r, IReadOnlyList t, CancellationToken ct) => - inner.ReadAtTimeAsync(r, t, ct); - public HistorianHealthSnapshot GetHealthSnapshot() => inner.GetHealthSnapshot(); + NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct); + public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot(); public void Dispose() { } } From ccda27d2207ee9a4170c1d45fc398d3f0d2fee19 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 12 Oct 2026 17:20:41 -0400 Subject: [PATCH 4/7] =?UTF-8?q?fix(historian):=20#691.4=20review=20?= =?UTF-8?q?=E2=80=94=20uncapped=20Gateway=20events=20reads=20no=20longer?= =?UTF-8?q?=20pre-size=20to=20int.MaxValue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GatewayHistorianDataSource.ReadEventsAsync sized its result list to maxEvents, which is int.MaxValue for NumValuesPerNode = 0: every uncapped Gateway events read (forward on master, and the new walked reverse one) threw 'Array dimensions exceeded' and counted as a backend failure. Capacity is bounded at 1024. The events walk delegate moved to ReverseEventsReader so it is tested over the real Gateway source (forward uncapped, reverse uncapped, reverse capped). --- docs/Historian.md | 3 + .../GatewayHistorianDataSource.cs | 3 +- .../OtOpcUaNodeManager.cs | 19 +---- .../ReverseEventsReader.cs | 54 +++++++++++++ .../FakeHistorianGatewayClient.cs | 5 +- .../GatewayReverseEventsTests.cs | 76 +++++++++++++++++++ 6 files changed, 142 insertions(+), 18 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseEventsReader.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayReverseEventsTests.cs diff --git a/docs/Historian.md b/docs/Historian.md index c14a98ff..fca9ccf6 100644 --- a/docs/Historian.md +++ b/docs/Historian.md @@ -787,6 +787,9 @@ says is complete carries no continuation point. `NumValuesPerNode == 0` stays un oversized-cluster over-fetch keeps exactly the events at `T` (the gateway's end is inclusive) in the same order. - **"More exists"** is found by walking for one event past the page (the walk has no source truncation flag to hand on). - **Uncapped** reverse reads are one forward read of the window, sorted and reversed. + - ⚠️ **Also fixed (#691.4 review): uncapped events reads on Gateway.** `NumValuesPerNode = 0` reaches the source as + `int.MaxValue`, and `GatewayHistorianDataSource` pre-sized its result list to it ("Array dimensions exceeded") — every + uncapped Gateway events read, forward too, failed the node and counted as a backend failure. The capacity is now bounded. - A timestamp holding more than `MaxTieClusterOverfetch` events ⇒ **`BadTooManyOperations`** for that node. - ⚠️ **Gateway prerequisite:** the gateway's `RuntimeDb:EventReadMaxRows` must be `0` (its default, unlimited) or larger than the walk's biggest read (`2N + 18` rows, a discovery read 1,026, a tie-cluster read up to `2·MaxTieClusterOverfetch + 4`). diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs index b25790be..a5041334 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs @@ -425,7 +425,8 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis try { var hasCap = maxEvents > 0; - var collected = new List(hasCap ? maxEvents : 0); + // Bounded initial capacity: an uncapped read arrives as int.MaxValue ("Array dimensions exceeded", #691.4 review). + var collected = new List(hasCap ? Math.Min(maxEvents, 1024) : 0); var truncated = false; await foreach (var wireEvent in _client diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 57ab3fc8..8488e171 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -5311,22 +5311,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // Walk for one more than the fetch: finding it is the "more events exist" signal a source gives with its // continuation point (the walk itself cannot flag truncation). var want = numValuesPerNode == 0 ? 0u : (uint)Math.Min(uint.MaxValue - 1L, (long)fetch + 1); - var budget = Math.Max(1, MaxTieClusterOverfetch); - var walkSource = sourceName; - var (rows, next) = await ReverseWalk.WalkAsync( - async (s, e, cap, token) => - { - var r = await source.ReadEventsAsync(walkSource, s, e, cap == 0 ? int.MaxValue : cap, reverse: false, token) - .ConfigureAwait(false); - return new ForwardSegmentRead(r.Events, r.ContinuationPoint is not null); - }, - ev => ev.EventTimeUtc.Ticks, EventIdTieOrder.Instance, startUtc, endUtc, want, budget, - source.TimestampResolutionTicks, Clock.GetUtcNow().UtcDateTime, walkHint, - at => new HistoryReadLimitExceededException(budget, - $"A reverse events read of '{walkSource}' at {at:O} found more than {budget} events at one " + - "timestamp; the backend reads forward only, so the latest of them cannot be selected. Increase " + - "ServerHistorian:MaxTieClusterOverfetch."), - ct) + var (rows, next) = await ReverseEventsReader.ReadAsync( + source, sourceName, startUtc, endUtc, want, MaxTieClusterOverfetch, Clock.GetUtcNow().UtcDateTime, + walkHint, ct) .ConfigureAwait(false); walkHint = next; var more = want > 0 && rows.Count > fetch; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseEventsReader.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseEventsReader.cs new file mode 100644 index 00000000..52ab3bfd --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseEventsReader.cs @@ -0,0 +1,54 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; + +/// One walked-back reverse events page (). +/// At most the requested count, newest first by (EventTimeUtc, EventId). +/// What the next page of the same read resumes with. +internal readonly record struct ReverseEventsPage(List Events, ReverseWalkHint Hint); + +/// +/// Gitea #691.4: serves a Part 11 reverse events read — (lo, hi] newest-first — on a backend that reads events +/// forward only ( = false: Gateway, Null) with the +/// . A segment read is cut short when the source flags truncation (its continuation point); ties +/// are ordered by because the gateway orders by event time only. +/// +internal static class ReverseEventsReader +{ + /// Reads (lo, hi] newest-first from forward event reads. + /// A backend that reads events forward. + /// Event-source filter (null = all). + /// The later (inclusive) bound. + /// The earlier (exclusive) bound. + /// How many events; 0 = all in the window (one forward read). + /// The tie budget (MaxTieClusterOverfetch) — a ceiling only. + /// Now, for the look-ahead clamp. + /// What the previous page carried (default = none). + /// Cancellation. + /// The events, 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? sourceName, DateTime hi, DateTime lo, uint want, int maxTieCluster, + DateTime nowUtc, ReverseWalkHint hint, CancellationToken ct) + { + var budget = Math.Max(1, maxTieCluster); + var (rows, next) = await ReverseWalk.WalkAsync( + async (start, end, cap, token) => + { + // cap 0 = the whole window: int.MaxValue is the events contract's "no cap" (a non-positive value would + // mean the backend's DEFAULT cap). + var r = await source.ReadEventsAsync(sourceName, start, end, cap == 0 ? int.MaxValue : cap, reverse: false, token) + .ConfigureAwait(false); + return new ForwardSegmentRead(r.Events, r.ContinuationPoint is not null); + }, + ev => ev.EventTimeUtc.Ticks, EventIdTieOrder.Instance, hi, lo, want, budget, source.TimestampResolutionTicks, + nowUtc, hint, + at => new HistoryReadLimitExceededException(budget, + $"A reverse events read of '{sourceName}' at {at:O} found more than {budget} events at one timestamp; the " + + "backend reads forward only, so the latest of them cannot be selected. Increase " + + "ServerHistorian:MaxTieClusterOverfetch."), + ct) + .ConfigureAwait(false); + return new ReverseEventsPage(rows, next); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/FakeHistorianGatewayClient.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/FakeHistorianGatewayClient.cs index 6cb69cc8..5ee232b0 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/FakeHistorianGatewayClient.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/FakeHistorianGatewayClient.cs @@ -54,6 +54,9 @@ public sealed class FakeHistorianGatewayClient : IHistorianGatewayClient public int LastReadEventsMaxEvents; public int ReadEventsCallCount; + /// When set, ReadEvents serves EventsSource(start, end) instead of . + public Func>? EventsSource; + /// Thrown (deferred to first enumeration) by every read method when set. public Exception? ThrowOnRead; @@ -187,7 +190,7 @@ public sealed class FakeHistorianGatewayClient : IHistorianGatewayClient LastReadEventsEndUtc = endUtc; LastReadEventsMaxEvents = maxEvents; ReadEventsCallCount++; - return ToAsyncStream(Events, ThrowOnRead, ct); + return ToAsyncStream(EventsSource is null ? Events : EventsSource(startUtc, endUtc).ToList(), ThrowOnRead, ct); } public Task WriteLiveValuesAsync( diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayReverseEventsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayReverseEventsTests.cs new file mode 100644 index 00000000..7fecc48c --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayReverseEventsTests.cs @@ -0,0 +1,76 @@ +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; +using ZB.MOM.WW.HistorianGateway.Contracts.Grpc; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests; + +/// +/// Gitea #691.4 review: events read uncapped (and walked back newest-first) through the REAL +/// over a gateway-shaped fake — inclusive [start, end], ordered by event time. +/// +public sealed class GatewayReverseEventsTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static readonly DateTime T0 = new(2026, 10, 12, 18, 0, 0, DateTimeKind.Utc); + + private static FakeHistorianGatewayClient Store(int count) + { + var stored = Enumerable.Range(0, count) + .Select(i => new HistorianEvent { Id = $"e{i:0000}", Type = "Alarm", EventTime = Timestamp.FromDateTime(T0.AddSeconds(i)) }) + .ToList(); + return new FakeHistorianGatewayClient + { + EventsSource = (start, end) => stored.Where(e => e.EventTime.ToDateTime() >= start && e.EventTime.ToDateTime() <= end), + }; + } + + /// + /// NumValuesPerNode = 0 reaches the source as int.MaxValue; the result list used to be pre-sized to it + /// ("Array dimensions exceeded") — every uncapped Gateway events read failed and counted as a backend failure. + /// + [Fact] + public async Task An_uncapped_forward_events_read_is_served() + { + var fake = Store(30); + var ds = new GatewayHistorianDataSource(fake, NullLogger.Instance); + + var r = await ds.ReadEventsAsync(null, T0, T0.AddMinutes(1), int.MaxValue, Ct); + + r.Events.Count.ShouldBe(30); + r.ContinuationPoint.ShouldBeNull(); + ds.GetHealthSnapshot().TotalFailures.ShouldBe(0); + } + + [Fact] + public async Task An_uncapped_reverse_events_read_is_one_forward_read_newest_first() + { + var fake = Store(30); + var ds = new GatewayHistorianDataSource(fake, NullLogger.Instance); + + var page = await ReverseEventsReader.ReadAsync(ds, null, hi: T0.AddSeconds(25), lo: T0.AddSeconds(5), want: 0, + maxTieCluster: 65536, nowUtc: T0.AddHours(1), hint: default, Ct); + + page.Events.Select(e => e.EventId).ShouldBe(Enumerable.Range(6, 20).Reverse().Select(i => $"e{i:0000}")); + fake.ReadEventsCallCount.ShouldBe(1); + ds.GetHealthSnapshot().TotalFailures.ShouldBe(0); + } + + [Fact] + public async Task A_capped_reverse_events_read_walked_back_over_the_gateway_returns_the_latest_newest_first() + { + var fake = Store(300); + var ds = new GatewayHistorianDataSource(fake, NullLogger.Instance); + + var page = await ReverseEventsReader.ReadAsync(ds, null, hi: T0.AddSeconds(200), lo: T0.AddSeconds(50), want: 10, + maxTieCluster: 65536, nowUtc: T0.AddHours(1), hint: default, Ct); + + page.Events.Select(e => e.EventId).ShouldBe(Enumerable.Range(191, 10).Reverse().Select(i => $"e{i:0000}")); + fake.LastReadEventsStartUtc.ShouldBeLessThanOrEqualTo(fake.LastReadEventsEndUtc); + ds.GetHealthSnapshot().TotalFailures.ShouldBe(0); + } +} From d28e3e64f1b709750ecc9eef1d61356711d4d89c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 12 Oct 2026 17:37:32 -0400 Subject: [PATCH 5/7] =?UTF-8?q?fix(historian):=20#691.4=20review=20?= =?UTF-8?q?=E2=80=94=20search=20the=20gap=20above=20a=20truncated=20read?= =?UTF-8?q?=20by=20gallop=20then=20bisect,=20never=20below=201=20ms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisecting the gap above a truncated read halved below the 1 ms request granularity (requests are ms-rounded, so it re-sent identical requests) and bisected the WHOLE gap even when the burst ended right above the row it saw: a 1,026-row burst ending 100 days ago took 46 reads. After an empty top-anchored segment the walk now gallops up from the known row ((known + d, top], d x4, following each truncated read), then bisects the bracket, with halving floored at the granularity (the Min kept; a crowded gallop falls back to top-anchored halving). Burst: 18 reads, 0 repeated requests; dense tag stopped 2 days ago: 26 (gallop alone crawled: 94). Walk fuzzed at 4,000 seeds locally (400 in-tree). Docs: measured counts; CLAUDE.md gateway prerequisites list RuntimeDb:EventReadMaxRows. --- CLAUDE.md | 5 +- docs/Historian.md | 15 ++-- .../ReverseWalk.cs | 77 +++++++++++++++++-- .../ReverseWalkTests.cs | 40 +++++++++- 4 files changed, 119 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e8a24343..9dc3ac84 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 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; #691: after two empty segments it asks once for the whole remainder, and the stretch a page proved empty rides the continuation point — the last pages of a deep page-through no longer each walk ~14 reads to 1970). 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 follow since #691.4: a reverse/end-only capped events read on Gateway/Null (`SupportsReverseEvents` false) is walked back the same way (ties sorted by EventId — the gateway orders by time only; one extra event found ⇒ continuation point) — it used to be refused `BadHistoryOperationUnsupported` (explicit reverse) or answered with the OLDEST N ascending (end-only); needs the gateway's `RuntimeDb:EventReadMaxRows` 0/unlimited (it truncates silently). `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; #691: after two empty segments it asks once for the whole remainder, and the stretch a page proved empty rides the continuation point — the last pages of a deep page-through no longer each walk ~14 reads to 1970). 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 follow since #691.4: a reverse/end-only capped events read on Gateway (`SupportsReverseEvents` false; Null now reports `true` and answers empty) is walked back the same way (ties sorted by EventId — the gateway orders by time only; one extra event found ⇒ continuation point) — it used to be refused `BadHistoryOperationUnsupported` (explicit reverse) or answered with the OLDEST N ascending (end-only); needs the gateway's `RuntimeDb:EventReadMaxRows` 0/unlimited (it truncates silently). Uncapped Gateway events reads (forward too) used to throw "Array dimensions exceeded" (result list pre-sized to `int.MaxValue`) — fixed. Above a truncated read the walk gallops up from the newest row seen, then bisects (never below 1 ms): a burst that ended 100 days ago ≈ 18 reads, a dense tag that stopped days ago ≈ 26. `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. @@ -1455,6 +1455,9 @@ The target HistorianGateway OtOpcUa points at **must** run with: - `RuntimeDb:Enabled=true` — enables the `WriteLiveValues` SQL live path (continuous historization). - `RuntimeDb:EventReadsEnabled=true` — enables `ReadEvents` from `Runtime.dbo.Events` (alarm-history reads). +- `RuntimeDb:EventReadMaxRows=0` (its default, unlimited) — or larger than the reverse-events walk's biggest read + (`2·MaxTieClusterOverfetch + 4`). The cap truncates **silently**, so a walked-back reverse events read (#691.4) would + lose the newest events of a segment it cut. - An API key carrying scopes **`historian:read`**, **`historian:write`**, **`historian:tags:write`**. ### Migration note (deployments upgrading from the Wonderware backend) diff --git a/docs/Historian.md b/docs/Historian.md index fca9ccf6..553768c6 100644 --- a/docs/Historian.md +++ b/docs/Historian.md @@ -654,9 +654,12 @@ backend: - **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, + showed, and never again below the newest row it saw until what lies above that row is read. The gap above it is + searched: a segment at that density ending on the top (data running right up to it); if empty, a **gallop** up from + that row (`(row + d, top]`, `d` ×4 per step, following each truncated read) until a read comes back empty, then a + **bisection** of the bracket — never narrower than the 1 ms request granularity, which only re-sent the same request. + Measured: a dense tag that stopped two days ago ≈ **26** reads, a burst of just over 1,024 rows that ended 100 days ago + ≈ **18** (was 46 bisecting the whole gap) — rather than re-reading the oldest rows of a 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. - **Proving the rest empty (#691).** Walking down through empty history to the floor used to cost one read per ×4 @@ -778,8 +781,8 @@ says is complete carries no continuation point. `NumValuesPerNode == 0` stays un is forward `[MinValue, EndTime)` everywhere. - **Backends.** The server checks `IHistorianDataSource.SupportsReverseEvents`. SqlServer and QuestDB serve reverse natively (`… > lo AND … <= hi ORDER BY time DESC, id DESC`, the `ReadEventsAsync(…, bool reverse, …)` overload) and report `true`. - **Gateway and Null report `false`** — the gateway streams ascending only and rejects `end < start` — and are never sent a - reverse read: since **#691.4** the server walks it back from bounded **forward** reads with the raw walk (#686/#689/#691: + **Gateway reports `false`** — the gateway streams ascending only and rejects `end < start` — and is never sent a reverse + read (Null reports `true` and answers empty either way, as for raw): since **#691.4** the server walks it back from bounded **forward** reads with the raw walk (#686/#689/#691: page-sized ms-aligned segments filtered to `(segLo, segHi]`, a read the source flagged truncated is never answered from, the now + 1 day / 1970 bounds, the remainder probe and the proven-empty stretch carried in the cursor). Event specifics: - **Ties** are sorted by `EventId` (ordinal) on every read — the gateway orders by `EventTimeUtc` only, in any order within a @@ -796,7 +799,7 @@ says is complete carries no continuation point. `NumValuesPerNode == 0` stays un That cap truncates **silently** (the stream just ends), so a segment it cut would look complete and drop its newest events — the same blindness a forward read has to it, but on the end a reverse read is asking for. - ⚠️ **Client-visible (#691.4).** On Gateway (the default backend) an explicit reverse events read used to be refused + ⚠️ **Client-visible (#691.4).** On Gateway (the default backend) and Null an explicit reverse events read used to be refused `BadHistoryOperationUnsupported` (#675), and an end-only capped events read returned the **oldest** N events ascending. Both now return the latest events newest-first. Because the capability is checked, not a caught exception, a `NotSupportedException` thrown from inside a backend (a driver library) is still logged as a backend fault. Every diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseWalk.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseWalk.cs index 738c6de5..91e18537 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseWalk.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseWalk.cs @@ -156,10 +156,19 @@ internal static class ReverseWalk 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. + // 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. @@ -178,7 +187,20 @@ internal static class ReverseWalk var probe = !probed && known == 0 && emptyRun >= 2 && segHi - stopTicks > width; var w = probe ? segHi - stopTicks : width; - if (known > 0) w = Math.Min(w, Math.Max(1, (segHi - known) / 2)); + 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 when halving: requests are rounded outward to it, so a + // narrower half re-sends the same request (#691.4 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(w, 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)); @@ -244,6 +266,14 @@ internal static class ReverseWalk 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) { @@ -255,10 +285,17 @@ internal static class ReverseWalk known = lastInner; knownWidth = Math.Max(resolution, aimed); width = Math.Max(resolution, Math.Min(aimed, segHi - segLo - 1)); + lowerBy = 0; + bisecting = false; } else { - width = Math.Max(resolution, (segHi - segLo) / 4); // only rows before the segment: shrink it + // 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; @@ -283,9 +320,29 @@ internal static class ReverseWalk 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; + 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 { @@ -293,6 +350,9 @@ internal static class ReverseWalk 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; @@ -300,6 +360,9 @@ internal static class ReverseWalk { // 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; } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ReverseWalkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ReverseWalkTests.cs index d44c20c2..cfc3193e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ReverseWalkTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ReverseWalkTests.cs @@ -120,7 +120,7 @@ public sealed class ReverseWalkTests /// /// #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. + /// that stretch — it searches the gap above the newest row it saw (gallop up, then bisect). Measured: 26 reads. /// [Fact] public async Task A_dense_tag_below_a_long_empty_stretch_is_found_in_a_bounded_number_of_reads() @@ -133,8 +133,8 @@ public sealed class ReverseWalkTests 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)); + source.Reads.ShouldBeLessThanOrEqualTo(30); + source.RowsReturned.ShouldBeLessThanOrEqualTo(30 * (ReverseWalk.DiscoveryRows + 2)); } /// @@ -185,6 +185,27 @@ public sealed class ReverseWalkTests } } + /// + /// #691.4 review: a burst just over the discovery read, sub-millisecond spaced, ending 100 days ago. The search for + /// the top of the burst above the truncated read never re-sends an identical request (halving below the 1 ms request + /// granularity did), and gallops up from the truncated read's newest row instead of bisecting the whole 100-day gap + /// down to the millisecond (was 46 reads). + /// + [Fact] + public async Task Bisecting_above_a_truncated_read_never_resends_an_identical_request() + { + var end = T0.AddDays(-100); + var count = ReverseWalk.DiscoveryRows + 2; + var series = Enumerable.Range(0, count).Select(i => At(end.Ticks - (count - 1 - i) * 1_000, i)).ToList(); // 0.1 ms apart + 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([count - 1d, count - 2d, count - 3d]); + source.RepeatedRequests.ShouldBe(0, string.Join("\n", source.Log)); + source.Reads.ShouldBeLessThanOrEqualTo(22, string.Join("\n", source.Log)); + } + private static long Ts(DataValueSnapshot s) => s.SourceTimestampUtc!.Value.Ticks; /// Random ascending series: bursts, gaps, sub-millisecond stamps and tie clusters. @@ -221,7 +242,15 @@ public sealed class ReverseWalkTests public int Reads { get; private set; } public int BadRequests { get; private set; } - public void Reset() { MaxCap = 0; RowsReturned = 0; Reads = 0; } + /// Requests that repeated an earlier request exactly (start, end, cap) — read-only, so pure waste. + public int RepeatedRequests { get; private set; } + + private readonly HashSet<(DateTime, DateTime, uint)> _seen = []; + + /// Every request, for failure messages. + public List Log { get; } = []; + + public void Reset() { MaxCap = 0; RowsReturned = 0; Reads = 0; RepeatedRequests = 0; _seen.Clear(); Log.Clear(); } public bool SupportsReverseRaw => false; @@ -229,6 +258,9 @@ public sealed class ReverseWalkTests CancellationToken cancellationToken) { Reads++; + if (!_seen.Add((startUtc, endUtc, maxValuesPerNode))) RepeatedRequests++; + + Log.Add($"[{startUtc:O} .. {endUtc:O}] cap {maxValuesPerNode}"); if (startUtc > endUtc || maxValuesPerNode == 0 || startUtc < DateTime.UnixEpoch) BadRequests++; MaxCap = Math.Max(MaxCap, maxValuesPerNode); var stamp = roundUp && startUtc.Ticks % TimeSpan.TicksPerMillisecond != 0 From 574c3aa1b0f4ee41bcfb873464b9e8be859c5252 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 12 Oct 2026 17:37:32 -0400 Subject: [PATCH 6/7] =?UTF-8?q?fix(historian):=20#691.4=20review=20?= =?UTF-8?q?=E2=80=94=20Null=20reports=20SupportsReverseEvents=20=3D=20true?= =?UTF-8?q?,=20like=20raw?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The null source has no history in either direction; reporting false made the server walk an empty reverse events read back through three empty forward reads. It now answers the reverse overload with the empty result itself (guard test unchanged: it still declares both members). --- .../Historian/IHistorianDataSource.cs | 4 ++-- .../Historian/NullHistorianDataSource.cs | 13 +++++-------- .../OtOpcUaNodeManager.cs | 2 +- .../ReverseEventsReader.cs | 2 +- .../Historian/NullHistorianDataSourceTests.cs | 12 +++++++----- 5 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs index 3b9c1b97..309a0b63 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs @@ -143,8 +143,8 @@ public interface IHistorianDataSource : IDisposable /// /// /// The default forwards a forward read to the five-argument overload and throws - /// for a reverse one — a backend that cannot read newest-first (Gateway, - /// Null). The server never relies on that throw: it consults first and walks + /// for a reverse one — a backend that cannot read newest-first (the + /// Gateway). The server never relies on that throw: it consults first and walks /// such a read back from forward reads (#691.4), never returning the earliest events in ascending order as if /// they were the answer. A backend that serves reverse natively must override BOTH members. /// diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/NullHistorianDataSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/NullHistorianDataSource.cs index 9d97b317..811e3dc2 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/NullHistorianDataSource.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/NullHistorianDataSource.cs @@ -61,13 +61,12 @@ public sealed class NullHistorianDataSource : IHistorianDataSource public bool SupportsReverseRaw => true; /// - /// false (Gitea #675): there is no history to read in either direction; the server walks a reverse - /// read back from (empty) forward reads like any backend that cannot read newest-first (#691.4). - public bool SupportsReverseEvents => false; + /// true, like (Gitea #691.4 review; false under #675): there is no + /// history to read in either direction — no need for the server to walk back through empty forward reads. + public bool SupportsReverseEvents => true; /// - /// Forward ⇒ the empty result; reverse ⇒ , the interface contract for a - /// backend without . + /// The empty result in either direction. public Task ReadEventsAsync( string? sourceName, DateTime startUtc, @@ -75,9 +74,7 @@ public sealed class NullHistorianDataSource : IHistorianDataSource int maxEvents, bool reverse, CancellationToken cancellationToken) => - reverse - ? throw new NotSupportedException("The null historian cannot read events newest-first (reverse).") - : Task.FromResult(EmptyEvents); + Task.FromResult(EmptyEvents); /// /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 8488e171..a939ed3c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -5291,7 +5291,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 reverse = startUtc > endUtc; } - // A reverse read on a backend that cannot read newest-first (Gateway, Null) is never sent there (#675 refused it + // A reverse read on a backend that cannot read newest-first (the Gateway) is never sent there (#675 refused it // here); since #691.4 it is walked back from bounded forward reads — the raw walk (#686), with ties ordered by // EventId because the gateway orders by event time only. Decided by the capability, not by catching an // exception, so a NotSupportedException thrown from inside a backend stays a backend fault. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseEventsReader.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseEventsReader.cs index 52ab3bfd..f19648d3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseEventsReader.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseEventsReader.cs @@ -9,7 +9,7 @@ internal readonly record struct ReverseEventsPage(List Events, /// /// Gitea #691.4: serves a Part 11 reverse events read — (lo, hi] newest-first — on a backend that reads events -/// forward only ( = false: Gateway, Null) with the +/// forward only ( = false: the Gateway) with the /// . A segment read is cut short when the source flags truncation (its continuation point); ties /// are ordered by because the gateway orders by event time only. /// diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/NullHistorianDataSourceTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/NullHistorianDataSourceTests.cs index e9045e7b..4c4b1dd7 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/NullHistorianDataSourceTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/NullHistorianDataSourceTests.cs @@ -68,17 +68,19 @@ public sealed class NullHistorianDataSourceTests } /// - /// #675: the direction-aware overload's default — forward is the five-argument read, reverse is refused - /// (the server answers BadHistoryOperationUnsupported). + /// #691.4 review: like raw, the null source reads events in either direction (empty) and says so — the server sends + /// it the reverse read directly instead of walking back through empty forward reads (it refused reverse under #675). /// [Fact] - public async Task Direction_aware_ReadEventsAsync_forwards_forward_and_refuses_reverse() + public async Task Direction_aware_ReadEventsAsync_is_empty_in_both_directions() { IHistorianDataSource source = Source; + source.SupportsReverseEvents.ShouldBeTrue(); (await source.ReadEventsAsync(null, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, 0, reverse: false, Ct)) .Events.ShouldBeEmpty(); - await Should.ThrowAsync(() => - source.ReadEventsAsync(null, DateTime.UtcNow, DateTime.UtcNow.AddHours(-1), 0, reverse: true, Ct)); + var reverse = await source.ReadEventsAsync(null, DateTime.UtcNow, DateTime.UtcNow.AddHours(-1), 0, reverse: true, Ct); + reverse.Events.ShouldBeEmpty(); + reverse.ContinuationPoint.ShouldBeNull(); } /// GetHealthSnapshot reports a fully-disabled (zeroed, disconnected, no-node) shape. From 9bb1363cd6169c71b3292288f227f4af8ec132fa Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 12 Oct 2026 17:50:39 -0400 Subject: [PATCH 7/7] =?UTF-8?q?fix(historian):=20#691.4=20re-review=20?= =?UTF-8?q?=E2=80=94=20never=20narrow=20a=20walk=20segment=20below=20the?= =?UTF-8?q?=201=20ms=20request=20granularity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tie cluster's truncated read left the width at one tick; once the gap above it was under two milliseconds nothing floored it, so segHi stepped down one tick per read: 30 rows at one ms 0.3 s below hi with a carried 1 s width took 10,006 identical reads (S8). (a) The top-anchored width is floored at the granularity, Min kept; (b) a segment no wider than the granularity is the unit that escalates to MaxTieClusterOverfetch and then refuses - per millisecond, exact for the ms-resolution Gateway (documented). Both are needed: the floor alone loops on crowded reads with a small tie budget. S8 is a repo test (4 shapes, reads <= 30, 0 repeated requests); random-data tests now measure the refusal unit as rows in any 1 ms window. Reviewer harness, 5,000 seeds x default/MSONLY/CROWD: worst page 70 reads (default/MSONLY), 63 (CROWD) at M=65536, p99.9 27-37; no page over 500 reads at any tie budget. --- CLAUDE.md | 2 +- docs/Historian.md | 8 +++- .../ReverseEventsReader.cs | 2 +- .../ReverseRawReader.cs | 2 +- .../ReverseWalk.cs | 22 ++++++----- ...NodeManagerHistoryReadEventsPagingTests.cs | 22 ++++++++++- .../NodeManagerHistoryReadPagingTests.cs | 33 +++++++++++++++- .../ReverseWalkTests.cs | 38 ++++++++++++++++++- 8 files changed, 109 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9dc3ac84..1b49cc92 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 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; #691: after two empty segments it asks once for the whole remainder, and the stretch a page proved empty rides the continuation point — the last pages of a deep page-through no longer each walk ~14 reads to 1970). 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 follow since #691.4: a reverse/end-only capped events read on Gateway (`SupportsReverseEvents` false; Null now reports `true` and answers empty) is walked back the same way (ties sorted by EventId — the gateway orders by time only; one extra event found ⇒ continuation point) — it used to be refused `BadHistoryOperationUnsupported` (explicit reverse) or answered with the OLDEST N ascending (end-only); needs the gateway's `RuntimeDb:EventReadMaxRows` 0/unlimited (it truncates silently). Uncapped Gateway events reads (forward too) used to throw "Array dimensions exceeded" (result list pre-sized to `int.MaxValue`) — fixed. Above a truncated read the walk gallops up from the newest row seen, then bisects (never below 1 ms): a burst that ended 100 days ago ≈ 18 reads, a dense tag that stopped days ago ≈ 26. `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 more than max(1, `MaxTieClusterOverfetch`) samples within ONE MILLISECOND ⇒ `BadTooManyOperations` (per ms, not per timestamp — requests are ms-rounded, and letting a segment narrow below 1 ms stepped a page through one ms a tick per read, ~10k reads); walks no later than now + 1 day and never before the Unix epoch; #691: after two empty segments it asks once for the whole remainder, and the stretch a page proved empty rides the continuation point — the last pages of a deep page-through no longer each walk ~14 reads to 1970). 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 follow since #691.4: a reverse/end-only capped events read on Gateway (`SupportsReverseEvents` false; Null now reports `true` and answers empty) is walked back the same way (ties sorted by EventId — the gateway orders by time only; one extra event found ⇒ continuation point) — it used to be refused `BadHistoryOperationUnsupported` (explicit reverse) or answered with the OLDEST N ascending (end-only); needs the gateway's `RuntimeDb:EventReadMaxRows` 0/unlimited (it truncates silently). Uncapped Gateway events reads (forward too) used to throw "Array dimensions exceeded" (result list pre-sized to `int.MaxValue`) — fixed. Above a truncated read the walk gallops up from the newest row seen, then bisects (never below 1 ms): a burst that ended 100 days ago ≈ 18 reads, a dense tag that stopped days ago ≈ 26. `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 553768c6..40aae51a 100644 --- a/docs/Historian.md +++ b/docs/Historian.md @@ -659,9 +659,13 @@ backend: that row (`(row + d, top]`, `d` ×4 per step, following each truncated read) until a read comes back empty, then a **bisection** of the bracket — never narrower than the 1 ms request granularity, which only re-sent the same request. Measured: a dense tag that stopped two days ago ≈ **26** reads, a burst of just over 1,024 rows that ended 100 days ago - ≈ **18** (was 46 bisecting the whole gap) — rather than re-reading the oldest rows of a burst over and over. Only **one timestamp holding more than `M` samples** is refused, + ≈ **18** (was 46 bisecting the whole gap) — rather than re-reading the oldest rows of a burst over and over. Only **more than `M` samples within one millisecond** are refused, **`BadTooManyOperations`** (a request limit, not a backend fault); a cluster of exactly `M`, or neighbouring - clusters each within `M`, is served. + clusters each within `M` and a millisecond or more apart, is served. ⚠️ The unit is the **millisecond**, not the + timestamp (#691.4 re-review): requests are rounded to the millisecond, so a narrower segment is the same request — + letting the width fall below it once stepped a page through one millisecond a tick at a time (~10,000 reads). Exact + for the Gateway, which stores milliseconds; a sub-millisecond backend walked this way would refuse more than `M` + samples inside one millisecond even when no single timestamp holds that many. - **Proving the rest empty (#691).** Walking down through empty history to the floor used to cost one read per ×4 growth step — ~14–16 reads (~2.5 s live) on **every** last page of a deep page-through. Now, after two consecutive empty segments, the walk asks **once per page** for the whole remainder down to the floor at the page budget: a diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseEventsReader.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseEventsReader.cs index f19648d3..2a7cc98c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseEventsReader.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseEventsReader.cs @@ -44,7 +44,7 @@ internal static class ReverseEventsReader ev => ev.EventTimeUtc.Ticks, EventIdTieOrder.Instance, hi, lo, want, budget, source.TimestampResolutionTicks, nowUtc, hint, at => new HistoryReadLimitExceededException(budget, - $"A reverse events read of '{sourceName}' at {at:O} found more than {budget} events at one timestamp; the " + + $"A reverse events read of '{sourceName}' at {at:O} found more than {budget} events within one millisecond; the " + "backend reads forward only, so the latest of them cannot be selected. Increase " + "ServerHistorian:MaxTieClusterOverfetch."), ct) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseRawReader.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseRawReader.cs index 309e9e8b..6f3d3902 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseRawReader.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseRawReader.cs @@ -71,7 +71,7 @@ internal static class ReverseRawReader }, 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 " + + $"A reverse raw read of '{tagname}' at {at:O} found more than {budget} samples within one millisecond; the " + "backend reads forward only, so the latest of them cannot be selected. Increase " + "ServerHistorian:MaxTieClusterOverfetch."), ct) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseWalk.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseWalk.cs index 91e18537..014c760d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseWalk.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/ReverseWalk.cs @@ -54,8 +54,9 @@ internal readonly record struct ForwardSegmentRead(IReadOnlyList Rows, boo /// 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. +/// 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 @@ -194,12 +195,13 @@ internal static class ReverseWalk 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 when halving: requests are rounded outward to it, so a - // narrower half re-sends the same request (#691.4 review). Keep the Min: a truncated read crowded by rows - // before the segment must still shrink the segment. + // 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(w, gap <= 2 * granularity ? gap : 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)); @@ -248,10 +250,12 @@ internal static class ReverseWalk continue; } - if (segHi - segLo <= resolution) + if (segHi - segLo <= granularity) { - // 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). + // 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; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsPagingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsPagingTests.cs index 89be1c67..dc0ecf85 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsPagingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsPagingTests.cs @@ -446,20 +446,38 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable var expected = events.OrderByDescending(e => e.EventTimeUtc).ThenByDescending(e => e.EventId, StringComparer.Ordinal) .Select(e => e.EventId).ToList(); + // The walk refuses more than MaxTieClusterOverfetch events within ONE MILLISECOND (#691.4 re-review): with the + // small budget a dense sub-millisecond burst is a legitimate BadTooManyOperations — and nothing else is. + var times = events.Select(e => e.EventTimeUtc.Ticks).Order().ToList(); + var msMax = 0; + for (int i = 0, j = 0; i < times.Count; i++) + { + while (times[i] - times[j] >= TimeSpan.TicksPerMillisecond) j++; + msMax = Math.Max(msMax, i - j + 1); + } + var all = new List(); byte[]? cp = null; var pages = 0; + var refused = false; do { var r = ReadEvents(nm, node, max, cp, end, DateTime.MinValue); - r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good, $"seed {seed}"); + if (r.Error.StatusCode.Code == StatusCodes.BadTooManyOperations && msMax > nm.MaxTieClusterOverfetch) + { + refused = true; + break; + } + + r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good, $"seed {seed}, ms max {msMax}"); all.AddRange(r.Ids); cp = r.Cp; (++pages).ShouldBeLessThan(2000); } while (cp is not null); - all.ShouldBe(expected, $"seed {seed}, N = {max}"); + if (refused) all.ShouldBe(expected.Take(all.Count), $"seed {seed}: what came before the refusal"); + else all.ShouldBe(expected, $"seed {seed}, N = {max}"); } await host.DisposeAsync(); 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 b25afb52..747ac185 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs @@ -731,9 +731,38 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable 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); + // The walk refuses more than MaxTieClusterOverfetch samples within ONE MILLISECOND (#691.4 re-review), so with + // the small budget a dense sub-millisecond burst is a legitimate BadTooManyOperations — and nothing else is. + var msMax = 0; + for (int i = 0, j = 0; i < series.Count; i++) + { + while (series[i].SourceTimestampUtc!.Value.Ticks - series[j].SourceTimestampUtc!.Value.Ticks >= TimeSpan.TicksPerMillisecond) j++; + msMax = Math.Max(msMax, i - j + 1); + } - collected.ShouldBe(Enumerable.Range(0, series.Count).Reverse().Select(i => (double)i), $"seed {seed}, N = {max}"); + var collected = new List(); + byte[]? cp = null; + var pages = 0; + var refused = false; + do + { + var (values, error, next) = ReadRaw(nm, nodeId, DateTime.MinValue, end, max, inboundCp: cp); + if (error.StatusCode.Code == StatusCodes.BadTooManyOperations && msMax > nm.MaxTieClusterOverfetch) + { + refused = true; + break; + } + + error.StatusCode.Code.ShouldBe(StatusCodes.Good, $"seed {seed}, N = {max}, ms max {msMax}"); + collected.AddRange(values); + cp = next; + (++pages).ShouldBeLessThan(2000, "paging must terminate, not loop"); + } + while (cp is not null); + + var expected = Enumerable.Range(0, series.Count).Reverse().Select(i => (double)i).ToList(); + if (refused) collected.ShouldBe(expected.Take(collected.Count), $"seed {seed}: what came before the refusal"); + else collected.ShouldBe(expected, $"seed {seed}, N = {max}"); } await host.DisposeAsync(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ReverseWalkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ReverseWalkTests.cs index cfc3193e..12bf4b1a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ReverseWalkTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ReverseWalkTests.cs @@ -32,7 +32,14 @@ public sealed class ReverseWalkTests for (var seed = 0; seed < 400; seed++) { var rng = new Random(seed); - var series = RandomSeries(rng, out var maxCluster); + var series = RandomSeries(rng, out _); + // The refusal unit is one millisecond (#691.4 re-review): the most rows inside any 1 ms window. + var maxCluster = 0; + for (int i = 0, j = 0; i < series.Count; i++) + { + while (Ts(series[i]) - Ts(series[j]) >= TimeSpan.TicksPerMillisecond) j++; + maxCluster = Math.Max(maxCluster, i - j + 1); + } 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); @@ -81,7 +88,7 @@ public sealed class ReverseWalkTests } catch (HistoryReadLimitExceededException) { - if (maxCluster <= tieBudget) failures.Add($"seed {seed}: refused with no cluster over {tieBudget} (max {maxCluster})"); + if (maxCluster <= tieBudget) failures.Add($"seed {seed}: refused with no millisecond over {tieBudget} rows (max {maxCluster})"); break; } } @@ -206,6 +213,33 @@ public sealed class ReverseWalkTests source.Reads.ShouldBeLessThanOrEqualTo(22, string.Join("\n", source.Log)); } + /// + /// #691.4 re-review (S8): a tie cluster under a carried width. 30 rows at one whole-millisecond timestamp 0.3 s below + /// hi, nothing above, N = 1, a carried 1 s width. The truncated read of the cluster left the width at one tick; + /// once the gap above it was under two milliseconds nothing floored it, so segHi stepped down one tick per read — + /// 10,006 reads of the same millisecond (28 before the gallop). + /// + [Theory] + [InlineData(30, 1, 0.3, 1.0)] + [InlineData(34, 1, 0.52, 0.48)] + [InlineData(25, 1, 1.2, 1.0)] + [InlineData(40, 3, 3.5, 2.0)] + public async Task A_tie_cluster_under_a_carried_width_is_read_in_a_bounded_number_of_reads( + int ties, int want, double secondsBelow, double hintSeconds) + { + var at = T0.Ticks - (long)(secondsBelow * TimeSpan.TicksPerSecond); + at -= at % TimeSpan.TicksPerMillisecond; + var series = Enumerable.Range(0, ties).Select(i => At(at, i)).ToList(); + var source = new AvevaShapedSource(series); + + var page = await ReverseRawReader.ReadAsync(source, "T", T0, DateTime.MinValue, (uint)want, 65536, T0, + new ReverseWalkHint((long)(hintSeconds * TimeSpan.TicksPerSecond)), Ct); + + page.Samples.Select(s => (double)s.Value!).ShouldBe(Enumerable.Range(ties - want, want).Reverse().Select(i => (double)i)); + source.Reads.ShouldBeLessThanOrEqualTo(30, string.Join("\n", source.Log.Take(40))); + source.RepeatedRequests.ShouldBe(0); + } + private static long Ts(DataValueSnapshot s) => s.SourceTimestampUtc!.Value.Ticks; /// Random ascending series: bursts, gaps, sub-millisecond stamps and tie clusters.