using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using Opc.Ua; using Opc.Ua.Server; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult; using SdkHistoryReadResult = Opc.Ua.HistoryReadResult; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// Phase-D server-side HistoryRead continuation-point paging for the Raw arm. Boots a real /// (as does), injects an /// (the in-process harness uses a SESSION-LESS /// OperationContext, so the production session-backed store has no session to bind to — the /// in-memory store exercises the SAME dispatch path), and a series-backed fake historian, then drives /// the full multi-page round trip through the node manager's public HistoryRead. /// public sealed class NodeManagerHistoryReadPagingTests : IDisposable { private static CancellationToken Ct => TestContext.Current.CancellationToken; private readonly string _pkiRoot = Path.Combine( Path.GetTempPath(), $"otopcua-historypaging-{Guid.NewGuid():N}"); private static readonly DateTime Epoch = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); /// A full first page emits a continuation point; resuming returns subsequent pages; the /// final short page clears the continuation point — and the union of pages is the complete series with /// no duplicate or skipped sample. (#692: every page is full — a resume re-reads the boundary ties it trims plus one /// lookahead row, so 250 at 100 is exactly three pages of 100, 100 and 50.) [Fact] public async Task Raw_pages_full_series_across_continuation_points() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore(); // 250 distinct-timestamp samples; client caps each page at 100. var series = MakeSeries(count: 250, stepSeconds: 1); nm.HistorianDataSource = new SeriesHistorianDataSource(series); nm.EnsureVariable("eq-1/pv", parentFolderNodeId: null, displayName: "PV", dataType: "Double", writable: false, historianTagname: "WW.PV", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/pv")!.NodeId; var collected = new List(); byte[]? cp = null; var pageCount = 0; var sawCp = false; do { var (values, error, nextCp) = ReadRaw(nm, nodeId, start: Epoch, end: Epoch.AddHours(1), max: 100, inboundCp: cp); error.StatusCode.Code.ShouldBe(StatusCodes.Good); collected.AddRange(values); cp = nextCp; if (cp is not null) sawCp = true; pageCount++; pageCount.ShouldBeLessThan(20, "paging must terminate, not loop"); } while (cp is not null); sawCp.ShouldBeTrue("a 250-sample series capped at 100 must page"); pageCount.ShouldBe(3, "100 + 100 + 50 — every page full (#692)"); // The union is the exact series — no duplicates, no skips, in order. collected.Count.ShouldBe(250); collected.ShouldBe(Enumerable.Range(0, 250).Select(i => (double)i)); await host.DisposeAsync(); } /// Part 11: only a StartTime (EndTime unset = MinValue) with a cap pages FORWARD to an open upper bound — /// it must not be mistaken for a reverse read, and the stored cursor must not carry MinValue as its end. [Fact] public async Task Raw_open_ended_forward_read_pages_ascending_exactly_once() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore(); nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1)); nm.EnsureVariable("eq-1/open", parentFolderNodeId: null, displayName: "Open", dataType: "Double", writable: false, historianTagname: "WW.Open", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/open")!.NodeId; var collected = new List(); byte[]? cp = null; var pages = 0; do { var (values, error, next) = ReadRaw(nm, nodeId, Epoch, DateTime.MinValue, max: 100, inboundCp: cp); error.StatusCode.Code.ShouldBe(StatusCodes.Good); collected.AddRange(values); cp = next; (++pages).ShouldBeLessThan(20); } while (cp is not null); collected.ShouldBe(Enumerable.Range(0, 250).Select(i => (double)i)); await host.DisposeAsync(); } /// Part 11: only an EndTime (StartTime unset = MinValue) with a cap reads BACKWARD from the EndTime. [Fact] public async Task Raw_end_only_read_pages_descending_from_the_end_time() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore(); nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1)); nm.EnsureVariable("eq-1/endonly", parentFolderNodeId: null, displayName: "EndOnly", dataType: "Double", writable: false, historianTagname: "WW.EndOnly", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/endonly")!.NodeId; var collected = new List(); byte[]? cp = null; var pages = 0; do { var (values, error, next) = ReadRaw(nm, nodeId, DateTime.MinValue, Epoch.AddHours(1), max: 100, inboundCp: cp); error.StatusCode.Code.ShouldBe(StatusCodes.Good); collected.AddRange(values); cp = next; (++pages).ShouldBeLessThan(20); } while (cp is not null); collected.ShouldBe(Enumerable.Range(0, 250).Reverse().Select(i => (double)i)); await host.DisposeAsync(); } /// An exactly-full series whose total is a multiple of the cap ends on its last full page: each read asks for /// one lookahead row past the page (#692), so the server knows the page is the last and emits no continuation point — /// there is no trailing EMPTY page any more. A stale point resumed anyway still terminates with GoodNoData. [Fact] public async Task Raw_exact_multiple_ends_on_its_last_full_page() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore(); nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(count: 100, stepSeconds: 1)); nm.EnsureVariable("eq-1/exact", parentFolderNodeId: null, displayName: "Exact", dataType: "Double", writable: false, historianTagname: "WW.Exact", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/exact")!.NodeId; var (r1, _, cp1) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: null); r1.Count.ShouldBe(100); cp1.ShouldBeNull("the lookahead row found nothing past the page — it is the last"); // Positive control: one more sample past the page ⇒ the same full page DOES carry a continuation point. nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(count: 101, stepSeconds: 1)); var (r2, e2, cp2) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: null); r2.Count.ShouldBe(100); e2.StatusCode.Code.ShouldBe(StatusCodes.Good); cp2.ShouldNotBeNull(); var (r3, _, cp3) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: cp2); r3.ShouldBe([100d]); cp3.ShouldBeNull(); await host.DisposeAsync(); } /// Boundary timestamps that TIE across the page split are neither duplicated nor skipped: /// the resume cursor drops the already-emitted ties and re-reads the un-emitted ones at the same /// timestamp. [Fact] public async Task Raw_paging_dedups_tied_boundary_timestamps() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore(); // 5 samples: indices 2,3,4 all share the SAME timestamp (Epoch+2s). With cap 4, page 1 returns // [0,1,2,3] (cutting through the tie cluster); page 2 must return [4] (the un-emitted tie) only. var ts = new[] { Epoch, // 0 Epoch.AddSeconds(1), // 1 Epoch.AddSeconds(2), // 2 ┐ Epoch.AddSeconds(2), // 3 │ tied at Epoch+2s Epoch.AddSeconds(2), // 4 ┘ }; var series = ts.Select((t, i) => new DataValueSnapshot((double)i, StatusCodes.Good, t, t)).ToArray(); nm.HistorianDataSource = new SeriesHistorianDataSource(series); nm.EnsureVariable("eq-1/tie", parentFolderNodeId: null, displayName: "Tie", dataType: "Double", writable: false, historianTagname: "WW.Tie", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/tie")!.NodeId; var collected = new List(); var (r1, _, cp1) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 4, inboundCp: null); r1.Count.ShouldBe(4); r1.ShouldBe(new[] { 0.0, 1.0, 2.0, 3.0 }); cp1.ShouldNotBeNull(); collected.AddRange(r1); var (r2, _, cp2) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 4, inboundCp: cp1); // Only the un-emitted tie (index 4) — NOT the already-emitted 2 and 3. r2.ShouldBe(new[] { 4.0 }); cp2.ShouldBeNull(); collected.AddRange(r2); // Exactly the 5 distinct samples, once each. collected.ShouldBe(new[] { 0.0, 1.0, 2.0, 3.0, 4.0 }); await host.DisposeAsync(); } /// Oversized tie cluster (more ties at one timestamp than the page cap): the (timestamp, skip) /// resume cursor alone cannot advance past it — the fixed-(start,end,cap) backend keeps returning the /// same first cap ties. The paging now over-fetches the WHOLE cluster (a start == end read, /// bounded by MaxTieClusterOverfetch) and pages WITHIN the timestamp, so the read drains the /// cluster (and any data after it) across continuation points with no dup/skip. Here a tie cluster of 3 /// (indices 3..7 share one timestamp) sits between distinct samples; with cap 2 the union must be all 10 /// values in order. [Fact] public async Task Raw_oversized_tie_cluster_pages_within_the_timestamp() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore(); // 3 distinct, then a tie cluster of 5 (all at Epoch+3s), then 2 more distinct — 10 total. The tie // cluster (5) is larger than the page cap (2), the case that used to stall the cursor. var series = MakeSeriesWithTieCluster(distinctBefore: 3, tieCount: 5, distinctAfter: 2); series.Length.ShouldBe(10); nm.HistorianDataSource = new SeriesHistorianDataSource(series); nm.EnsureVariable("eq-1/burst", parentFolderNodeId: null, displayName: "Burst", dataType: "Double", writable: false, historianTagname: "WW.Burst", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/burst")!.NodeId; var collected = new List(); byte[]? cp = null; var pageCount = 0; do { var (values, error, nextCp) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 2, inboundCp: cp); error.StatusCode.Code.ShouldBe(StatusCodes.Good); collected.AddRange(values); cp = nextCp; pageCount++; pageCount.ShouldBeLessThan(20, "paging must terminate, not loop, even through a tie cluster"); } while (cp is not null); // Every value, once, in order — the cluster was paged through, not dropped or duplicated. collected.Count.ShouldBe(10); collected.ShouldBe(Enumerable.Range(0, 10).Select(i => (double)i)); await host.DisposeAsync(); } /// The absurd-burst backstop is preserved: a tie cluster STRICTLY larger than the configured /// still fails that node rather than buffering an unbounded /// burst — BadTooManyOperations, the request-limit code the reverse walk-back uses too (#686; was /// BadHistoryOperationUnsupported). With /// the bound set to 3 and a 5-way tie cluster, the resume read that hits the cluster fails loudly. [Fact] public async Task Raw_tie_cluster_beyond_overfetch_bound_fails_loudly() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore(); nm.MaxTieClusterOverfetch = 3; // cluster of 5 will exceed this ⇒ backstop. // 5 samples ALL sharing one timestamp (Epoch+2s) — a tie cluster of 5 with a page cap of 2. var t = Epoch.AddSeconds(2); var series = Enumerable.Range(0, 5) .Select(i => new DataValueSnapshot((double)i, StatusCodes.Good, t, t)).ToArray(); nm.HistorianDataSource = new SeriesHistorianDataSource(series); nm.EnsureVariable("eq-1/absurd", parentFolderNodeId: null, displayName: "Absurd", dataType: "Double", writable: false, historianTagname: "WW.Absurd", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/absurd")!.NodeId; // Page 1: a full page of the first 2 ties, with a continuation point. var (r1, e1, cp1) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 2, inboundCp: null); e1.StatusCode.Code.ShouldBe(StatusCodes.Good); r1.ShouldBe(new[] { 0.0, 1.0 }); cp1.ShouldNotBeNull(); // Page 2: the resume read hits the cluster; over-fetch finds 5 > bound 3 ⇒ a clear error, NOT a // silent GoodNoData that would drop the un-emitted ties. var (r2, e2, cp2) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 2, inboundCp: cp1); e2.StatusCode.Code.ShouldBe(StatusCodes.BadTooManyOperations); r2.ShouldBeEmpty(); cp2.ShouldBeNull(); await host.DisposeAsync(); } /// NumValuesPerNode == 0 ("all values") never pages — the whole series returns in one shot /// with a null continuation point. [Fact] public async Task Raw_unlimited_request_returns_everything_without_a_cp() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore(); nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1)); nm.EnsureVariable("eq-1/all", parentFolderNodeId: null, displayName: "All", dataType: "Double", writable: false, historianTagname: "WW.All", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/all")!.NodeId; var (r, e, cp) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 0, inboundCp: null); e.StatusCode.Code.ShouldBe(StatusCodes.Good); r.Count.ShouldBe(250); cp.ShouldBeNull("an unlimited (max==0) request must not page"); await host.DisposeAsync(); } /// An inbound continuation point the store doesn't know (released / never issued / from /// another node) yields BadContinuationPointInvalid for that node, and the source is NOT read. [Fact] public async Task Raw_unknown_continuation_point_yields_BadContinuationPointInvalid() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore(); var fake = new SeriesHistorianDataSource(MakeSeries(count: 10, stepSeconds: 1)); nm.HistorianDataSource = fake; nm.EnsureVariable("eq-1/bad-cp", parentFolderNodeId: null, displayName: "BadCp", dataType: "Double", writable: false, historianTagname: "WW.BadCp", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/bad-cp")!.NodeId; fake.ResetReadCount(); var bogus = Guid.NewGuid().ToByteArray(); var (_, e, cp) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: bogus); e.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid); cp.ShouldBeNull(); fake.ReadCount.ShouldBe(0); // never reached the backend. await host.DisposeAsync(); } /// releaseContinuationPoints drops the stored cursor WITHOUT reading data: a subsequent /// resume of the released point then misses (BadContinuationPointInvalid). [Fact] public async Task Release_drops_the_cursor_without_reading() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore(); var fake = new SeriesHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1)); nm.HistorianDataSource = fake; nm.EnsureVariable("eq-1/rel", parentFolderNodeId: null, displayName: "Rel", dataType: "Double", writable: false, historianTagname: "WW.Rel", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/rel")!.NodeId; // Page 1 — get a CP. var (_, _, cp1) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: null); cp1.ShouldNotBeNull(); // Release it — the dispatcher routes releaseContinuationPoints=true to HistoryReleaseContinuationPoints, // which never reaches the Raw arm. No data is read. fake.ResetReadCount(); InvokeRelease(nm, nodeId, cp1!); fake.ReadCount.ShouldBe(0); // Resuming the released point now misses. var (_, e, _) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: cp1); e.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid); await host.DisposeAsync(); } /// /// A reverse read (start > end) with NumValuesPerNode = 1 over three distinct-timestamp samples must /// page through every sample, newest first. Before the fix the second page's resume read re-returned the /// boundary sample, the trim emptied it, the tie-cluster path drained the one-sample "cluster" and stepped /// FORWARD to T + 1 tick — past a reverse window's end — so paging stopped after the first sample. /// [Fact] public async Task Raw_reverse_read_with_one_value_per_page_returns_every_sample_descending() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore(); nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(count: 3, stepSeconds: 1)); nm.EnsureVariable("eq-1/rev", parentFolderNodeId: null, displayName: "Rev", dataType: "Double", writable: false, historianTagname: "WW.Rev", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/rev")!.NodeId; var collected = await DrainAsync(nm, nodeId, start: Epoch.AddHours(1), end: Epoch.AddSeconds(-1), max: 1); collected.ShouldBe([2d, 1d, 0d]); await host.DisposeAsync(); } /// The reverse mirror of : a 5-way tie /// cluster between distinct samples, cap 2, read newest-first — every value once, in descending order. [Fact] public async Task Raw_reverse_read_pages_through_an_oversized_tie_cluster() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore(); nm.HistorianDataSource = new SeriesHistorianDataSource( MakeSeriesWithTieCluster(distinctBefore: 3, tieCount: 5, distinctAfter: 2)); nm.EnsureVariable("eq-1/revburst", parentFolderNodeId: null, displayName: "RevBurst", dataType: "Double", writable: false, historianTagname: "WW.RevBurst", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/revburst")!.NodeId; var collected = await DrainAsync(nm, nodeId, start: Epoch.AddHours(1), end: Epoch.AddSeconds(-1), max: 2); collected.ShouldBe(Enumerable.Range(0, 10).Reverse().Select(i => (double)i)); await host.DisposeAsync(); } /// /// Forward paging at NumValuesPerNode = 1 over a MICROSECOND backend (QuestDB: it truncates every inbound /// time to 10 ticks and reports TimestampResolutionTicks = 10) returns every sample exactly once and /// terminates. Before the fix the drained one-sample "cluster" resumed at T + 1 tick, which the backend /// truncated back to T: the same sample came back with no boundary skip, and the read looped forever. /// [Fact] public async Task Raw_forward_read_with_one_value_per_page_on_a_microsecond_backend_returns_every_sample_once() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore(); // Microsecond-spaced samples, so a one-tick step would land strictly between two of them. var series = Enumerable.Range(0, 6) .Select(i => { var t = Epoch.AddTicks(10L * i); return new DataValueSnapshot((double)i, StatusCodes.Good, t, t); }) .ToArray(); nm.HistorianDataSource = new MicrosecondHistorianDataSource(series); nm.EnsureVariable("eq-1/us", parentFolderNodeId: null, displayName: "Us", dataType: "Double", writable: false, historianTagname: "WW.Us", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/us")!.NodeId; var collected = await DrainAsync(nm, nodeId, start: Epoch, end: Epoch.AddHours(1), max: 1); collected.ShouldBe([0d, 1d, 2d, 3d, 4d, 5d]); await host.DisposeAsync(); } /// The reverse mirror on the same microsecond backend: newest first, each sample once. [Fact] public async Task Raw_reverse_read_with_one_value_per_page_on_a_microsecond_backend_returns_every_sample_once() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore(); var series = Enumerable.Range(0, 6) .Select(i => { var t = Epoch.AddTicks(10L * (i + 1)); return new DataValueSnapshot((double)i, StatusCodes.Good, t, t); }) .ToArray(); nm.HistorianDataSource = new MicrosecondHistorianDataSource(series); nm.EnsureVariable("eq-1/usrev", parentFolderNodeId: null, displayName: "UsRev", dataType: "Double", writable: false, historianTagname: "WW.UsRev", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/usrev")!.NodeId; var collected = await DrainAsync(nm, nodeId, start: Epoch.AddHours(1), end: Epoch, max: 1); collected.ShouldBe([5d, 4d, 3d, 2d, 1d, 0d]); await host.DisposeAsync(); } // --- #686: reverse / end-only raw reads on a backend that only reads forward (the Gateway) ------------------------- /// Boots a node manager over a and one historized variable. private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager Nm, NodeId NodeId)> BootForwardOnlyAsync( ForwardOnlyHistorianDataSource source, string name) { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore(); nm.HistorianDataSource = source; nm.EnsureVariable($"eq-1/{name}", parentFolderNodeId: null, displayName: name, dataType: "Double", writable: false, historianTagname: $"WW.{name}", realm: AddressSpaceRealm.Uns); return (host, nm, nm.TryGetVariable($"eq-1/{name}")!.NodeId); } /// /// #686: an end-only capped read (Part 11: page BACKWARD from EndTime) on a backend that cannot read newest-first /// pages every sample newest-first — and never sends that backend start > end (the gateway rejects it /// InvalidArgument, which used to fail the node and count as a backend fault). /// [Fact] public async Task Raw_end_only_read_on_a_forward_only_backend_pages_descending_without_a_reverse_window() { var source = new ForwardOnlyHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1)); var (host, nm, nodeId) = await BootForwardOnlyAsync(source, "FwdEndOnly"); var collected = await DrainAsync(nm, nodeId, DateTime.MinValue, Epoch.AddHours(1), max: 100); collected.ShouldBe(Enumerable.Range(0, 250).Reverse().Select(i => (double)i)); source.ReverseWindowCount.ShouldBe(0); await host.DisposeAsync(); } /// #686: the first page of an end-only capped read is the N values AT OR BEFORE EndTime, newest first — /// not the oldest N in history (the pre-#669 forward meaning), and not the synthetic boundary row the AVEVA-shaped /// fake emits at each query start. [Fact] public async Task Raw_end_only_capped_read_on_a_forward_only_backend_returns_the_latest_values_before_end_time() { var source = new ForwardOnlyHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1)); var (host, nm, nodeId) = await BootForwardOnlyAsync(source, "FwdLatest"); var (values, error, cp) = ReadRaw(nm, nodeId, DateTime.MinValue, Epoch.AddSeconds(100), max: 3, inboundCp: null); error.StatusCode.Code.ShouldBe(StatusCodes.Good); values.ShouldBe([100d, 99d, 98d]); cp.ShouldNotBeNull(); source.ReverseWindowCount.ShouldBe(0); await host.DisposeAsync(); } /// #686: an explicit reverse read with no cap returns the whole window newest-first in one page. [Fact] public async Task Raw_unbounded_explicit_reverse_read_on_a_forward_only_backend_returns_the_window_descending() { var source = new ForwardOnlyHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1)); var (host, nm, nodeId) = await BootForwardOnlyAsync(source, "FwdRevAll"); // (end, start] = (Epoch+9s, Epoch+200s] — both edges exercised. var (values, error, cp) = ReadRaw(nm, nodeId, Epoch.AddSeconds(200), Epoch.AddSeconds(9), max: 0, inboundCp: null); error.StatusCode.Code.ShouldBe(StatusCodes.Good); values.ShouldBe(Enumerable.Range(10, 191).Reverse().Select(i => (double)i)); cp.ShouldBeNull(); source.ReverseWindowCount.ShouldBe(0); source.ReadCount.ShouldBe(1, "an uncapped reverse read is ONE forward read, reversed in memory — not a walk"); await host.DisposeAsync(); } /// #686: a reverse read paging through a tie cluster on a forward-only backend — every value once, newest first. [Fact] public async Task Raw_reverse_read_on_a_forward_only_backend_pages_through_a_tie_cluster() { // No AVEVA boundary row here: paging WITHIN the cluster re-reads it with the node manager's start == end point // over-fetch, and a row fabricated at a point read's start is indistinguishable from a tie at that instant — // dropping it is the Gateway source's job (#687), not the walk's. var source = new ForwardOnlyHistorianDataSource( MakeSeriesWithTieCluster(distinctBefore: 3, tieCount: 5, distinctAfter: 2), emitBoundaryRow: false); var (host, nm, nodeId) = await BootForwardOnlyAsync(source, "FwdRevBurst"); var collected = await DrainAsync(nm, nodeId, start: Epoch.AddHours(1), end: Epoch.AddSeconds(-1), max: 2); collected.ShouldBe(Enumerable.Range(0, 10).Reverse().Select(i => (double)i)); source.ReverseWindowCount.ShouldBe(0); await host.DisposeAsync(); } /// #686: a segment denser than the per-read row budget (MaxTieClusterOverfetch) is narrowed and re-read, /// never answered from a truncated forward read. [Fact] public async Task Raw_reverse_read_on_a_forward_only_backend_narrows_a_segment_over_the_row_budget() { var source = new ForwardOnlyHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1)); var (host, nm, nodeId) = await BootForwardOnlyAsync(source, "FwdDense"); nm.MaxTieClusterOverfetch = 16; // a 1-minute segment holds 60 samples var collected = await DrainAsync(nm, nodeId, DateTime.MinValue, Epoch.AddHours(1), max: 100); collected.ShouldBe(Enumerable.Range(0, 250).Reverse().Select(i => (double)i)); source.MaxRowsReturned.ShouldBeLessThanOrEqualTo(2 * 16 + 4); source.ReverseWindowCount.ShouldBe(0); await host.DisposeAsync(); } /// #686: sparse history far before EndTime is found, in a bounded number of forward reads. [Fact] public async Task Raw_end_only_read_on_a_forward_only_backend_finds_sparse_history_in_bounded_reads() { static DataValueSnapshot At(int year, double v) { var t = new DateTime(year, 1, 1, 0, 0, 0, DateTimeKind.Utc); return new DataValueSnapshot(v, StatusCodes.Good, t, t); } var source = new ForwardOnlyHistorianDataSource([At(1990, 1), At(2000, 2)]); var (host, nm, nodeId) = await BootForwardOnlyAsync(source, "FwdSparse"); var (values, error, cp) = ReadRaw(nm, nodeId, DateTime.MinValue, Epoch, max: 5, inboundCp: null); error.StatusCode.Code.ShouldBe(StatusCodes.Good); values.ShouldBe([2d, 1d]); cp.ShouldBeNull(); source.ReadCount.ShouldBeLessThanOrEqualTo(20); source.MinRequestedStart.ShouldBeGreaterThanOrEqualTo(DateTime.UnixEpoch, "the walk never asks for data before 1970"); source.ReverseWindowCount.ShouldBe(0); await host.DisposeAsync(); } /// #686: a single-timestamp tie cluster larger than the row budget cannot be read newest-first from /// forward reads — the node is refused BadTooManyOperations (a request limit, not a backend fault). [Fact] public async Task Raw_reverse_read_on_a_forward_only_backend_refuses_a_tie_cluster_over_the_row_budget() { var source = new ForwardOnlyHistorianDataSource(MakeSeriesWithTieCluster(distinctBefore: 1, tieCount: 10, distinctAfter: 0)); var (host, nm, nodeId) = await BootForwardOnlyAsync(source, "FwdHugeTie"); nm.MaxTieClusterOverfetch = 4; var (_, error, cp) = ReadRaw(nm, nodeId, Epoch.AddHours(1), Epoch.AddSeconds(-1), max: 2, inboundCp: null); error.StatusCode.Code.ShouldBe(StatusCodes.BadTooManyOperations); cp.ShouldBeNull(); source.ReverseWindowCount.ShouldBe(0); await host.DisposeAsync(); } /// #686 review: a dense tag is not re-discovered every page — the width the walk ended on rides the /// continuation point, so after the first page each page is about one read, with no full (discarded) reads. [Fact] public async Task Raw_reverse_paging_on_a_forward_only_backend_carries_the_observed_density_between_pages() { // 2000 samples 10 ms apart: a 1-minute first segment holds all of them, far over a budget of 64. var series = Enumerable.Range(0, 2000) .Select(i => { var t = Epoch.AddMilliseconds(10L * i); return new DataValueSnapshot((double)i, StatusCodes.Good, t, t); }) .ToArray(); var source = new ForwardOnlyHistorianDataSource(series); var (host, nm, nodeId) = await BootForwardOnlyAsync(source, "FwdDensity"); nm.MaxTieClusterOverfetch = 64; // Page 1 finds the data (walking down an empty hour) and its density; measure the pages after it. var (first, _, cp) = ReadRaw(nm, nodeId, DateTime.MinValue, Epoch.AddSeconds(20), max: 20, inboundCp: null); first.ShouldBe(Enumerable.Range(1980, 20).Reverse().Select(i => (double)i)); source.ResetCounts(); var collected = new List(first); var pages = 0; while (cp is not null) { var (values, error, next) = ReadRaw(nm, nodeId, DateTime.MinValue, Epoch.AddSeconds(20), max: 20, inboundCp: cp); error.StatusCode.Code.ShouldBe(StatusCodes.Good); collected.AddRange(values); cp = next; (++pages).ShouldBeLessThan(200); } collected.ShouldBe(Enumerable.Range(0, 2000).Reverse().Select(i => (double)i)); // The discriminating count: reads that came back full and were thrown away. Without the carried width every page // starts on a 1-minute segment holding all 2000 samples and discards full reads first (~2 per page). source.FullReads.ShouldBeLessThanOrEqualTo(2); // About one read per page, plus the final pages' walks from the first sample down to the 1970 floor (~16 each). source.ReadCount.ShouldBeLessThanOrEqualTo(pages + 40); 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(); // #692: every page is full (N = 3) — a resumed page used to net N − 1 after trimming its boundary sample. values.ShouldBe([expected, expected - 1d, expected - 2d], $"page {page} continues exactly"); expected -= 3; 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); // 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); } 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(); } /// /// #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] public async Task Raw_reverse_read_from_max_value_on_a_forward_only_backend_starts_at_now_plus_one_day() { var source = new ForwardOnlyHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1)); var (host, nm, nodeId) = await BootForwardOnlyAsync(source, "FwdMax"); var now = Epoch.AddHours(1); nm.Clock = new FixedClock(now); var (values, error, _) = ReadRaw(nm, nodeId, DateTime.MaxValue, Epoch.AddSeconds(-1), max: 3, inboundCp: null); error.StatusCode.Code.ShouldBe(StatusCodes.Good); values.ShouldBe([249d, 248d, 247d]); source.MaxRequestedEnd.ShouldBeLessThanOrEqualTo(now.AddDays(1).AddMilliseconds(1)); source.ReadCount.ShouldBeLessThanOrEqualTo(10); await host.DisposeAsync(); } /// #686 review: AVEVA may stamp the row it fabricates at a query start rounded UP to the millisecond. The walk /// requests millisecond-aligned starts, so that row can never land inside (EndTime, …] when EndTime is not on a /// millisecond. [Fact] public async Task Raw_reverse_read_on_a_forward_only_backend_never_returns_a_boundary_row_rounded_up_into_the_window() { var source = new ForwardOnlyHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1), boundaryAtCeilingMs: true); var (host, nm, nodeId) = await BootForwardOnlyAsync(source, "FwdCeil"); var (values, error, _) = ReadRaw(nm, nodeId, Epoch.AddSeconds(10), Epoch.AddTicks(5_000), max: 100, inboundCp: null); error.StatusCode.Code.ShouldBe(StatusCodes.Good); values.ShouldBe(Enumerable.Range(1, 10).Reverse().Select(i => (double)i)); // (Epoch+0.5ms, Epoch+10s]: no 0 await host.DisposeAsync(); } /// #686 review: the upper edge is filtered too — the request end is rounded UP to the millisecond, so a /// sample one tick after StartTime comes back from the backend and must not be returned. [Fact] public async Task Raw_reverse_read_on_a_forward_only_backend_excludes_a_sample_one_tick_after_the_start() { DataValueSnapshot At(long ticks, double v) { var t = Epoch.AddTicks(ticks); return new DataValueSnapshot(v, StatusCodes.Good, t, t); } var t0 = TimeSpan.TicksPerSecond; var source = new ForwardOnlyHistorianDataSource([At(0, 0), At(t0, 1), At(t0 + 1, 99)]); var (host, nm, nodeId) = await BootForwardOnlyAsync(source, "FwdUpper"); var (values, error, _) = ReadRaw(nm, nodeId, Epoch.AddTicks(t0), Epoch.AddSeconds(-1), max: 10, inboundCp: null); error.StatusCode.Code.ShouldBe(StatusCodes.Good); values.ShouldBe([1d, 0d]); await host.DisposeAsync(); } /// #686 review: a tie cluster of EXACTLY MaxTieClusterOverfetch is served — the row the backend /// fabricates at the query start does not consume the budget. [Fact] public async Task Raw_reverse_read_on_a_forward_only_backend_serves_a_tie_cluster_of_exactly_the_budget() { var source = new ForwardOnlyHistorianDataSource(MakeSeriesWithTieCluster(distinctBefore: 2, tieCount: 4, distinctAfter: 2)); var (host, nm, nodeId) = await BootForwardOnlyAsync(source, "FwdExact"); nm.MaxTieClusterOverfetch = 4; var (values, error, _) = ReadRaw(nm, nodeId, Epoch.AddHours(1), Epoch.AddSeconds(-1), max: 100, inboundCp: null); error.StatusCode.Code.ShouldBe(StatusCodes.Good); values.ShouldBe(Enumerable.Range(0, 8).Reverse().Select(i => (double)i)); await host.DisposeAsync(); } /// #686 review: adjacent tie clusters one millisecond apart, none over the budget, are served — a neighbour /// inside the request's alignment slack does not get the segment refused. [Fact] public async Task Raw_reverse_read_on_a_forward_only_backend_serves_adjacent_tie_clusters_within_the_budget() { var series = new List(); var v = 0; for (var c = 0; c < 3; c++) { var t = Epoch.AddSeconds(5).AddMilliseconds(c); for (var i = 0; i < 4; i++) series.Add(new DataValueSnapshot((double)v++, StatusCodes.Good, t, t)); } var source = new ForwardOnlyHistorianDataSource(series); var (host, nm, nodeId) = await BootForwardOnlyAsync(source, "FwdAdjacent"); nm.MaxTieClusterOverfetch = 4; var (values, error, _) = ReadRaw(nm, nodeId, Epoch.AddHours(1), Epoch.AddSeconds(-1), max: 100, inboundCp: null); error.StatusCode.Code.ShouldBe(StatusCodes.Good); values.ShouldBe(Enumerable.Range(0, 12).Reverse().Select(i => (double)i)); await host.DisposeAsync(); } /// #686 review: a MaxTieClusterOverfetch of 1 (or a misconfigured 0) does not make every reverse read on /// a forward-only backend refuse — distinct-timestamp data is still served. [Theory] [InlineData(1)] [InlineData(0)] public async Task Raw_reverse_read_on_a_forward_only_backend_works_with_a_minimal_budget(int budget) { var source = new ForwardOnlyHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1)); var (host, nm, nodeId) = await BootForwardOnlyAsync(source, $"FwdMin{budget}"); nm.MaxTieClusterOverfetch = budget; var (values, error, _) = ReadRaw(nm, nodeId, DateTime.MinValue, Epoch.AddSeconds(100), max: 3, inboundCp: null); error.StatusCode.Code.ShouldBe(StatusCodes.Good); values.ShouldBe([100d, 99d, 98d]); await host.DisposeAsync(); } /// A backend that DECLARES native reverse support is still sent the reverse window — one read. [Fact] public async Task Raw_end_only_read_on_a_reverse_capable_backend_is_one_native_reverse_read() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore(); var source = new SeriesHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1)); nm.HistorianDataSource = source; nm.EnsureVariable("eq-1/native", parentFolderNodeId: null, displayName: "Native", dataType: "Double", writable: false, historianTagname: "WW.Native", realm: AddressSpaceRealm.Uns); var nodeId = nm.TryGetVariable("eq-1/native")!.NodeId; var (values, _, _) = ReadRaw(nm, nodeId, DateTime.MinValue, Epoch.AddSeconds(100), max: 3, inboundCp: null); values.ShouldBe([100d, 99d, 98d]); source.ReadCount.ShouldBe(1); await host.DisposeAsync(); } /// Pages a Raw read to completion and returns the union of the pages in arrival order. private static Task> DrainAsync(OtOpcUaNodeManager nm, NodeId nodeId, DateTime start, DateTime end, uint max, int maxPages = 40) { var collected = new List(); byte[]? cp = null; var pageCount = 0; do { var (values, error, nextCp) = ReadRaw(nm, nodeId, start, end, max, inboundCp: cp); error.StatusCode.Code.ShouldBe(StatusCodes.Good); collected.AddRange(values); cp = nextCp; pageCount++; pageCount.ShouldBeLessThan(maxPages, "paging must terminate, not loop"); } while (cp is not null); return Task.FromResult(collected); } // --- helpers -------------------------------------------------------------------------------- /// Issue a single-node Raw HistoryRead and return that node's decoded sample values, error, /// and outbound continuation point. private static (IReadOnlyList Values, ServiceResult Error, byte[]? Cp) ReadRaw( OtOpcUaNodeManager nm, NodeId nodeId, DateTime start, DateTime end, uint max, byte[]? inboundCp) { var context = new OperationContext( new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null); var details = new ReadRawModifiedDetails { ReturnBounds = false, StartTime = start, EndTime = end, NumValuesPerNode = max, IsReadModified = false, }; var nodesToRead = new List { new() { NodeId = nodeId, ContinuationPoint = inboundCp }, }; var results = new List { null! }; var errors = new List { null! }; nm.HistoryRead(context, details, TimestampsToReturn.Both, releaseContinuationPoints: false, nodesToRead, results, errors); var values = new List(); if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho)) { var data = (HistoryData)ExtensionObject.ToEncodeable(ho); foreach (var dv in data.DataValues) values.Add((double)dv.Value); } return (values, errors[0], results[0]?.ContinuationPoint); } /// Issue a release-only HistoryRead (releaseContinuationPoints=true) for the node + point. private static void InvokeRelease(OtOpcUaNodeManager nm, NodeId nodeId, byte[] cp) { var context = new OperationContext( new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null); var details = new ReadRawModifiedDetails { ReturnBounds = false, StartTime = Epoch, EndTime = Epoch.AddHours(1), NumValuesPerNode = 100, IsReadModified = false, }; var nodesToRead = new List { new() { NodeId = nodeId, ContinuationPoint = cp } }; var results = new List { null! }; var errors = new List { null! }; nm.HistoryRead(context, details, TimestampsToReturn.Both, releaseContinuationPoints: true, nodesToRead, results, errors); } private static DataValueSnapshot[] MakeSeries(int count, int stepSeconds) => Enumerable.Range(0, count) .Select(i => { var t = Epoch.AddSeconds((long)i * stepSeconds); return new DataValueSnapshot((double)i, StatusCodes.Good, t, t); }) .ToArray(); /// Build a sorted series: distinct-second samples, then a /// run of samples ALL at the same timestamp (the next second after the /// last distinct one — the oversized tie cluster), then distinct /// samples each a second later. Values are 0..N-1 in series order so the union assertion is exact. private static DataValueSnapshot[] MakeSeriesWithTieCluster(int distinctBefore, int tieCount, int distinctAfter) { var samples = new List(distinctBefore + tieCount + distinctAfter); var value = 0; var second = 0; for (var i = 0; i < distinctBefore; i++, second++) { var t = Epoch.AddSeconds(second); samples.Add(new DataValueSnapshot((double)value++, StatusCodes.Good, t, t)); } var tieT = Epoch.AddSeconds(second++); // the single shared timestamp for the whole cluster for (var i = 0; i < tieCount; i++) samples.Add(new DataValueSnapshot((double)value++, StatusCodes.Good, tieT, tieT)); for (var i = 0; i < distinctAfter; i++, second++) { var t = Epoch.AddSeconds(second); samples.Add(new DataValueSnapshot((double)value++, StatusCodes.Good, t, t)); } return samples.ToArray(); } /// A series-backed fake historian: holds a full sorted series and serves a Raw read by /// returning the samples in [start, end] capped at maxValuesPerNode (start inclusive — exactly the /// resume-cursor contract). Processed / AtTime / Events are not exercised here. private sealed class SeriesHistorianDataSource(IReadOnlyList series) : IHistorianDataSource { private int _readCount; /// Serves start > end natively — the SQL backends' contract (#686: declared, like they do). public bool SupportsReverseRaw => true; public int ReadCount => _readCount; public void ResetReadCount() => _readCount = 0; public Task ReadRawAsync( string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken cancellationToken) { Interlocked.Increment(ref _readCount); // Reverse (Part 11, start > end): (end, start] in DESCENDING time — the SQL backend's contract. // Ties keep series order reversed, so a reverse page of a cluster is deterministic. var window = startUtc > endUtc ? series .Where(s => (s.SourceTimestampUtc ?? DateTime.MinValue) > endUtc && (s.SourceTimestampUtc ?? DateTime.MinValue) <= startUtc) .Reverse() .ToList() : series .Where(s => (s.SourceTimestampUtc ?? DateTime.MinValue) >= startUtc && (s.SourceTimestampUtc ?? DateTime.MinValue) <= endUtc) .ToList(); // maxValuesPerNode == 0 means "no limit". var page = maxValuesPerNode == 0 ? window : window.Take((int)maxValuesPerNode).ToList(); return Task.FromResult(new HistorianRead(page, null)); } public Task ReadProcessedAsync( string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, HistoryAggregateType aggregate, CancellationToken cancellationToken) => Task.FromResult(new HistorianRead(Array.Empty(), null)); public Task ReadAtTimeAsync( string fullReference, IReadOnlyList timestampsUtc, CancellationToken cancellationToken) => Task.FromResult(new HistorianRead(Array.Empty(), null)); public Task ReadEventsAsync( string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken) => Task.FromResult(new HistoricalEventsResult(Array.Empty(), null)); public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot(); public void Dispose() { } } /// /// #686: a Gateway-shaped backend that reads FORWARD only. is false; a reverse window /// (start > end) is counted and rejected as the gateway does (InvalidArgument). A forward read is /// inclusive [start, end] and, like an AVEVA Full read, first emits a synthetic boundary row stamped at /// the query start carrying the value in effect there (Bad 0 when there is none) — counted against the cap. /// private sealed class ForwardOnlyHistorianDataSource( IReadOnlyList series, bool emitBoundaryRow = true, bool boundaryAtCeilingMs = false) : IHistorianDataSource { private readonly Lock _gate = new(); private int _readCount; private int _reverseWindowCount; private int _maxRowsReturned; public int ReadCount => _readCount; public int ReverseWindowCount => _reverseWindowCount; public int MaxRowsReturned => _maxRowsReturned; /// The earliest query start and latest query end this backend was sent. public DateTime MinRequestedStart { get; private set; } = DateTime.MaxValue; public DateTime MaxRequestedEnd { get; private set; } = DateTime.MinValue; /// 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; RowsReturned = 0; MaxCapRequested = 0; } Interlocked.Exchange(ref _readCount, 0); } public bool SupportsReverseRaw => false; public Task ReadRawAsync( string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken cancellationToken) { Interlocked.Increment(ref _readCount); if (startUtc > endUtc) { Interlocked.Increment(ref _reverseWindowCount); throw new InvalidOperationException("start_time must be less than or equal to end_time. (InvalidArgument)"); } lock (_gate) { if (startUtc < MinRequestedStart) MinRequestedStart = startUtc; if (endUtc > MaxRequestedEnd) MaxRequestedEnd = endUtc; } static DateTime Ts(DataValueSnapshot s) => s.SourceTimestampUtc ?? DateTime.MinValue; var prior = series.LastOrDefault(s => Ts(s) <= startUtc); // AVEVA may stamp its fabricated row at the query start rounded UP to its millisecond resolution. var stamp = boundaryAtCeilingMs && startUtc.Ticks % TimeSpan.TicksPerMillisecond != 0 ? new DateTime(startUtc.Ticks - startUtc.Ticks % TimeSpan.TicksPerMillisecond + TimeSpan.TicksPerMillisecond, DateTimeKind.Utc) : startUtc; var rows = new List(); if (emitBoundaryRow) rows.Add(prior is null ? new DataValueSnapshot(0d, StatusCodes.BadNoData, stamp, stamp) : 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(); 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) { } return Task.FromResult(new HistorianRead(page, null)); } public Task ReadProcessedAsync( string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, HistoryAggregateType aggregate, CancellationToken cancellationToken) => Task.FromResult(new HistorianRead(Array.Empty(), null)); public Task ReadAtTimeAsync( string fullReference, IReadOnlyList timestampsUtc, CancellationToken cancellationToken) => Task.FromResult(new HistorianRead(Array.Empty(), null)); public Task ReadEventsAsync( string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken) => Task.FromResult(new HistoricalEventsResult(Array.Empty(), null)); public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot(); public void Dispose() { } } /// /// A microsecond-resolution backend, shaped like QuestDB: every inbound window bound is TRUNCATED to 10 ticks /// (as QuestDbConnection.Round does) before the series is filtered, and the source reports /// = 10. /// private sealed class MicrosecondHistorianDataSource(IReadOnlyList series) : IHistorianDataSource { private readonly SeriesHistorianDataSource _inner = new(series); public long TimestampResolutionTicks => 10; public bool SupportsReverseRaw => true; // QuestDB serves reverse natively private static DateTime Truncate(DateTime t) => new(t.Ticks - t.Ticks % 10, DateTimeKind.Utc); public Task ReadRawAsync( string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken cancellationToken) => _inner.ReadRawAsync(fullReference, Truncate(startUtc), Truncate(endUtc), maxValuesPerNode, cancellationToken); public Task ReadProcessedAsync( string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, HistoryAggregateType aggregate, CancellationToken cancellationToken) => _inner.ReadProcessedAsync(fullReference, startUtc, endUtc, interval, aggregate, cancellationToken); public Task ReadAtTimeAsync( string fullReference, IReadOnlyList timestampsUtc, CancellationToken cancellationToken) => _inner.ReadAtTimeAsync(fullReference, timestampsUtc, cancellationToken); public Task ReadEventsAsync( string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, CancellationToken cancellationToken) => _inner.ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, cancellationToken); public HistorianHealthSnapshot GetHealthSnapshot() => _inner.GetHealthSnapshot(); public void Dispose() { } } private sealed class FixedClock(DateTime now) : TimeProvider { public override DateTimeOffset GetUtcNow() => new(now); } private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() { var applicationUri = $"urn:OtOpcUa.HistoryPagingTest:{Guid.NewGuid():N}"; var started = await OpcUaHostStart.StartAsync( port => new OpcUaApplicationHost( new OpcUaApplicationHostOptions { ApplicationName = "OtOpcUa.HistoryPagingTest", ApplicationUri = applicationUri, OpcUaPort = port, PublicHostname = "localhost", PkiStoreRoot = _pkiRoot, }, NullLogger.Instance), () => new OtOpcUaSdkServer(), Ct); var host = started.Host; var server = started.Server; return (host, server); } public void Dispose() { if (Directory.Exists(_pkiRoot)) { try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort cleanup */ } } } }