From 7e8941fbdccdd23b66d382ba71f8d493ea6c276a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 10:15:21 -0400 Subject: [PATCH] test(historian): paged-vs-unpaged raw-cap fuzz, MaxReturnDataValues advertisement, cap floor of 3 - NodeManagerHistoryReadRawCapFuzzTests: 10k seeds (env OTOPCUA_PAGING_FUZZ_ITERATIONS) through a real SDK node manager against wire-faithful fakes of SqlServer / QuestDB / Gateway that enforce the backend half of the cap; random series with ties, random windows (empty, on/past a tie, between samples, forward/reverse/end-only, bounds), N in {0,1,2,small,huge}, cap in {off,3..1000}, tie budget {3,8,65536}. Pages must concatenate to the cap-off one-shot answer (checked against the series), terminate within values + 2 (+ one empty step per oversized boundary cluster), never exceed the cap, never ask the backend for 0 / over-cap; refusals only past the budget. - HistoryPaging.AdvertiseMaxReturnDataValues writes HistoryServerCapabilities/ MaxReturnDataValues (i=11273), read back through the SDK master node manager. - MinMaxRawValuesPerRead raised to 3: at 2 a resumed page has no room (value + its boundary tie + lookahead) and every value cost an empty page + CP (seed 15). - RawPageLimit rows pin the default-cap arithmetic (9 999 first page, 9 998 resumed). Fuzz findings recorded as follow-ups: a bounded read whose data fits one page returns cap + 1 (end bound fitted against N, not the cap; node manager, F2); the Gateway walk-back's cost on a sparse series under a tiny tie budget is pre-existing (seed 320: 82 reads uncapped, 93 capped). Refs #736 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../HistoryPaging.cs | 49 +- .../RawPageLimit.cs | 17 +- .../Historian/ServerHistorianOptions.cs | 16 +- ...oryServerCapabilitiesAdvertisementTests.cs | 58 +++ .../NodeManagerHistoryReadRawCapFuzzTests.cs | 436 ++++++++++++++++++ .../RawPageLimitTests.cs | 9 + .../Historian/ServerHistorianOptionsTests.cs | 14 +- 7 files changed, 576 insertions(+), 23 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/HistoryServerCapabilitiesAdvertisementTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadRawCapFuzzTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs index 36172326..3dc4ab79 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs @@ -1,3 +1,5 @@ +using Opc.Ua; +using Opc.Ua.Server; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; @@ -110,16 +112,51 @@ internal enum HistoryCursorKind /// NumValuesPerNode samples with a null continuation point — so paging is synthesised here, /// time-based: /// -/// A page that returns EXACTLY the requested cap (NumValuesPerNode > 0) MAY have -/// more behind it ⇒ emit a continuation point. -/// A short page (fewer than the cap) is the last page ⇒ no continuation point. -/// NumValuesPerNode == 0 means "all values, no limit" (OPC UA Part 11) ⇒ never page; -/// return everything in one shot. +/// A page whose read returned the lookahead row past it MAY have more behind it ⇒ emit a continuation +/// point ( judges a read against what was asked for). +/// A short read (no lookahead row) is the last page ⇒ no continuation point. +/// NumValuesPerNode == 0 means "all values" (OPC UA Part 11) — but not "in one shot": the page is +/// sized to the backend's by +/// (the server raw cap ServerHistorian:MaxRawValuesPerRead, default 10 000, and/or a strict gateway's +/// learned ceiling; review R08:1.1, #736), and a full page carries a continuation point like any other. Only +/// with no limit at all (0, the opt-out) is everything returned in one shot. /// -/// All methods are static + pure so they unit-test without a server, a session, or the SDK. +/// All paging methods are static + pure so they unit-test without a server, a session, or the SDK; +/// is the one SDK-facing member. /// internal static class HistoryPaging { + /// + /// Advertise the server raw cap as HistoryServerCapabilities/MaxReturnDataValues (i=11273; Part 11 §5.4.2: + /// "the maximum number of values that can be returned by the Server for each Node", 0 = no limit). The cap is + /// the most values one HistoryRead-Raw page returns (ServerHistorian:MaxRawValuesPerRead), so a client can + /// size its NumValuesPerNode and expect a continuation point past it. Idempotent; a re-sync with a + /// different cap overwrites. Never throws: a missing diagnostics manager or node leaves the SDK default (0) and + /// returns false — HistoryRead itself still enforces the cap. + /// + /// The running server. + /// The cap (<= 0 advertises 0 = no limit). + /// true when the property was written. + public static bool AdvertiseMaxReturnDataValues(IServerInternal? server, int maxRawValuesPerRead) + { + if (server?.DiagnosticsNodeManager is not DiagnosticsNodeManager diagnostics) return false; + try + { + lock (diagnostics.Lock) + { + var capabilities = diagnostics.GetDefaultHistoryCapabilities(); + if (capabilities?.MaxReturnDataValues is not { } property) return false; + property.Value = (uint)Math.Max(0, maxRawValuesPerRead); + property.ClearChangeMasks(diagnostics.SystemContext, false); + return true; + } + } + catch (ServiceResultException) + { + return false; + } + } + /// /// Decide whether a just-returned page is "full" and therefore MAY be followed by more data — /// the signal to emit a continuation point. A page is full when the client asked for a finite diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/RawPageLimit.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/RawPageLimit.cs index 3adb40db..e4af5d11 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/RawPageLimit.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/RawPageLimit.cs @@ -11,17 +11,22 @@ internal readonly record struct RawPagePlan(int Limit, uint PageCap, uint Reques /// /// sizes a HistoryRead-Raw page to what the backend can return in one read -/// ( = L — a strict HistorianGateway's learned row ceiling, less -/// its boundary-row allowance). With a limit the read — page P + boundary ties k + the lookahead row — is at most L, -/// so P = min(want, L − k − 1) (all values: P = L − k − 1); a full page carries a continuation point like any other, as -/// Part 11 allows fewer values than NumValuesPerNode with one. Previously a cap above a strict gateway's ceiling -/// (or an uncapped read of a window holding more) failed BadTooManyOperations. +/// ( = L — the server raw cap +/// ServerHistorian:MaxRawValuesPerRead, default 10 000, which every backend reports (review R08:1.1, #736), +/// and/or a strict HistorianGateway's learned row ceiling less its boundary-row allowance, whichever is lower). With +/// a limit the read — page P + boundary ties k + the lookahead row — is at most L, so P = min(want, L − k − 1) (all +/// values: P = L − k − 1, i.e. a first page of 9 999 at the default cap); a full page carries a continuation point like +/// any other, as Part 11 allows fewer values than NumValuesPerNode with one. Previously a cap above a strict +/// gateway's ceiling (or an uncapped read of a window holding more) failed BadTooManyOperations, and before +/// #736 an uncapped read on a backend without a ceiling read the whole window in one shot. /// /// /// /// Never a silent truncation. The backend still answers a read in full or refuses it; only the server's page is /// smaller, and a page is judged full against what was asked for. A read the backend cannot serve at all (a tie -/// cluster larger than one read) stays BadTooManyOperations. +/// cluster larger than one read) stays BadTooManyOperations. The backends enforce the same cap themselves +/// (a read asking for more than L, or for everything, is refused when the window holds more than L rows), so this +/// planner is what keeps the server's own reads inside it: with L > 0 no read it plans ever asks for 0 or more than L. /// /// /// One re-read, planned again. A refused read () is re-planned and 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 1835c5ef..ba2fe9c7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs @@ -158,10 +158,12 @@ public sealed class ServerHistorianOptions public const int DefaultMaxRawValuesPerRead = 10_000; /// - /// The smallest usable : a read must carry a page of at least one value AND the - /// lookahead row that tells the server more data follows, so a cap of 1 is raised to 2 (). + /// The smallest usable : a resumed page's read must carry at least one value, the + /// boundary tie it re-reads and trims, AND the lookahead row that tells the server more data follows. At 2 a resumed + /// page has no room at all and every value costs an empty page with a continuation point (the #692 defect, re-created + /// by configuration), so 1 and 2 are raised to 3 (). /// - public const int MinMaxRawValuesPerRead = 2; + public const int MinMaxRawValuesPerRead = 3; /// /// Every backend: the most raw values the server returns on ONE HistoryRead-Raw page, and the most rows one @@ -175,8 +177,10 @@ public sealed class ServerHistorianOptions /// and is REFUSED (BadTooManyOperations) when the window holds more — never answered truncated — so a caller that /// bypasses the node manager's paging cannot bypass the bound. On the Gateway backend a strict gateway's learned row /// ceiling (less its boundary-row allowance) still applies when lower. The tie-cluster bound of a resumed page is also - /// at most cap − 1 (as under a gateway ceiling; above that is inert). Default - /// 10 000. Non-positive = unbounded, the pre-#736 behaviour (a warning); 1 is raised to 2. + /// at most cap − 1 (as under a gateway ceiling; above that is inert), and a + /// page whose boundary tie cluster is that large steps past it with one empty page. Default 10 000. Non-positive = + /// unbounded, the pre-#736 behaviour (a warning); below + /// (3) is raised to it. /// public int MaxRawValuesPerRead { get; init; } = DefaultMaxRawValuesPerRead; @@ -243,7 +247,7 @@ public sealed class ServerHistorianOptions if (MaxRawValuesPerRead <= 0) warnings.Add($"ServerHistorian:MaxRawValuesPerRead is {MaxRawValuesPerRead} — must be > 0; HistoryRead-Raw pages and backend raw reads are unbounded, so a client asking for a whole window (NumValuesPerNode = 0) materialises every sample in memory."); else if (MaxRawValuesPerRead < MinMaxRawValuesPerRead) - warnings.Add($"ServerHistorian:MaxRawValuesPerRead is {MaxRawValuesPerRead} — must be at least {MinMaxRawValuesPerRead} (a page plus its lookahead row); treated as {MinMaxRawValuesPerRead}."); + warnings.Add($"ServerHistorian:MaxRawValuesPerRead is {MaxRawValuesPerRead} — must be at least {MinMaxRawValuesPerRead} (a value, its boundary tie and the lookahead row); treated as {MinMaxRawValuesPerRead}."); if (EventReadHorizon < TimeSpan.Zero) warnings.Add($"ServerHistorian:EventReadHorizon is {EventReadHorizon} — negative is treated as no horizon (00:00:00); HistoryReadEvents reads every window in full."); if (HistoryReadDeadline <= TimeSpan.Zero) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/HistoryServerCapabilitiesAdvertisementTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/HistoryServerCapabilitiesAdvertisementTests.cs new file mode 100644 index 00000000..0aaca5b1 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/HistoryServerCapabilitiesAdvertisementTests.cs @@ -0,0 +1,58 @@ +using Opc.Ua; +using Opc.Ua.Server; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// R08:1.1 (#736): the server raw cap (ServerHistorian:MaxRawValuesPerRead) is advertised as +/// HistoryServerCapabilities/MaxReturnDataValues (i=11273) through , +/// read back the way a client would — through the SDK master node manager — over a real server. +/// +public sealed class HistoryServerCapabilitiesAdvertisementTests(BoundsServerFixture fx) : IClassFixture +{ + private async Task ReadAdvertisedAsync() + { + var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.Read, identity: null); + var nodesToRead = new ReadValueIdCollection + { + new ReadValueId { NodeId = VariableIds.HistoryServerCapabilities_MaxReturnDataValues, AttributeId = Attributes.Value }, + }; + var (values, _) = await fx.Nm.Server.NodeManager.ReadAsync(context, 0, TimestampsToReturn.Neither, nodesToRead, TestContext.Current.CancellationToken); + values.Count.ShouldBe(1); + StatusCode.IsGood(values[0].StatusCode).ShouldBeTrue(values[0].StatusCode.ToString()); + return (uint)values[0].Value; + } + + [Fact] + public async Task The_cap_is_advertised_as_MaxReturnDataValues_and_a_re_sync_overwrites_it() + { + await fx.Gate.WaitAsync(TestContext.Current.CancellationToken); + try + { + HistoryPaging.AdvertiseMaxReturnDataValues(fx.Nm.Server, 10_000).ShouldBeTrue(); + (await ReadAdvertisedAsync()).ShouldBe(10_000u); + + HistoryPaging.AdvertiseMaxReturnDataValues(fx.Nm.Server, 2_500).ShouldBeTrue(); + (await ReadAdvertisedAsync()).ShouldBe(2_500u); + + // Unbounded (the opt-out) advertises Part 11's "no limit". + HistoryPaging.AdvertiseMaxReturnDataValues(fx.Nm.Server, 0).ShouldBeTrue(); + (await ReadAdvertisedAsync()).ShouldBe(0u); + HistoryPaging.AdvertiseMaxReturnDataValues(fx.Nm.Server, -5).ShouldBeTrue(); + (await ReadAdvertisedAsync()).ShouldBe(0u); + } + finally + { + HistoryPaging.AdvertiseMaxReturnDataValues(fx.Nm.Server, 0); + fx.Gate.Release(); + } + } + + [Fact] + public void No_server_is_a_quiet_false() + { + HistoryPaging.AdvertiseMaxReturnDataValues(null, 10_000).ShouldBeFalse(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadRawCapFuzzTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadRawCapFuzzTests.cs new file mode 100644 index 00000000..eb64ef64 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadRawCapFuzzTests.cs @@ -0,0 +1,436 @@ +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; + +/// +/// R08:1.1 (#736): the paged-vs-unpaged fuzz for the server raw cap. Every paging change in this repo has had boundary +/// bugs that the hand-written cases missed (silent truncation, an endless cursor, an empty page between every value), so +/// the cap is proved the only way that has worked: random series (bursts, gaps, sub-millisecond stamps, tie clusters), +/// random windows (empty, on a tie, one step past a tie, between samples, forward / reverse / end-only, with and without +/// bounds) and random caps — NumValuesPerNode 0 / 1 / 2 / small / huge, the server cap 2 … 1000 or off, the +/// tie-cluster budget 3 / 8 / 65536 — paged through a REAL SDK node manager against a wire-faithful fake of each backend +/// (SqlServer: tick precision, native reverse, TOP; QuestDB: µs truncation of bounds AND stamps, native reverse, +/// LIMIT; Gateway: forward only, [start, end) after its own boundary-row filtering, walked back by the +/// server) that enforces the backend half of the cap exactly as the real sources do (a read asking for 0 or more than +/// the cap reads cap + 1 rows and is refused when the window holds more). +/// +/// The oracle is the same node manager's one-shot answer with the cap OFF (NumValuesPerNode = 0, no limit), +/// which for a plain read is also checked against the series itself. The concatenation of the pages must equal it +/// exactly; the read must terminate within the number of values plus two pages; no page may exceed the cap; the fake +/// must never see a request for 0 or more than the cap (the "bounded by the same cap" invariant); and a +/// BadTooManyOperations is accepted only when a tie cluster (or, on the Gateway shape, one millisecond) is +/// larger than the budget that the cap and MaxTieClusterOverfetch leave — after an exact prefix. The run is +/// non-vacuous by construction: it asserts that empty windows, mid-tie windows, reverse reads, caps of 1 and 2 and +/// every shape occurred, and that most iterations were exact. +/// +/// +/// Iterations: OTOPCUA_PAGING_FUZZ_ITERATIONS (default 10 000, the lane gate). Seeds are stable, so a failure +/// names a reproducible seed. +/// +/// +public sealed class NodeManagerHistoryReadRawCapFuzzTests(BoundsServerFixture fx) : IClassFixture +{ + private static readonly DateTime T0 = new(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc); + + /// The backend the fake is shaped like. + public enum Shape { SqlServer, QuestDb, Gateway } + + private enum Direction { Forward, Reverse, EndOnly } + + private static int Iterations => + int.TryParse(Environment.GetEnvironmentVariable("OTOPCUA_PAGING_FUZZ_ITERATIONS"), out var n) && n > 0 ? n : 10_000; + + [Fact] + public async Task Paged_equals_unpaged_on_random_series_windows_and_caps_and_always_terminates() + { + var iterations = Iterations; + var counts = new Dictionary(); + void Count(string key) => counts[key] = counts.GetValueOrDefault(key) + 1; + + await fx.Gate.WaitAsync(TestContext.Current.CancellationToken); + try + { + for (var seed = 0; seed < iterations; seed++) + { + var rng = new Random(seed); + var shape = (Shape)(seed % 3); + var resolution = shape == Shape.QuestDb ? 10L : 1L; + var series = RandomSeries(rng, resolution); + var cap = rng.Next(10) switch { 0 => 0, 1 or 2 => 3, 3 => 4, 4 => 5, 5 => 7, 6 => 10, 7 => 16, 8 => 33, _ => 1000 }; + var n = rng.Next(9) switch { 0 or 1 => 0u, 2 or 3 => 1u, 4 or 5 => 2u, 6 => (uint)rng.Next(3, 20), 7 => (uint)rng.Next(20, 200), _ => 100_000u }; + var tieBudget = rng.Next(4) switch { 0 => 3, 1 => 8, _ => 65536 }; + var direction = rng.Next(5) switch { 0 => Direction.Reverse, 1 => Direction.EndOnly, _ => Direction.Forward }; + if (direction == Direction.EndOnly && n == 0) direction = Direction.Forward; // Part 11: one bound only with a cap + var bounds = direction != Direction.EndOnly && rng.Next(4) == 0; + + var a = PickBoundary(rng, series, resolution); + var b = PickBoundary(rng, series, resolution); + var (lo, hi) = a <= b ? (a, b) : (b, a); + var (start, end) = direction switch + { + Direction.Forward => (lo, hi), + Direction.Reverse => (hi, lo), + _ => (DateTime.MinValue, hi), + }; + var tag = $"seed {seed} {shape} cap {cap} N {n} tie {tieBudget} {direction} bounds {bounds} [{start:O} .. {end:O}]"; + + // The walk-back (Gateway shape only: reverse / end-only reads, and the bound lookups of any bounded read) refuses + // a millisecond holding more samples than its budget — computed up front, over the whole series, because the + // walk and the lookups read around the window. + var walks = shape == Shape.Gateway && (direction != Direction.Forward || bounds); + var msMax = walks ? MillisecondMax(series) : 0; + + // The oracle: the one-shot answer with the cap off (and, for a plain read, the series itself). + var truthSource = new CappedFake(series, shape, cap: 0); + fx.Nm.HistoricalSourceForTest(truthSource, tieBudget); + // An end-only read is paged backward from EndTime (the node manager flips it to a reverse read down to the + // beginning of time); its oracle is the explicit reverse read down to the Unix epoch — the walk-back's floor, + // and Part 11 (the SDK checks it) needs both bounds set when NumValuesPerNode = 0. + var (truth, truthError, truthCp) = direction == Direction.EndOnly + ? Read(hi, DateTime.UnixEpoch, 0, bounds: false, cp: null) + : Read(start, end, 0, bounds, cp: null); + if (truthError.StatusCode.Code == StatusCodes.BadTooManyOperations) + { + // Only the walk-back can refuse an uncapped one-shot read, and only past its budget (max(1, M) with no cap). + (walks && msMax > Math.Max(1, tieBudget)).ShouldBeTrue($"the oracle read was refused: {tag} (ms max {msMax})"); + Count("oracle refused"); + continue; + } + truthError.StatusCode.Code.ShouldBe(StatusCodes.Good, $"oracle: {tag}"); + truthCp.ShouldBeNull($"oracle (no cap, N = 0) is one page: {tag}"); + if (!bounds) + Render(truth).ShouldBe(Render(Oracle(series, start, end, direction, resolution)), $"oracle vs series: {tag}"); + truthSource.OverCapRequests.ShouldBe(0, $"the uncapped oracle source must never be asked for more than it allows: {tag}"); + + var windowTies = LargestTieCluster(truth); + if (truth.Count == 0) Count("empty"); + if (windowTies >= 2) Count("ties in window"); + if (EndsOnTie(series, start, end, direction, resolution)) Count("window ends on a tie"); + if (direction == Direction.Reverse) Count("reverse"); + if (direction == Direction.EndOnly) Count("end-only"); + if (n == 1) Count("N=1"); + if (n == 2) Count("N=2"); + if (n == 0) Count("N=0"); + if (cap == 3) Count("cap=3"); + if (bounds) Count("bounds"); + Count(shape.ToString()); + + // A boundary tie cluster as large as the cap leaves a resumed page no room: the server steps past it with one + // empty page carrying a continuation point (the documented #694 ceiling behaviour) — the one empty page allowed. + var oversizedClusters = cap > 0 + ? truth.Where(v => !(v.Value is null && v.StatusCode.Code == StatusCodes.BadBoundNotFound)) + .GroupBy(v => v.SourceTimestamp.Ticks).Count(g => g.Count() >= Math.Max(2, cap - 1)) + : 0; + + // The read under test. + var source = new CappedFake(series, shape, cap); + fx.Nm.HistoricalSourceForTest(source, tieBudget); + var pages = new List>(); + var collected = new List(); + byte[]? cp = null; + StatusCode status; + do + { + var (values, error, next) = Read(start, end, n, bounds, cp); + status = error.StatusCode; + if (!StatusCode.IsGood(status)) break; + pages.Add(values); + collected.AddRange(values); + cp = next; + pages.Count.ShouldBeLessThanOrEqualTo(truth.Count + 2 + oversizedClusters, $"paging must terminate: {tag} pages {Sizes(pages)}"); + } + while (cp is not null); + + source.OverCapRequests.ShouldBe(0, $"the server asked the backend for 0 or more than the cap: {tag}"); + source.BadRequests.ShouldBe(0, $"a reverse window reached a forward-only backend: {tag}"); + + if (status.Code == StatusCodes.BadTooManyOperations) + { + // Legitimate only past the budget the cap leaves: a tie cluster larger than min(M, cap − 1), or on the + // Gateway shape a millisecond holding more than min(M, cap − 2) samples (the walk-back's budget). + var overfetchBound = cap > 0 ? Math.Min(Math.Max(1, tieBudget), Math.Max(1, cap - 1)) : Math.Max(1, tieBudget); + var walkBudget = cap > 0 ? Math.Max(1, Math.Min(tieBudget, cap - 2)) : Math.Max(1, tieBudget); + var legit = windowTies > overfetchBound || (walks && msMax > walkBudget); + legit.ShouldBeTrue($"refused with no tie cluster over {overfetchBound} (max {windowTies}; ms max {msMax} vs {walkBudget}): {tag}"); + Render(collected).ShouldBe(Render(truth.Take(collected.Count)), $"the prefix before the refusal: {tag}"); + Count("refused"); + continue; + } + + status.Code.ShouldBe(StatusCodes.Good, $"{tag}"); + Render(collected).ShouldBe(Render(truth), $"paged ≠ unpaged: {tag} pages {Sizes(pages)}"); + if (cap > 0) + { + // A bounded read whose data fits one page carries its start bound, the data and its end bound: the node + // manager fits the end bound against the client's N but not against the server cap (follow-up F2), so that + // one page may hold cap + 1 values; every other page is at most the cap. + var bothBoundsOnOnePage = bounds && pages.Count == 1; + pages.ShouldAllBe(p => p.Count <= cap + (bothBoundsOnOnePage ? 1 : 0), $"a page over the cap: {tag} pages {Sizes(pages)}"); + if (bothBoundsOnOnePage && pages[0].Count == cap + 1) Count("bounded single page at cap+1 (F2)"); + } + if (n > 0) pages.ShouldAllBe(p => p.Count <= n, $"a page over N: {tag} pages {Sizes(pages)}"); + for (var i = 0; i < pages.Count - 1; i++) + { + if (pages[i].Count > 0) continue; + // Allowed only as the step past a boundary tie cluster the cap cannot page within. + var previousLast = i > 0 && pages[i - 1].Count > 0 ? pages[i - 1][^1].SourceTimestamp.Ticks : (long?)null; + var clusterAtBoundary = previousLast is { } t ? truth.Count(v => v.SourceTimestamp.Ticks == t) : 0; + (cap > 0 && clusterAtBoundary >= Math.Max(2, cap - 1)).ShouldBeTrue( + $"an empty page with a continuation point (page {i}, boundary cluster {clusterAtBoundary}): {tag} pages {Sizes(pages)}"); + Count("empty step over an oversized tie cluster"); + } + if (pages.Count > 1) Count("paged"); + if (pages.Count > 1 && n == 0) Count("paged at N=0"); + // A cost guard against a read explosion, not a budget. A native shape reads once per page plus the bound + // lookups. The Gateway shape's walk-back has its own cost profile — ~30 reads a page on a dense tag + // (#691/#693), far more on a sparse series under a tiny tie budget (seed 320: 82 reads with NO cap, seed 6428: + // 120+) — so it is judged RELATIVE to the same request with the cap off (the pre-#736 behaviour), per page: + // the cap must not make the walk explode compared to before. The baseline asks for the page size the cap + // imposes (N = cap − 1): an uncapped N = 0 reverse read used to be ONE int.MaxValue forward read — the very + // memory vector the cap removes — so "what N = 0 cost before" is not the comparison. + var allowedReads = 8 * pages.Count + 8; + if (shape == Shape.Gateway) + { + var baseline = new CappedFake(series, shape, cap: 0); + fx.Nm.HistoricalSourceForTest(baseline, tieBudget); + var baseN = cap > 0 && (n == 0 || n > (uint)cap) ? (uint)(cap - 1) : n; + var basePages = 0; + byte[]? baseCp = null; + do + { + var (_, baseError, next) = Read(start, end, baseN, bounds, baseCp); + if (!StatusCode.IsGood(baseError.StatusCode)) break; + basePages++; + baseCp = next; + } + while (baseCp is not null && basePages <= truth.Count + 2 + oversizedClusters); + allowedReads = 2 * baseline.Reads * Math.Max(1, pages.Count) / Math.Max(1, basePages) + 32 * pages.Count + 16; + } + source.Reads.ShouldBeLessThanOrEqualTo(allowedReads, + $"backend reads exploded: {source.Reads} for {pages.Count} pages (allowed {allowedReads}): {tag}"); + Count("exact"); + } + } + finally + { + fx.Nm.HistoricalSourceForTest(NullHistorianDataSource.Instance, 65536); + fx.Gate.Release(); + } + + // Non-vacuous: every category the brief names occurred, and most runs were exact. + var summary = string.Join(", ", counts.OrderBy(kv => kv.Key).Select(kv => $"{kv.Key}={kv.Value}")); + foreach (var required in new[] { "empty", "ties in window", "window ends on a tie", "reverse", "end-only", "N=1", "N=2", "N=0", "cap=3", "bounds", "paged", "paged at N=0", "SqlServer", "QuestDb", "Gateway" }) + counts.GetValueOrDefault(required).ShouldBeGreaterThan(iterations / 200, $"{required} under-represented: {summary}"); + counts.GetValueOrDefault("exact").ShouldBeGreaterThan(iterations * 6 / 10, summary); + TestContext.Current.TestOutputHelper?.WriteLine($"raw-cap fuzz: {iterations} iterations — {summary}"); + } + + // --- generators ----------------------------------------------------------------------------- + + private static DateTime Ts(DataValueSnapshot s) => s.SourceTimestampUtc ?? DateTime.MinValue; + + private static DateTime Trunc(DateTime t, long resolution) => new(t.Ticks - t.Ticks % resolution, DateTimeKind.Utc); + + /// Random ascending series: bursts, gaps, sub-millisecond stamps and tie clusters; stamps stored at the resolution. + private static List RandomSeries(Random rng, long resolution) + { + var list = new List(); + var t = T0.Ticks - rng.Next(0, 3) * TimeSpan.TicksPerHour; + var count = rng.Next(1, 120); + var v = 0; + while (list.Count < count) + { + t += rng.Next(6) switch + { + 0 => rng.Next(1, 10), // sub-millisecond (sub-µs on QuestDB: collapses onto a tie) + 1 => rng.Next(1, 5) * TimeSpan.TicksPerMillisecond, // dense + 2 => rng.Next(1, 3) * TimeSpan.TicksPerSecond, + 3 => rng.Next(1, 30) * TimeSpan.TicksPerMinute, // a gap + _ => rng.Next(1, 1000) * TimeSpan.TicksPerMillisecond, + }; + var ties = rng.Next(7) == 0 ? rng.Next(2, 9) : 1; + for (var k = 0; k < ties; k++) + { + var ts = Trunc(new DateTime(t, DateTimeKind.Utc), resolution); + var status = rng.Next(12) == 0 ? StatusCodes.Bad : StatusCodes.Good; + list.Add(new DataValueSnapshot(status == StatusCodes.Bad ? null : (double)v, status, ts, ts)); + v++; + } + } + + return list; + } + + /// A window bound: before / after the data, exactly on a sample (a tie, maybe), one step past one, a tick before one, or between two. + private static DateTime PickBoundary(Random rng, List series, long resolution) + { + var first = Ts(series[0]); + var last = Ts(series[^1]); + var i = rng.Next(series.Count); + var t = Ts(series[i]); + switch (rng.Next(9)) + { + case 0: return first.AddSeconds(-rng.Next(1, 100)); + case 1: return last.AddSeconds(rng.Next(1, 100)); + case 2: + case 3: return t; + case 4: return t.AddTicks(resolution); + case 5: return t.AddTicks(-1); // sub-resolution: a microsecond backend truncates it back ONTO the sample + case 6: return t.AddMilliseconds(1); + default: + var next = i + 1 < series.Count ? Ts(series[i + 1]) : t.AddSeconds(10); + return new DateTime(t.Ticks + (long)((next.Ticks - t.Ticks) * rng.NextDouble()), DateTimeKind.Utc); + } + } + + /// The series' own answer for a plain (no bounds) read: forward [start, end), point, reverse / end-only (end, start] newest first. + private static List Oracle(List series, DateTime start, DateTime end, Direction direction, long resolution) + { + var s = Trunc(start, resolution); + var e = Trunc(end, resolution); + IEnumerable window = direction switch + { + // start == end is Part 11's point read whichever way it was generated: the node manager reads it forward. + Direction.Forward or Direction.Reverse when s == e => series.Where(x => Ts(x) == s), + Direction.Forward => series.Where(x => Ts(x) >= s && Ts(x) < e), + Direction.Reverse => series.Where(x => Ts(x) > e && Ts(x) <= s).Reverse(), + _ => series.Where(x => Ts(x) <= e).Reverse(), + }; + return window.Select(x => new DataValue(new Variant(x.Value), new StatusCode(x.StatusCode), Ts(x), Ts(x))).ToList(); + } + + /// The largest tie cluster among the returned stored values (a bound placeholder is not a stored value). + private static int LargestTieCluster(IReadOnlyList truth) => + truth.Where(v => !(v.Value is null && v.StatusCode.Code == StatusCodes.BadBoundNotFound)) + .GroupBy(v => v.SourceTimestamp.Ticks).Select(g => g.Count()).DefaultIfEmpty(0).Max(); + + /// The most samples within one millisecond anywhere in the series (the walk-back's refusal rule). + private static int MillisecondMax(List series) + { + var msMax = 0; + for (int i = 0, j = 0; i < series.Count; i++) + { + while (Ts(series[i]).Ticks - Ts(series[j]).Ticks >= TimeSpan.TicksPerMillisecond) j++; + msMax = Math.Max(msMax, i - j + 1); + } + + return msMax; + } + + private static bool EndsOnTie(List series, DateTime start, DateTime end, Direction direction, long resolution) + { + var edge = Trunc(direction == Direction.Forward ? end : direction == Direction.Reverse ? start : end, resolution); + return series.Count(x => Ts(x) == edge) >= 2; + } + + // --- helpers ---------------------------------------------------------------------------------- + + private static string Sizes(IEnumerable> pages) => "[" + string.Join(",", pages.Select(p => p.Count)) + "]"; + + private static string Render(IEnumerable values) => + string.Join(" | ", values.Select(v => $"{v.SourceTimestamp.Ticks}={v.Value ?? "null"}/{v.StatusCode.Code:X8}")); + + private (IReadOnlyList Values, ServiceResult Error, byte[]? Cp) Read(DateTime start, DateTime end, uint max, bool bounds, byte[]? cp) + { + var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null); + var details = new ReadRawModifiedDetails + { + StartTime = start, EndTime = end, NumValuesPerNode = max, ReturnBounds = bounds, IsReadModified = false, + }; + var nodesToRead = new List { new() { NodeId = fx.NodeId, ContinuationPoint = cp } }; + var results = new List { null! }; + var errors = new List { null! }; + fx.Nm.HistoryRead(context, details, TimestampsToReturn.Both, releaseContinuationPoints: false, nodesToRead, results, errors); + + var values = new List(); + if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho)) + values.AddRange(((HistoryData)ExtensionObject.ToEncodeable(ho)).DataValues); + return (values, errors[0], results[0]?.ContinuationPoint); + } + + /// + /// A series-backed historian shaped like one backend, enforcing the backend half of the server raw cap exactly as + /// the real data sources do: it reports the cap as ; a read asking for 0 or more than + /// the cap reads cap + 1 rows and throws when the window holds more + /// (never a truncated answer). It also records every such request () — the server must + /// never issue one — and every reverse window sent to the forward-only shape (). + /// + private sealed class CappedFake(IReadOnlyList series, Shape shape, int cap) : IHistorianDataSource + { + private readonly Lock _gate = new(); + + public int Reads { get; private set; } + public int OverCapRequests { get; private set; } + public int BadRequests { get; private set; } + + public bool SupportsReverseRaw => shape != Shape.Gateway; + + public long TimestampResolutionTicks => shape == Shape.QuestDb ? 10 : 1; + + public int MaxRawValuesPerRead => cap; + + private DateTime Trunc(DateTime t) => NodeManagerHistoryReadRawCapFuzzTests.Trunc(t, TimestampResolutionTicks); + + public Task ReadRawAsync( + string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken cancellationToken) + { + var probe = cap > 0 && (maxValuesPerNode == 0 || maxValuesPerNode > (uint)cap); + lock (_gate) + { + Reads++; + if (probe) OverCapRequests++; + if (shape == Shape.Gateway && startUtc > endUtc) BadRequests++; + } + + if (shape == Shape.Gateway && startUtc > endUtc) + throw new InvalidOperationException("start_time must be less than or equal to end_time. (InvalidArgument)"); + + startUtc = Trunc(startUtc); + endUtc = Trunc(endUtc); + List window; + if (startUtc > endUtc) window = series.Where(s => Ts(s) > endUtc && Ts(s) <= startUtc).Reverse().ToList(); + else if (startUtc == endUtc) window = series.Where(s => Ts(s) == startUtc).ToList(); + else window = series.Where(s => Ts(s) >= startUtc && Ts(s) < endUtc).ToList(); + + var top = probe ? cap + 1 : maxValuesPerNode == 0 ? int.MaxValue : (int)Math.Min(maxValuesPerNode, int.MaxValue); + var page = window.Take(top).ToList(); + if (probe && page.Count > cap) + throw new HistoryReadLimitExceededException(cap, $"more than MaxRawValuesPerRead ({cap}) samples"); + 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() { } + } +} + +/// Test-only wiring shared by the paging fuzz: swap the source and the tie budget, with a fresh continuation store. +internal static class NodeManagerFuzzExtensions +{ + public static void HistoricalSourceForTest(this OtOpcUaNodeManager nm, IHistorianDataSource source, int tieBudget) + { + nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore(); + nm.MaxTieClusterOverfetch = tieBudget; + nm.HistorianDataSource = source; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/RawPageLimitTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/RawPageLimitTests.cs index fdd3e597..7818258d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/RawPageLimitTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/RawPageLimitTests.cs @@ -24,6 +24,15 @@ public sealed class RawPageLimitTests [InlineData(5000u, 983, 984, 0u, 0u, true)] // the ties alone fill a read: never a 0 (uncapped) request [InlineData(5000u, 5000, 984, 0u, 0u, true)] // never an underflow [InlineData(5000u, 0, 1, 0u, 0u, true)] + // R08:1.1 (#736): the server raw cap (default 10 000) is the limit every backend reports — an uncapped read pages at it. + [InlineData(0u, 0, 10_000, 9_999u, 10_000u, false)] // NumValuesPerNode = 0: a first page of cap − 1 + the lookahead row + [InlineData(0u, 1, 10_000, 9_998u, 10_000u, false)] // a resumed page re-reads its one boundary tie + [InlineData(100_000u, 0, 10_000, 9_999u, 10_000u, false)] // a client cap above the server cap is bounded by it + [InlineData(10_000u, 0, 10_000, 9_999u, 10_000u, false)] // exactly the cap: still cap − 1 (the lookahead row needs room) + [InlineData(500u, 0, 10_000, 500u, 501u, false)] // under the cap: unchanged + [InlineData(0u, 0, 3, 2u, 3u, false)] // the smallest usable cap (MinMaxRawValuesPerRead = 3): a first page of two + [InlineData(0u, 1, 3, 1u, 3u, false)] // … and a resumed page still carries one value beside its boundary tie + [InlineData(0u, 1, 2, 0u, 0u, true)] // at 2 a resumed page has NO room — every value would cost an empty page (why 3 is the floor) public void Plan_fits_page_ties_and_lookahead_into_the_limit(uint want, int k, int limit, uint page, uint request, bool noRoom) { var plan = RawPageLimit.Plan(want, k, limit); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ServerHistorianOptionsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ServerHistorianOptionsTests.cs index 8ad76302..08d700b2 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ServerHistorianOptionsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ServerHistorianOptionsTests.cs @@ -208,18 +208,22 @@ public sealed class ServerHistorianOptionsTests Assert.Contains("unbounded", m); } - [Fact] - public void A_MaxRawValuesPerRead_of_one_is_raised_to_two_and_warns_when_enabled() + [Theory] + [InlineData(1)] + [InlineData(2)] + public void A_MaxRawValuesPerRead_below_three_is_raised_to_three_and_warns_when_enabled(int value) { - // One row per read cannot carry a page AND the lookahead row that says "more follows" — a fresh page would be refused. + // A resumed page's read must carry a value, the boundary tie it re-reads and the lookahead row that says "more + // follows": at 2 it has no room and every value costs an empty page with a continuation point (the #692 defect). var o = new ServerHistorianOptions { - Enabled = true, Endpoint = "https://h:5222", ApiKey = "histgw_x_y", MaxRawValuesPerRead = 1, + Enabled = true, Endpoint = "https://h:5222", ApiKey = "histgw_x_y", MaxRawValuesPerRead = value, }; - Assert.Equal(2, o.EffectiveMaxRawValuesPerRead); + Assert.Equal(3, o.EffectiveMaxRawValuesPerRead); Assert.Equal(ServerHistorianOptions.MinMaxRawValuesPerRead, o.EffectiveMaxRawValuesPerRead); var m = Assert.Single(o.Validate()); Assert.Contains("MaxRawValuesPerRead", m); + Assert.Equal(3, new ServerHistorianOptions { MaxRawValuesPerRead = 3 }.EffectiveMaxRawValuesPerRead); } [Fact]