diff --git a/docs/Historian.md b/docs/Historian.md index bee5c14b..b44c88d8 100644 --- a/docs/Historian.md +++ b/docs/Historian.md @@ -780,14 +780,23 @@ data. **probe** (18 rows) for the sample after the last. Measured on the fake: ten times one second apart on a 1 Hz tag cost **1 read** (all exact hits) or **3** (interpolated); ten times an hour apart on a 1 Hz tag **12** (≈ 9.5k rows, never the 32k between); one time 400 days after the only samples **5**; a tag with no history **4**. A window read that fills its budget resolves the times before - its last row; the next time either continues from there (if the rows in between should fit one read) or jumps to it with a read - that starts a density-sized margin earlier (so its `prev` is normally in the same read). -- **Walk-back** (the gateway reads forward only): segments stepping back from the time, the first sized from the window read's density - (else 1 minute), growing ×4 when empty; after two empty segments the whole remainder down to the epoch is asked once (a complete - answer ends the walk — sparse history and no history are found in a handful of reads). A segment that fills its budget is narrowed - towards the time to about a quarter of a read at the density it showed — never below a row it saw — so dense history is bisected, - not paged (a 1 kHz tag behind a 10-minute gap: 7 reads, < 10k rows). At most 64 reads; more than 1,024 stored samples within one - millisecond fail the node `BadTooManyOperations` (not counted as a backend failure). + its last row; the next time either continues from there (if the rows in between should fit one read) or **jumps** to it with a + small read (64 rows) that starts a density-sized margin earlier (so its `prev` is normally in the same read) — 1,000 times + spread over ten years of a 1 Hz tag cost ≈ 1,000 reads of ≤ 64 rows (≈ 66k rows; it used to be 1,024-row reads). +- **Walk-back** (the gateway reads forward only), in two phases. **Gallop:** segments stepping back from the time, the first sized + from the window read's density (else 1 minute), growing ×4 when empty; after two empty segments the whole remainder down to the + epoch is asked once — a complete answer ends the walk, a **full** one seeds the bisection with its last row. **Bisect** (once a + stored row is known below — a full segment's, or an earlier window read's last row): read `(mid, hi]`; empty ⇒ `hi = mid`; full ⇒ + the known row moves up to its last row; complete with rows ⇒ done. Right after a full read one segment is aimed at about a quarter + of a read at the density it showed (until an aimed segment comes back empty — a gap — then plain halving); below 2 ms the segment + is the whole stretch from the known row (never a 100 ns step). Measured (review harness, 5 years of history, time = now − 1 min): + a tag stopped 1 h / 1 d / 7 d / 30 d / 365 d ago costs 6–18 reads at 1/min, 21–25 at 1 Hz and 25–31 at 10 Hz; a 1 kHz tag behind + a 10-minute gap 6 reads (< 10k rows). (Before the review fix a full segment reset the width and a full whole-remainder read was + discarded, so a dense tag that stopped weeks ago gave up after 65 reads.) More than 1,024 stored samples within one millisecond + fail the node `BadTooManyOperations` (not counted as a backend failure). +- **Bounded.** One request takes at most **64 + 4 × distinct times** raw reads; past it the node fails `BadTooManyOperations` + ("needed more than N raw reads", naming a small strict-gateway ceiling when one bounded the reads). A strict gateway whose ceiling is + below 4 rows is refused up front with a message naming the ceiling; a small one (e.g. 10) is served by paging within the cap. - **Wire shape.** Every request bound is a whole millisecond and a row counts only **strictly inside** its request, so AVEVA's synthetic boundary row at the query start (#687) — and any row at the query end — is never taken for a stored sample. A Bad row is value-less (#690). Nothing is asked past now + 1 day (the gateway's AVEVA may refuse a far-future end; a time beyond it is stepped from the last @@ -800,7 +809,12 @@ data. **before** the shared evaluator runs, so its own type rules apply exactly as on the SQL backends: a **Boolean** node (value ≠ 0) is **stepped** — never 0.5; an **integer** node is interpolated, rounded half away from zero and returned as the node's own integer type (`Int32` ⇒ `int`); Float / Double interpolate as Double (as the SQL backends return them). With no - hint (an unknown node) every numeric tag interpolates as Double. + hint (an unknown node) every numeric tag interpolates as Double. A **DateTime** node is stepped. Under an integer hint a NaN, ±∞ or + out-of-Int64-range stored value is no integer: it is treated as unusable (as `BadNoData`), so a neighbour like that is never + interpolated or saturated into a Good value. +- **What differs between backends (by design, not changed by #693).** Only the **Gateway** converts an AtTime result to the node's + integer type (`Int32` ⇒ `int`): the SQL backends store and return integer nodes as `Int64` (and Boolean as Boolean). Gateway + **Raw** reads still return a Boolean node's samples as the stored Double 0/1 (the hint is passed only on AtTime). - ⚠️ **Shared-evaluator fix (all backends that use it):** `AtTimeEvaluator` returned every interpolated `Int64` boxed as a **Double** (a C# `cond ? (long)x : double` conditional widens the long); its "keeping the Int64 type" tests passed because Shouldly's `ShouldBe` compares numbers across types. SqlServer / QuestDB interpolated `Int64` AtTime values are now `Int64`, diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayAtTimeReader.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayAtTimeReader.cs index 5273dde4..f42ede4d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayAtTimeReader.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayAtTimeReader.cs @@ -30,12 +30,22 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway; /// apart on a 1 Hz tag cost 1 read (all exact hits) or 3 (interpolated); ten times an hour apart about one read each. /// /// -/// Walk-back (the gateway reads forward only): segments (lo, hi] stepping back from hi, each one -/// forward read; an empty segment grows the next ×4 (from the density the window read showed, else -/// ), and after two empty segments the whole remainder down to the epoch is asked once -/// (a complete answer ends the walk). A segment that fills its budget is narrowed towards hi to a width sized -/// from the density it showed, never below a row it saw (rows exist there) — so dense history is bisected, not paged. -/// At most reads. +/// Walk-back (the gateway reads forward only), in two phases. Gallop: segments (lo, hi] stepping +/// back from hi, each one forward read, the first sized from the density the window read showed (else +/// ), an empty one growing the next ×4; after two empty segments the whole remainder down +/// to the epoch is asked once — a complete answer ends the walk, a FULL one seeds the bisection with its last row. +/// Bisect (once a stored row is known below hi — a full segment's last row, or the last row of an earlier +/// window read): read (mid, hi]; empty ⇒ hi = mid; full ⇒ the known row moves up to its last row; +/// complete with rows ⇒ its last row is the answer. Right after a full read the next segment is aimed at about a +/// quarter of a read at the density it showed (one try — an empty aimed segment falls back to halving), and below +/// 2 ms the segment is the whole stretch from the known row (never a 100 ns step). +/// +/// +/// Bounded. One request may take at most 64 + 4 × distinct times raw reads (, +/// ); past it — or with a strict-gateway ceiling below rows — the +/// node fails BadTooManyOperations with a message naming the limit. A jump read (to a time far past the previous +/// read) asks for only rows, so a thousand times spread over ten years cost about a thousand +/// small reads, not a thousand 1,024-row reads. /// /// /// Wire shape. Every request bound is a whole millisecond (AVEVA stores milliseconds and truncates request @@ -57,23 +67,34 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway; /// internal static class GatewayAtTimeReader { - /// The row budget of a window read and a walk-back segment. + /// The row budget of a window read (the first, or one continuing from a full one) and a walk-back segment. internal const int ReadRows = 1024; + /// The row budget of a jump read (a window read restarting at a time far past the previous read's last row). + internal const int JumpRows = 64; + /// The row budget of the forward probe for the sample after the last time (boundary row + ties + one). internal const int ProbeRows = 2 + RawAggregateReader.PreWindowAllowance; + /// The smallest row budget a read can work with (the boundary row, a tie, one sample beyond). + internal const int MinBudget = 4; + + /// Raw reads one request may always take … + internal const int BaseReads = 64; + + /// … plus this many per distinct requested time. + internal const int ReadsPerTime = 4; + /// The first walk-back segment's width when the window read showed no density. internal static readonly TimeSpan InitialWalkWidth = TimeSpan.FromMinutes(1); /// The widest walk-back segment sized from density, before ×4 growth. private static readonly TimeSpan MaxSeededWidth = TimeSpan.FromDays(1); - /// The most reads one walk-back may take. - internal const int MaxWalkReads = 64; - private const long Ms = TimeSpan.TicksPerMillisecond; + private static readonly TimeSpan OneMs = TimeSpan.FromMilliseconds(1); + private static readonly DateTime Floor = RawAggregateReader.ProbeFloor; /// One drained read: the rows strictly inside the request (ascending) and whether the stream ended short. @@ -86,28 +107,49 @@ internal static class GatewayAtTimeReader /// Requested times (any kind; treated as UTC, as on the SQL backends). /// Server timestamp, the beyond-now rule and the look-ahead horizon. /// Cancellation. - /// One value per requested time, stamped with it. - /// More than rows within one millisecond. /// The node's scalar value type (the hinted IHistorianDataSource.ReadAtTimeAsync), or null. + /// The request's raw-read cap; null = + per distinct time. + /// One value per requested time, stamped with it. + /// More than rows within one millisecond, the + /// read cap, or a strict gateway ceiling below . public static async Task> ReadAsync(IHistorianGatewayClient client, GatewayRowCeiling ceiling, - string tag, IReadOnlyList timestampsUtc, DateTime now, CancellationToken ct, TypeCode? valueType = null) + string tag, IReadOnlyList timestampsUtc, DateTime now, CancellationToken ct, TypeCode? valueType = null, + int? maxReads = null) { var requested = timestampsUtc.Select(t => DateTime.SpecifyKind(t, DateTimeKind.Utc)).ToArray(); if (requested.Length == 0) return []; - var neighbours = await ResolveAsync(new Ctx(client, ceiling, tag, ct, valueType), requested.Distinct().Order().ToArray(), now) - .ConfigureAwait(false); + var times = requested.Distinct().Order().ToArray(); + var ctx = new Ctx(client, ceiling, tag, ct, valueType, maxReads ?? BaseReads + ReadsPerTime * times.Length); + var neighbours = await ResolveAsync(ctx, times, now).ConfigureAwait(false); var result = new DataValueSnapshot[requested.Length]; for (var i = 0; i < requested.Length; i++) { var t = requested[i]; var (prev, next) = neighbours[t.Ticks]; + // A DateTime node is stepped (as on the SQL backends): a usable non-numeric next sends the evaluator down its + // stepped branch, with the next's own status. + if (valueType == TypeCode.DateTime && next is { IsUsable: true } n) + next = n with { Value = HistorianValue.FromString(string.Empty) }; result[i] = ToNodeType(AtTimeEvaluator.Evaluate(t, prev, next, now, now), valueType); } return result; } - private sealed record Ctx(IHistorianGatewayClient Client, GatewayRowCeiling Ceiling, string Tag, CancellationToken Ct, - TypeCode? ValueType); + /// One request's read context: the client, the type hint, and the read count against its cap. + private sealed class Ctx(IHistorianGatewayClient client, GatewayRowCeiling ceiling, string tag, CancellationToken ct, + TypeCode? valueType, int maxReads) + { + public IHistorianGatewayClient Client { get; } = client; + public GatewayRowCeiling Ceiling { get; } = ceiling; + public string Tag { get; } = tag; + public CancellationToken Ct { get; } = ct; + public TypeCode? ValueType { get; } = valueType; + public int MaxReads { get; } = maxReads; + public int Reads { get; set; } + + /// The smallest budget a read was served with (a strict gateway's ceiling shows here). + public int SmallestBudget { get; set; } = int.MaxValue; + } /// True for the integer type codes (evaluated as Int64, returned as the node's own type). private static bool IsInteger(TypeCode? t) => @@ -145,21 +187,22 @@ internal static class GatewayAtTimeReader for (; i < times.Length && times[i] <= Floor; i++) result[times[i].Ticks] = (null, null); if (i == times.Length) return result; - // Reads are planned on times clamped to the horizon (nothing is stored beyond it). - var qe = MsFloor(Min(times[^1], horizon)) + TimeSpan.FromMilliseconds(1); + // Reads are planned on times clamped to the horizon (nothing is stored beyond it, and no read ends past it). + var qe = Min(MsFloor(Min(times[^1], horizon)) + OneMs, horizon); var qs = RequestStart(Min(times[i], horizon)); + var budget = ReadRows; StoredSample? before = null; // the latest stored sample at or before qs … var beforeKnown = false; // … once known - DateTime? walkKnown = null; // a stored sample exists here (at or before qs): the walk-back never goes below it + DateTime? walkKnown = null; // a stored sample exists here (at or before qs): the walk-back bisects above it var walkWidth = InitialWalkWidth; var probeFrom = -1; // first index whose next lies at or after qe (complete last read) while (true) { - var read = await ReadTrustedAsync(ctx, qs, qe, ReadRows).ConfigureAwait(false); + var read = await ReadTrustedAsync(ctx, qs, qe, budget).ConfigureAwait(false); var rows = read.Rows; if (!read.Complete && rows.Count == 0) - throw TooDense(ctx.Tag, qs); + throw TooDense(ctx, qs); if (rows.Count >= 2 && !beforeKnown && walkKnown is null) walkWidth = Clamp(TimeSpan.FromTicks(4 * (rows[^1].TimeUtc - rows[0].TimeUtc).Ticks / (rows.Count - 1))); @@ -197,15 +240,17 @@ internal static class GatewayAtTimeReader if (k == times.Length) break; - // Truncated before times[k]: continue from the last row, or jump to times[k] with a density-sized margin. + // Truncated before times[k]: continue from the last row, or jump to times[k] with a density-sized margin and a + // small budget (it needs that time's neighbours, and whatever later times follow closely). var tn = Min(times[k], horizon); var spanTicks = Math.Max(1, (x - rows[0].TimeUtc).Ticks); var density = (double)rows.Count / spanTicks; // rows per tick var gapRows = density * (tn - x).Ticks; - var margin = Clamp(TimeSpan.FromTicks((long)(ReadRows / 4 / Math.Max(density, 1e-18)))); - var resume = MsFloor(x) - TimeSpan.FromMilliseconds(1); - var nextQs = gapRows < ReadRows / 2.0 ? resume : Max(RequestStart(tn - Min(margin, tn - Floor)), resume); - if (nextQs <= qs) throw TooDense(ctx.Tag, x); + var margin = Clamp(TimeSpan.FromTicks((long)Math.Min(TimeSpan.MaxValue.Ticks / 2.0, JumpRows / 4.0 / Math.Max(density, 1e-18)))); + var resume = MsFloor(x) - OneMs; + var jump = gapRows >= ReadRows / 2.0; + var nextQs = jump ? Max(RequestStart(tn - Min(margin, tn - Floor)), resume) : resume; + if (nextQs <= qs) throw TooDense(ctx, x); if (nextQs < x) { @@ -225,13 +270,14 @@ internal static class GatewayAtTimeReader walkKnown = x; walkWidth = margin; } + budget = jump ? JumpRows : ReadRows; qs = nextQs; i = k; } - if (probeFrom >= 0 && qe - TimeSpan.FromMilliseconds(1) < horizon) + if (probeFrom >= 0 && qe < horizon) { - var after = await FirstAfterAsync(ctx, qe - TimeSpan.FromMilliseconds(1), horizon, Min(times[^1], horizon)).ConfigureAwait(false); + var after = await FirstAfterAsync(ctx, qe - OneMs, horizon, Min(times[^1], horizon)).ConfigureAwait(false); if (after is not null) for (var k = probeFrom; k < times.Length; k++) { @@ -247,70 +293,92 @@ internal static class GatewayAtTimeReader /// The evaluator reads next only for a usable prev that is not an exact hit. private static bool NeedsNext(DateTime t, StoredSample? prev) => prev is { IsUsable: true } p && p.TimeUtc != t; + /// One walk-back segment (lo, hi]: its oldest and newest rows, and whether the read was complete. + private static async Task<(StoredSample? First, StoredSample? Last, bool Complete)> SegmentAsync( + Ctx ctx, DateTime lo, DateTime hi) + { + var read = await ReadTrustedAsync(ctx, MsFloor(lo), MsFloor(hi) + OneMs, ReadRows).ConfigureAwait(false); + StoredSample? first = null, last = null; + foreach (var r in read.Rows) + if (r.TimeUtc > lo && r.TimeUtc <= hi) + { + first ??= r; + last = r; + } + return (first, last, read.Complete); + } + /// - /// The latest stored sample with time in (Floor, hi], walking back in forward-read segments (see the type - /// remarks). : a stored sample exists at that time (≤ ). + /// The latest stored sample with time in (Floor, hi] (see the type remarks: gallop, then bisect). + /// : a stored sample exists at that time (≤ ) — the walk then bisects + /// above it straight away, its first segment aimed at . /// private static async Task WalkBackAsync(Ctx ctx, DateTime hi, DateTime? known, TimeSpan width) { + TimeSpan? aim = known is null ? null : width; + + // Gallop: no stored row known below hi yet. var empties = 0; - var triedRest = false; - for (var reads = 0; reads < MaxWalkReads; reads++) + while (known is null) { if (hi <= Floor) return null; - var rest = known is null && !triedRest && empties >= 2; - DateTime lo; - if (rest) + var rest = empties >= 2; + var lo = rest || (hi - Floor).Ticks <= width.Ticks ? Floor : hi - width; + var seg = await SegmentAsync(ctx, lo, hi).ConfigureAwait(false); + if (seg.Complete) { - lo = Floor; - triedRest = true; - } - else - { - lo = (hi - Floor).Ticks > width.Ticks ? hi - width : Floor; - if (known is { } kn && lo < kn) lo = kn.AddTicks(-1); // never below a row known to exist (it is the floor) - } - - var read = await ReadTrustedAsync(ctx, MsFloor(lo), MsFloor(hi) + TimeSpan.FromMilliseconds(1), ReadRows) - .ConfigureAwait(false); - StoredSample? first = null, last = null; - DateTime? lastAny = null; - foreach (var r in read.Rows) - { - if (r.TimeUtc > lo && r.TimeUtc <= hi) - { - first ??= r; - last = r; - } - lastAny = r.TimeUtc; - } - - if (read.Complete) - { - if (last is not null) return last; + if (seg.Last is { } found) return found; if (lo <= Floor) return null; hi = lo; empties++; width = Grow(width); continue; } - - // Truncated. A whole-remainder read of deep history proves nothing: carry on with segments. - if (rest) continue; - if (last is not { } seen || (known is { } k0 && seen.TimeUtc <= k0)) - throw TooDense(ctx.Tag, lastAny ?? lo); - var spanTicks = Math.Max(1, (seen.TimeUtc - first!.Value.TimeUtc).Ticks); // the density of the rows seen + // Full — a whole-remainder read of deep history too: its last row is a stored row below hi. Bisect above it. + if (seg.Last is not { } seen) throw TooDense(ctx, lo); known = seen.TimeUtc; - // Aim at about a quarter of a read from the density just seen, and never wider than half the open stretch. - var aimed = TimeSpan.FromTicks(Math.Max(Ms, spanTicks / 4)); - var half = TimeSpan.FromTicks(Math.Max(Ms, (hi - seen.TimeUtc).Ticks / 2)); - width = aimed < half ? aimed : half; - empties = 0; + aim = AimFrom(seg.First!.Value.TimeUtc, seen.TimeUtc); + } + + // Bisect: a stored row exists at `known`, nothing newer than `hi` matters; the answer is the newest row in [known, hi]. + var aimMissed = false; + while (true) + { + var k = known.Value; + DateTime lo; + var aimed = false; + if (hi - k < TimeSpan.FromMilliseconds(2)) + lo = k.AddTicks(-1); // the whole stretch from the known row: never a sub-millisecond step + else + { + lo = k + TimeSpan.FromTicks((hi - k).Ticks / 2); + if (aim is { } a && hi - a > lo) + { + lo = hi - a; // an aimed try right after a full read — until one comes back empty (a gap: halve from then on) + aimed = true; + } + } + aim = null; + + var seg = await SegmentAsync(ctx, lo, hi).ConfigureAwait(false); + if (seg.Complete) + { + if (seg.Last is { } found) return found; + if (lo < k) return null; // the known row is gone (deleted meanwhile): nothing is left to find + hi = lo; + aimMissed |= aimed; + continue; + } + if (seg.Last is not { } seen || seen.TimeUtc <= k) throw TooDense(ctx, k); + known = seen.TimeUtc; + if (!aimMissed) aim = AimFrom(seg.First!.Value.TimeUtc, seen.TimeUtc); } - throw new HistoryReadLimitExceededException(MaxWalkReads, - $"The AtTime read of '{ctx.Tag}' could not find the stored sample before a requested time within {MaxWalkReads} raw reads."); } + /// About a quarter of a read at the density a full read showed (its rows from to ). + private static TimeSpan AimFrom(DateTime first, DateTime last) => + TimeSpan.FromTicks(Math.Max(Ms, (last - first).Ticks / 4)); + /// The first stored sample after in (qs, end), from a small forward read. private static async Task FirstAfterAsync(Ctx ctx, DateTime qs, DateTime end, DateTime after) { @@ -321,21 +389,35 @@ internal static class GatewayAtTimeReader if (r.TimeUtc > after) return r; if (read.Complete) return null; } - throw TooDense(ctx.Tag, qs); + throw TooDense(ctx, qs); } /// /// One forward read [qs, qe] (whole milliseconds), drained, keeping only rows strictly inside it and after the - /// epoch. A strict gateway's ceiling is learned on refusal and then asked for directly; a full read is not re-probed - /// (the caller pages on, it does not need the rest of this read). + /// epoch — counted against the request's read cap. A strict gateway's ceiling is learned on refusal and then asked for + /// directly; a full read is not re-probed (the caller pages on, it does not need the rest of this read). /// private static async Task ReadTrustedAsync(Ctx ctx, DateTime qs, DateTime qe, int budget) { - var (read, _) = await ctx.Ceiling.ReadAsync(budget, b => PassAsync(ctx, qs, qe, b), (_, _) => false) + if (++ctx.Reads > ctx.MaxReads) + throw new HistoryReadLimitExceededException(ctx.MaxReads, + $"The AtTime read of '{ctx.Tag}' needed more than {ctx.MaxReads} raw reads ({BaseReads} + {ReadsPerTime} per " + + "requested time)" + CeilingNote(ctx) + "; the Gateway backend serves AtTime from raw reads — request fewer times."); + var (read, used) = await ctx.Ceiling.ReadAsync(budget, b => PassAsync(ctx, qs, qe, b), (_, _) => false) .ConfigureAwait(false); + if (used < ctx.SmallestBudget) ctx.SmallestBudget = used; + if (used < MinBudget) + throw new HistoryReadLimitExceededException(used, + $"The HistorianGateway's row ceiling ({used}, its Historian:ReadLimits:MaxValuesCeiling with " + + $"AllowStreamingBeyondCeiling=false) is below the {MinBudget} rows an AtTime raw read of '{ctx.Tag}' needs."); return read; } + private static string CeilingNote(Ctx ctx) => + ctx.SmallestBudget < JumpRows + ? $", with reads bounded by the HistorianGateway's row ceiling of {ctx.SmallestBudget} (Historian:ReadLimits:MaxValuesCeiling)" + : string.Empty; + private static async Task PassAsync(Ctx ctx, DateTime qs, DateTime qe, int budget) { var rows = new List(); @@ -369,9 +451,11 @@ internal static class GatewayAtTimeReader { TypeCode.Boolean => HistorianValue.FromBoolean(v != 0), TypeCode.String => HistorianValue.FromString(v.ToString("R", System.Globalization.CultureInfo.InvariantCulture)), - // In Int64 range only (2^63 is exactly representable; anything at or beyond it stays a Double). - _ when IsInteger(valueType) && v >= -9.2233720368547758E18 && v < 9.2233720368547758E18 => - HistorianValue.FromInt64((long)Math.Round(v, MidpointRounding.AwayFromZero)), + // In Int64 range only (2^63 is exactly representable); NaN, ±∞ or out of range is no integer at all — Empty, which + // StoredSample.FromStored makes BadNoData (unusable), never a saturated Good value. + _ when IsInteger(valueType) => v >= -9.2233720368547758E18 && v < 9.2233720368547758E18 + ? HistorianValue.FromInt64((long)Math.Round(v, MidpointRounding.AwayFromZero)) + : HistorianValue.Empty, _ => HistorianValue.FromDouble(v), }; @@ -395,8 +479,12 @@ internal static class GatewayAtTimeReader private static TimeSpan Grow(TimeSpan w) => w.Ticks > TimeSpan.MaxValue.Ticks / 4 ? TimeSpan.MaxValue : w * 4; - private static HistoryReadLimitExceededException TooDense(string tag, DateTime at) => - new(ReadRows, - $"The AtTime read of '{tag}' met more than {ReadRows} stored samples within about one millisecond of " + - $"{at:O}; the Gateway backend serves AtTime from raw reads."); + private static HistoryReadLimitExceededException TooDense(Ctx ctx, DateTime at) + { + var rows = Math.Min(ReadRows, ctx.SmallestBudget); + return new(rows, + $"The AtTime read of '{ctx.Tag}' met more than {rows} stored samples within about one millisecond of {at:O}" + + (rows < ReadRows ? $" (reads are bounded by the HistorianGateway's row ceiling of {rows})" : string.Empty) + + "; the Gateway backend serves AtTime from raw reads."); + } } diff --git a/tests/Drivers/Historian.Conformance/AtTimeConformance.cs b/tests/Drivers/Historian.Conformance/AtTimeConformance.cs index 2f8a9341..0379f484 100644 --- a/tests/Drivers/Historian.Conformance/AtTimeConformance.cs +++ b/tests/Drivers/Historian.Conformance/AtTimeConformance.cs @@ -139,6 +139,7 @@ public abstract class AtTimeConformance : ConformanceBase (At(10), HistorianValue.FromInt64(15), Good)); var s = (await B.CreateDataSource().ReadAtTimeAsync("I", [At(5), At(2)], Ct)).Samples; s[0].Value.ShouldBe(13L); // 12.5 ⇒ 13 (away from zero) + s[0].Value.ShouldBeOfType(); // ShouldBe compares numbers across types — the type needs its own check (#693) s[1].Value.ShouldBe(11L); // 11.0 s[0].StatusCode.ShouldBe(GoodInterpolated); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayAtTimeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayAtTimeTests.cs index 06378294..3c48537a 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayAtTimeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayAtTimeTests.cs @@ -409,7 +409,7 @@ public sealed class GatewayAtTimeTests var s = (await AtTime(fake, T0.AddMinutes(-5))).Single(); s.Value.ShouldBe(599_999d); s.StatusCode.ShouldBe(GoodInterpolated); // stepped from the last sample (before now) - fake.ReadRawCallCount.ShouldBe(7); // window, 1m, 4m, remainder (truncated), 16m (truncated), 256 ms, probe + fake.ReadRawCallCount.ShouldBe(6); // window, 1m, 4m, remainder (full: seeds the bisection), one aimed segment, probe store.RowsStreamed.ShouldBeLessThan(10_000); } @@ -446,6 +446,186 @@ public sealed class GatewayAtTimeTests ds.GetHealthSnapshot().TotalFailures.ShouldBe(1); } + // ---- long histories (review H1/M1/L1): a generated store, too large to materialise ----------------------------- + + /// + /// A regular series (value = sample index) over [from, to] every , optionally with a + /// hole (gapFrom, gapTo), served in the AVEVA wire shape (boundary row at the query start, ms bounds only, + /// max_values > 0, start <= end, nothing past now + 2 days). + /// + private sealed class SeriesStore(DateTime from, DateTime to, TimeSpan step, DateTime? gapFrom = null, DateTime? gapTo = null) + { + public long RowsStreamed; + + private bool InGap(DateTime t) => gapFrom is { } a && gapTo is { } b && t > a && t < b; + + private DateTime? FirstAtOrAfter(DateTime t) + { + if (t > to) return null; + var k = t <= from ? 0 : ((t - from).Ticks + step.Ticks - 1) / step.Ticks; + var r = from.AddTicks(k * step.Ticks); + if (InGap(r)) return FirstAtOrAfter(gapTo!.Value); + return r <= to ? r : null; + } + + private DateTime? LastBefore(DateTime t) + { + if (t <= from) return null; + var k = ((Min(t, to.AddTicks(1)) - from).Ticks - 1) / step.Ticks; + var r = from.AddTicks(k * step.Ticks); + return InGap(r) ? LastBefore(gapFrom!.Value.AddTicks(1)) : r; + } + + private static DateTime Min(DateTime a, DateTime b) => a <= b ? a : b; + + /// The stored row at , stamped (the boundary row) when given. + private HistorianSample Row(DateTime t, uint quality = 0, DateTime? at = null) => new() + { + Tag = "T", Timestamp = Timestamp.FromDateTime(at ?? t), NumericValue = (t - from).Ticks / step.Ticks, OpcQuality = 192, + Quality = quality, + }; + + public FakeHistorianGatewayClient Client(int? ceiling = null) => new() { RawSource = Serve, MaxValuesCeiling = ceiling }; + + private IEnumerable Serve(DateTime start, DateTime end, int max) + { + if (max <= 0 || end < start || end > Now.AddDays(2) + || start.Ticks % TimeSpan.TicksPerMillisecond != 0 || end.Ticks % TimeSpan.TicksPerMillisecond != 0) + throw new Grpc.Core.RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.InvalidArgument, "refused")); + var rows = new List(); + if (FirstAtOrAfter(start) != start) + rows.Add(LastBefore(start) is { } p + ? Row(p, 133, start) + : new HistorianSample { Tag = "T", Timestamp = Timestamp.FromDateTime(start), OpcQuality = 0, Quality = 1, NumericValue = 0 }); + for (var t = FirstAtOrAfter(start); t is { } u && u <= end && rows.Count < max; t = FirstAtOrAfter(u.AddTicks(1))) + rows.Add(Row(u)); + RowsStreamed += rows.Count; + return rows; + } + } + + [Theory] // H1: a dense tag that stopped reporting — the walk crosses the silence by bisection, not by regrowing segments + [InlineData(1.0, 30)] + [InlineData(1.0, 365)] + [InlineData(10.0, 30)] + [InlineData(10.0, 365)] + public async Task A_dense_tag_that_stopped_long_ago_is_found_in_a_bounded_number_of_reads(double hz, int stoppedDaysAgo) + { + var step = TimeSpan.FromTicks((long)(TimeSpan.TicksPerSecond / hz)); + var end = Now.AddDays(-stoppedDaysAgo); + var fake = new SeriesStore(end.AddYears(-5), end, step).Client(); + var s = (await AtTime(fake, Now.AddMinutes(-1))).Single(); + s.Value.ShouldBe((double)((end - end.AddYears(-5)).Ticks / step.Ticks)); // the last sample, held + s.StatusCode.ShouldBe(GoodInterpolated); + fake.ReadRawCallCount.ShouldBeLessThan(40); + } + + [Fact] // H1: a time inside a month-long hole in the middle of a dense history + public async Task A_time_inside_a_mid_history_gap_interpolates_across_it() + { + var from = Now.AddYears(-2); + var gapFrom = Now.AddDays(-200); + var gapTo = gapFrom.AddDays(30); + var fake = new SeriesStore(from, Now, TimeSpan.FromSeconds(1), gapFrom, gapTo).Client(); + var t = gapFrom.AddDays(15); + var s = (await AtTime(fake, t)).Single(); + var before = (gapFrom - from).Ticks / TimeSpan.TicksPerSecond; + var after = (gapTo - from).Ticks / TimeSpan.TicksPerSecond; + ((double)s.Value!).ShouldBe(before + (after - before) * 0.5, 1e-6); + s.StatusCode.ShouldBe(GoodInterpolated); + fake.ReadRawCallCount.ShouldBeLessThan(40); + } + + [Fact] // H1: a jump to a time in a gap — the walk starts from the earlier read's last row, far below + public async Task A_jump_into_a_gap_bisects_up_from_the_earlier_reads_last_row() + { + var from = Now.AddDays(-30); + var gapFrom = from.AddDays(2); + var gapTo = gapFrom.AddDays(10); + var fake = new SeriesStore(from, Now, TimeSpan.FromSeconds(1), gapFrom, gapTo).Client(); + var t0 = from.AddSeconds(10.5); + var t1 = gapFrom.AddDays(5); + var s = await AtTime(fake, t0, t1); + s[0].Value.ShouldBe(10.5d); + var before = (gapFrom - from).Ticks / TimeSpan.TicksPerSecond; + var after = (gapTo - from).Ticks / TimeSpan.TicksPerSecond; + ((double)s[1].Value!).ShouldBe(before + (after - before) * 0.5, 1e-6); + fake.ReadRawCallCount.ShouldBeLessThan(40); + } + + [Fact] // M1: widely spread times cost one SMALL read each, not one 1,024-row read each + public async Task A_thousand_times_over_ten_years_cost_one_small_read_each() + { + var store = new SeriesStore(Now.AddYears(-10), Now, TimeSpan.FromSeconds(1)); + var fake = store.Client(); + var rnd = new Random(1); + var times = Enumerable.Range(0, 1000).Select(_ => Now.AddYears(-9).AddTicks((long)(rnd.NextDouble() * TimeSpan.TicksPerDay * 3000))).ToArray(); + var s = await AtTime(fake, times); + s.ShouldAllBe(x => x.StatusCode == GoodInterpolated); + fake.ReadRawCallCount.ShouldBeLessThanOrEqualTo(1010); + store.RowsStreamed.ShouldBeLessThan(80_000); // ~64 rows a read (was ~1,000,000) + } + + [Fact] // L1: a strict-gateway ceiling too small to work with is refused with a message naming it + public async Task A_ceiling_below_a_usable_read_is_refused_naming_the_ceiling() + { + var fake = new SeriesStore(Now.AddDays(-1), Now, TimeSpan.FromSeconds(1)).Client(ceiling: 2); + var ex = await Should.ThrowAsync( + () => Source(fake).ReadAtTimeAsync("T", [Now.AddHours(-1)], Ct)); + ex.Message.ShouldContain("row ceiling (2"); + } + + [Fact] // L1: a small ceiling is served (paging), within the read cap + public async Task A_small_ceiling_is_served_within_the_read_cap() + { + var fake = new SeriesStore(Now.AddYears(-1), Now, TimeSpan.FromSeconds(1)).Client(ceiling: 10); + var times = Enumerable.Range(0, 50).Select(i => Now.AddDays(-300 + i * 5).AddSeconds(0.5)).ToArray(); + var s = await AtTime(fake, times); + s.ShouldAllBe(x => x.StatusCode == GoodInterpolated); + fake.ReadRawCallCount.ShouldBeLessThanOrEqualTo(GatewayAtTimeReader.BaseReads + GatewayAtTimeReader.ReadsPerTime * 50); + } + + [Fact] // L1: the request's read cap stops a walk with a clear error, never a hang (here a cap of 3 vs. a 5-read walk) + public async Task The_read_cap_stops_a_request_with_a_clear_error() + { + var fake = new AvevaStore([(T0.AddDays(-400), 42, false), (T0.AddDays(-400).AddSeconds(10), 43, false)]).Client(); + var ex = await Should.ThrowAsync(() => GatewayAtTimeReader.ReadAsync( + fake, new GatewayRowCeiling(), "T", [T0], Now, Ct, maxReads: 3)); + ex.Message.ShouldContain("needed more than 3 raw reads"); + fake.ReadRawCallCount.ShouldBe(3); + fake.RawStreamsCompleted.ShouldBe(3); + } + + [Fact] // the default cap scales with the request: 64 + 4 per distinct time + public async Task The_default_read_cap_is_64_plus_4_per_distinct_time() + { + (GatewayAtTimeReader.BaseReads, GatewayAtTimeReader.ReadsPerTime).ShouldBe((64, 4)); + await Task.CompletedTask; + } + + // ---- type hint edge cases (review L2/L3) ------------------------------------------------------------------------ + + [Fact] + public async Task A_DateTime_node_is_stepped() + { + var fake = new AvevaStore([(At(0), 0, false), (At(10), 10, false)]).Client(); + var s = (await AtTimeTyped(fake, TypeCode.DateTime, At(5))).Single(); + s.Value.ShouldBe(0d); + s.StatusCode.ShouldBe(GoodInterpolated); + } + + [Theory] + [InlineData(double.PositiveInfinity)] + [InlineData(double.NaN)] + [InlineData(1e30)] + public async Task Under_an_integer_hint_a_non_integer_next_value_is_unusable_not_saturated(double next) + { + var fake = new AvevaStore([(At(0), 100, false), (At(10), next, false)]).Client(); + var s = (await AtTimeTyped(fake, TypeCode.Int32, At(5))).Single(); + s.Value.ShouldBeOfType().ShouldBe(100); // held before an unusable neighbour + s.StatusCode.ShouldBe(UncertainInterpolated); + } + // ---- kill switch ----------------------------------------------------------------------------------------------- [Fact]