diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryBounds.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryBounds.cs new file mode 100644 index 00000000..120def61 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryBounds.cs @@ -0,0 +1,197 @@ +using Opc.Ua; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; + +/// +/// The end bound still owed to a HistoryRead-Raw read with ReturnBounds = true (Gitea #691). It rides the +/// continuation point, because Part 11 returns the end bound with the LAST page only. +/// +/// The request's end time (the later bound of a forward read, the earlier of a reverse one). +/// The read is newest-first (StartTime > EndTime, including an end-only read). +/// +/// false only for StartTime == EndTime: the samples AT that instant are the data, so the end bound is the +/// first sample strictly after it (Part 11 Table 1 footnote c). Otherwise the end bound is the sample at or beyond the +/// end time, and the data window excludes the end time. +/// +/// The end time was not given (a one-sided read's sentinel): there is no end bound to look up. +/// The data is drained and the end bound did not fit on the previous page. +/// The last value's time returned on an earlier page (stamps an open bound's placeholder). +internal sealed record HistoryEndBound( + DateTime AtUtc, bool Reverse, bool Inclusive, bool Open, bool DataExhausted = false, DateTime? LastReturnedUtc = null); + +/// +/// Gitea #691: OPC UA Part 11 bounding values for HistoryRead-Raw (ReadRawModifiedDetails.ReturnBounds), composed +/// in the server from every backend's existing raw read — the ONE authority, so SqlServer, QuestDB, Gateway and Null +/// answer identically. +/// +/// +/// +/// What a bound is (Part 11 §4.6, Table 1). Forward: the start bound is the stored sample AT or immediately +/// BEFORE StartTime, the end bound the one AT or immediately AFTER EndTime. Reverse mirrors it: the start bound is at +/// or after StartTime, the end bound at or before EndTime. A sample exactly on a bound IS the bound: on the start it +/// is already the first sample of the data (the window includes StartTime), so nothing extra is added; on the end it is +/// added (the window excludes EndTime). StartTime == EndTime reads forward, and its end bound is the first +/// sample strictly after the instant. A bound is a stored sample of any quality — a Bad sample is returned as stored. +/// +/// +/// Missing bound. Bad_BoundNotFound, a null value, stamped with the bound's time. A one-sided read's +/// missing side (no EndTime / no StartTime) is never looked up; it is Bad_BoundNotFound stamped one second after +/// (forward) or before (reverse) the previous returned value (Table 1 footnotes a and b). +/// +/// +/// Lookups. "At or before T" is a newest-first read of (0, T] capped at one — native on a backend that +/// declares , otherwise 's walk-back +/// from bounded forward reads (the Gateway: NOT AVEVA's synthetic boundary row, which is stamped at the query start +/// rather than at the prior sample's own time). "At or after T" is a forward read of [T, MaxValue) capped at one; +/// "strictly after T" starts one storage resolution later (lossless: nothing is stored in between). Inside a tie +/// cluster the bound is the cluster's first sample in the read's direction — the forward read's first, the reverse +/// read's first (the forward order's last). +/// +/// +/// Resolution. "On the bound" compares at the backend's storage resolution, because a microsecond backend +/// (QuestDB) truncates the window bounds it is sent: a sample at ⌊T⌋ is inside a window starting at T +/// there, so it must not also be returned as the start bound. +/// +/// +internal static class HistoryBounds +{ + private static readonly TimeSpan OpenBoundStep = TimeSpan.FromSeconds(1); + + /// The end bound owed by a fresh read over the normalised window. + /// The (normalised) start: the later time of a reverse read. + /// The (normalised) end; a one-sided read's sentinel (tick 0 / MaxValue) is an open bound. + /// The pending end bound. + public static HistoryEndBound For(DateTime startUtc, DateTime endUtc) => + new(endUtc, Reverse: startUtc > endUtc, Inclusive: startUtc != endUtc, Open: IsOpen(endUtc)); + + /// The pending end bound to store behind a continuation point, remembering the page's last returned time. + /// The pending end bound (null = bounds not requested). + /// The page being returned. + /// The bound to carry, or null. + public static HistoryEndBound? Carry(HistoryEndBound? bound, IReadOnlyList page) => + bound is null || page.Count == 0 || page[^1].SourceTimestampUtc is not { } last + ? bound + : bound with { LastReturnedUtc = last }; + + /// The Bad_BoundNotFound placeholder (null value) stamped at . + /// The bound's time. + /// The placeholder. + public static DataValueSnapshot NotFound(DateTime atUtc) => + new(null, StatusCodes.BadBoundNotFound, atUtc, atUtc); + + /// + /// The start bound to return ahead of the data, or null when a stored sample sits on StartTime (that sample is + /// the bound, and it is already the data's first sample). + /// + public static async Task StartBoundAsync( + IHistorianDataSource source, string tagname, DateTime startUtc, bool reverse, int maxTieCluster, DateTime nowUtc, + CancellationToken ct) + { + if (IsOpen(startUtc)) return NotFound(startUtc); + var found = reverse + ? await EarliestAtOrAfterAsync(source, tagname, startUtc, ct).ConfigureAwait(false) + : await LatestAtOrBeforeAsync(source, tagname, startUtc, maxTieCluster, nowUtc, ct).ConfigureAwait(false); + if (found is null) return NotFound(startUtc); + return SameInstant(Ticks(found), startUtc.Ticks, Resolution(source)) ? null : found; + } + + /// The end bound: the stored sample on or beyond the end time, else the placeholder. + /// The backend. + /// Historian tag. + /// The pending end bound. + /// The last value's time already returned (stamps an open bound's placeholder). + /// The walk-back's row budget (forward-only backends). + /// Now (the walk-back's look-ahead clamp). + /// Cancellation. + public static async Task EndBoundAsync( + IHistorianDataSource source, string tagname, HistoryEndBound bound, DateTime previousReturnedUtc, + int maxTieCluster, DateTime nowUtc, CancellationToken ct) + { + if (bound.Open) + { + var ticks = bound.Reverse + ? Math.Max(0L, previousReturnedUtc.Ticks - OpenBoundStep.Ticks) + : Math.Min(DateTime.MaxValue.Ticks, previousReturnedUtc.Ticks + OpenBoundStep.Ticks); + return NotFound(new DateTime(ticks, DateTimeKind.Utc)); + } + + DataValueSnapshot? found; + if (bound.Reverse) + found = await LatestAtOrBeforeAsync(source, tagname, bound.AtUtc, maxTieCluster, nowUtc, ct).ConfigureAwait(false); + else if (bound.Inclusive) + found = await EarliestAtOrAfterAsync(source, tagname, bound.AtUtc, ct).ConfigureAwait(false); + else + { + var resolution = Resolution(source); + found = bound.AtUtc.Ticks > DateTime.MaxValue.Ticks - resolution + ? null + : await EarliestAtOrAfterAsync(source, tagname, bound.AtUtc.AddTicks(resolution), ct).ConfigureAwait(false); + } + + return found ?? NotFound(bound.AtUtc); + } + + /// + /// Whether a data sample lies inside the data window of a bounded read — a forward window excludes the end time (a + /// sample there is the end bound), compared at the storage resolution. A backend whose forward end is inclusive, or a + /// resumed read that steps onto the end time, would otherwise return the end bound twice. + /// + public static bool InDataWindow(DataValueSnapshot sample, HistoryEndBound bound, long resolution) + { + if (!bound.Inclusive || bound.Open) return true; + var ts = Align(Ticks(sample), resolution); + var end = Align(bound.AtUtc.Ticks, resolution); + return bound.Reverse ? ts > end : ts < end; + } + + /// Whether a resumed read's next start is already past the data window (only the end bound remains). + public static bool DataWindowDrained(DateTime nextStartUtc, HistoryEndBound bound, long resolution) + { + if (!bound.Inclusive || bound.Open) return false; + var next = Align(nextStartUtc.Ticks, resolution); + var end = Align(bound.AtUtc.Ticks, resolution); + return bound.Reverse ? next <= end : next >= end; + } + + /// The latest stored sample at or before , or null. + internal static async Task LatestAtOrBeforeAsync( + IHistorianDataSource source, string tagname, DateTime atUtc, int maxTieCluster, DateTime nowUtc, CancellationToken ct) + { + if (atUtc.Ticks == 0) return null; + var floor = new DateTime(0, DateTimeKind.Utc); + IReadOnlyList rows = source.SupportsReverseRaw + ? (await source.ReadRawAsync(tagname, atUtc, floor, 1, ct).ConfigureAwait(false)).Samples + : (await ReverseRawReader.ReadAsync(source, tagname, atUtc, floor, 1, maxTieCluster, nowUtc, 0, ct) + .ConfigureAwait(false)).Samples; + var limit = Align(atUtc.Ticks, Resolution(source)); + foreach (var row in rows) + if (Align(Ticks(row), Resolution(source)) <= limit) + return row; + return null; + } + + /// The earliest stored sample at or after , or null. + internal static async Task EarliestAtOrAfterAsync( + IHistorianDataSource source, string tagname, DateTime atUtc, CancellationToken ct) + { + if (atUtc.Ticks >= DateTime.MaxValue.Ticks) return null; + var rows = (await source.ReadRawAsync(tagname, atUtc, new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc), 1, ct) + .ConfigureAwait(false)).Samples; + var limit = Align(atUtc.Ticks, Resolution(source)); + foreach (var row in rows) + if (Align(Ticks(row), Resolution(source)) >= limit) + return row; + return null; + } + + private static bool IsOpen(DateTime t) => t.Ticks == 0 || t.Ticks == DateTime.MaxValue.Ticks; + + private static bool SameInstant(long a, long b, long resolution) => Align(a, resolution) == Align(b, resolution); + + private static long Resolution(IHistorianDataSource source) => Math.Max(1L, source.TimestampResolutionTicks); + + private static long Ticks(DataValueSnapshot s) => (s.SourceTimestampUtc ?? DateTime.MinValue).Ticks; + + private static long Align(long ticks, long resolution) => ticks - ticks % resolution; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs index 595affbb..b618dbf9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs @@ -62,6 +62,10 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; /// serialized), so a new field with a "none" default is all the versioning it needs. /// /// The exclusive upper end of that stretch. +/// +/// with ReturnBounds = true only (Gitea #691): the end bound still owed — +/// Part 11 returns it with the last page. null = bounds not requested. +/// internal sealed record HistoryContinuationState( string Tagname, DateTime NextStartUtc, @@ -74,7 +78,8 @@ internal sealed record HistoryContinuationState( bool Reverse = false, long SegmentWidthTicks = 0, long EmptyFromTicks = 0, - long EmptyToTicks = 0) + long EmptyToTicks = 0, + HistoryEndBound? PendingEndBound = null) { /// The walk-back hint this cursor carries (#686/#689/#691). public ReverseWalkHint WalkHint => new(SegmentWidthTicks, EmptyFromTicks, EmptyToTicks); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index a939ed3c..982a3e11 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -5056,6 +5056,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 var work = new List>(nodesToProcess.Count); foreach (var handle in nodesToProcess) { + if (details.IsReadModified && details.ReturnBounds) + { + // Part 11 §6.5.3.3: returnBounds "shall be set to FALSE" for a modified read, "otherwise the Server returns + // a Bad_InvalidArgument" (#691). + errors[handle.Index] = StatusCodes.BadInvalidArgument; + results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.BadInvalidArgument }; + continue; + } + if (details.IsReadModified) { // Part 11 modified-value history (#668): the prior values every HistoryUpdate recorded. Gated by the @@ -5070,7 +5079,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 work.Add(() => ServeRawPagedAsync( handle, session, nodesToRead, results, errors, details.StartTime, details.EndTime, details.NumValuesPerNode, - ct)); + ct, details.ReturnBounds)); } RunBounded(work, HistoryReadBatchParallelism); @@ -6197,6 +6206,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The request window's (inclusive) lower bound, used for a fresh read. /// The (inclusive) upper bound of the read window; unchanged across pages. /// The client's per-page cap; 0 means "all values, no paging". + /// Cancellation (the per-request deadline). + /// + /// Part 11 bounding values (#691, ): the start bound leads the first page, the end bound + /// ends the last, and both count toward . A resumed read takes it from the cursor. + /// private async Task ServeRawPagedAsync( NodeHandle handle, ISession? session, @@ -6206,7 +6220,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 DateTime startTimeUtc, DateTime endUtc, uint numValuesPerNode, - CancellationToken ct) + CancellationToken ct, + bool returnBounds = false) { var inboundCp = nodesToRead[handle.Index].ContinuationPoint; @@ -6215,6 +6230,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 DateTime startUtc; var boundarySkip = 0; var walkHint = default(ReverseWalkHint); // #686/#689/#691: the walk-back's width + proven-empty stretch, page to page + HistoryEndBound? endBound = null; // #691: the end bound still owed (null = bounds not requested) string tagname; if (inboundCp is { Length: > 0 }) @@ -6236,6 +6252,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 endUtc = state.EndUtc; numValuesPerNode = state.NumValuesPerNode; walkHint = state.WalkHint; + endBound = state.PendingEndBound; } else { @@ -6261,6 +6278,78 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 else if (startUtc == DateTime.MinValue && endUtc != DateTime.MinValue) (startUtc, endUtc) = (endUtc, new DateTime(0, DateTimeKind.Utc)); } + + if (returnBounds) endBound = HistoryBounds.For(startUtc, endUtc); + } + + var nowUtc = Clock.GetUtcNow().UtcDateTime; + var resolution = Math.Max(1L, HistorianDataSource.TimestampResolutionTicks); + + // #691: the data is drained — the end bound ends this page when it has room (Part 11: bounds count toward + // NumValuesPerNode), else it is the whole next page. Returns the continuation point to emit, if any. + async Task AppendEndBoundAsync(List page, DateTime resumedAtUtc) + { + // The previous returned value stamps an open bound's placeholder (Part 11 Table 1 footnotes a/b): this + // page's last, else the last of an earlier page (carried in the cursor), else where the read resumed. + var previous = (page.Count > 0 ? page[^1].SourceTimestampUtc : null) + ?? endBound!.LastReturnedUtc ?? resumedAtUtc; + if (numValuesPerNode == 0 || page.Count < numValuesPerNode) + { + page.Add(await HistoryBounds.EndBoundAsync( + HistorianDataSource, tagname, endBound!, previous, MaxTieClusterOverfetch, nowUtc, ct).ConfigureAwait(false)); + return null; + } + + return _historyContinuationStore.Save(session, new HistoryContinuationState( + tagname, previous, endUtc, 0, numValuesPerNode, SegmentWidthTicks: walkHint.WidthTicks, + EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks, + PendingEndBound: HistoryBounds.Carry(endBound!, page)! with { DataExhausted = true })); + } + + // #691: a resumed bounded read whose data is drained (the end bound did not fit on the previous page, or the + // resume point already sits on the end time) owes only the end bound. + if (endBound is not null && inboundCp is { Length: > 0 } + && (endBound.DataExhausted || HistoryBounds.DataWindowDrained(startUtc, endBound, resolution))) + { + var onlyBound = new List(1); + var afterCp = await AppendEndBoundAsync(onlyBound, startUtc).ConfigureAwait(false); + results[handle.Index] = new SdkHistoryReadResult + { + StatusCode = StatusCodes.Good, + HistoryData = new ExtensionObject(ToHistoryDataFromSamples(onlyBound)), + ContinuationPoint = afterCp, + }; + errors[handle.Index] = ServiceResult.Good; + return; + } + + // #691: the start bound leads the first page and counts toward the cap; null when a sample sits on StartTime + // (that sample is the bound, and it is the data's first). + DataValueSnapshot? startBound = null; + var readCap = numValuesPerNode; + if (endBound is not null && inboundCp is not { Length: > 0 }) + { + startBound = await HistoryBounds.StartBoundAsync( + HistorianDataSource, tagname, startUtc, endBound.Reverse, MaxTieClusterOverfetch, nowUtc, ct) + .ConfigureAwait(false); + if (startBound is not null && numValuesPerNode == 1) + { + // The page is the start bound alone; the data starts at StartTime on the next page. + var boundOnlyCp = _historyContinuationStore.Save(session, new HistoryContinuationState( + tagname, startUtc, endUtc, 0, numValuesPerNode, SegmentWidthTicks: walkHint.WidthTicks, + EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks, + PendingEndBound: HistoryBounds.Carry(endBound, [startBound]))); + results[handle.Index] = new SdkHistoryReadResult + { + StatusCode = StatusCodes.Good, + HistoryData = new ExtensionObject(ToHistoryDataFromSamples([startBound])), + ContinuationPoint = boundOnlyCp, + }; + errors[handle.Index] = ServiceResult.Good; + return; + } + + if (startBound is not null && numValuesPerNode > 0) readCap = numValuesPerNode - 1; } // HistoryRead is NOT under the node-manager Lock — awaiting the async source is safe. @@ -6272,8 +6361,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (startUtc > endUtc && !source.SupportsReverseRaw) { var page = await ReverseRawReader.ReadAsync( - source, tagname, startUtc, endUtc, numValuesPerNode, MaxTieClusterOverfetch, - Clock.GetUtcNow().UtcDateTime, walkHint, ct) + source, tagname, startUtc, endUtc, readCap, MaxTieClusterOverfetch, + nowUtc, walkHint, ct) .ConfigureAwait(false); walkHint = page.Hint; sourceResult = new HistorianRead(page.Samples, ContinuationPoint: null); @@ -6281,17 +6370,29 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 else { sourceResult = await source - .ReadRawAsync(tagname, startUtc, endUtc, numValuesPerNode, ct) + .ReadRawAsync(tagname, startUtc, endUtc, readCap, ct) .ConfigureAwait(false); } - var backendFull = HistoryPaging.IsFullPage(sourceResult.Samples.Count, numValuesPerNode); + var backendFull = HistoryPaging.IsFullPage(sourceResult.Samples.Count, readCap); // On a resume read, drop the boundary ties already returned on the prior page. var samples = inboundCp is { Length: > 0 } ? HistoryPaging.TrimBoundaryDuplicates(sourceResult.Samples, startUtc, boundarySkip) : sourceResult.Samples; + // #691: a bounded read's data window excludes the end time (a sample there is the end bound). A backend whose + // forward end is inclusive (the gateway's own) returns it as data; dropping it also means the data is drained. + if (endBound is not null) + { + var inside = samples.Where(x => HistoryBounds.InDataWindow(x, endBound, resolution)).ToList(); + if (inside.Count != samples.Count) + { + samples = inside; + backendFull = false; + } + } + // Oversized tie cluster: a resume read whose FULL backend page does not advance past the resume // boundary timestamp `startUtc`. That happens when more samples share `startUtc` than the page // cap — a tie cluster larger than NumValuesPerNode. The fixed-(start,end,cap) backend can only @@ -6355,9 +6456,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { var clusterState = new HistoryContinuationState( tagname, resumeAt, endUtc, nextSkip, numValuesPerNode, SegmentWidthTicks: walkHint.WidthTicks, - EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks); + EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks, + PendingEndBound: HistoryBounds.Carry(endBound, slice)); clusterCp = _historyContinuationStore.Save(session, clusterState); } + else if (endBound is not null) + { + // #691: the data is drained — the end bound ends this page, or is the next one when this one is full. + clusterCp = await AppendEndBoundAsync(slice, startUtc).ConfigureAwait(false); + } results[handle.Index] = new SdkHistoryReadResult { @@ -6371,6 +6478,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // The "full page" test is against the RAW backend count (before trimming): the backend honoured // the cap, so a full backend page ⇒ there may be more even if we trimmed some boundary ties. + // #691: the start bound (first page only) leads the data. + if (startBound is not null) + { + var led = new List(samples.Count + 2) { startBound }; + led.AddRange(samples); + samples = led; + } + var historyData = ToHistoryDataFromSamples(samples); byte[]? outboundCp = null; @@ -6379,11 +6494,21 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 HistoryPaging.ComputeResumeCursor(samples, out var nextStart, out var skip); var nextState = new HistoryContinuationState( tagname, nextStart, endUtc, skip, numValuesPerNode, SegmentWidthTicks: walkHint.WidthTicks, - EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks); + EmptyFromTicks: walkHint.EmptyFromTicks, EmptyToTicks: walkHint.EmptyToTicks, + PendingEndBound: HistoryBounds.Carry(endBound, samples)); // Save may return null (no session on this request) ⇒ degrade to single-shot for this node. // Built AFTER historyData so a failure projecting samples can never orphan a stored cursor. outboundCp = _historyContinuationStore.Save(session, nextState); } + else if (endBound is not null) + { + // #691: the data is drained — the end bound ends this page, or is the next one when this one is full. + var page = new List(samples.Count + 1); + page.AddRange(samples); + outboundCp = await AppendEndBoundAsync(page, startUtc).ConfigureAwait(false); + samples = page; + historyData = ToHistoryDataFromSamples(samples); + } results[handle.Index] = new SdkHistoryReadResult { diff --git a/tests/Drivers/Historian.Conformance/BoundingValueConformance.cs b/tests/Drivers/Historian.Conformance/BoundingValueConformance.cs new file mode 100644 index 00000000..d8394d74 --- /dev/null +++ b/tests/Drivers/Historian.Conformance/BoundingValueConformance.cs @@ -0,0 +1,100 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance; + +/// +/// Gitea #691: the server composes Part 11 bounding values (HistoryRead-Raw ReturnBounds) from exactly these raw +/// reads on a backend that reads newest-first natively (HistoryBounds in the OPC UA server) — so every backend must +/// answer them precisely: "at or before T" = a reverse read of (0, T] capped at one, "at or after T" = a forward read +/// of [T, MaxValue) capped at one, "strictly after T" = the same from T + resolution. A bound is a stored +/// sample of any quality, so a Bad row or an outage marker comes back as the bound. +/// +public abstract class BoundingValueConformance : ConformanceBase +{ + private static readonly CancellationToken Ct = CancellationToken.None; + private static readonly DateTime Floor = new(0, DateTimeKind.Utc); + private static readonly DateTime Ceiling = new(DateTime.MaxValue.Ticks, DateTimeKind.Utc); + + private async Task AtOrBeforeAsync(IHistorianDataSource src, string tag, DateTime t) => + (await src.ReadRawAsync(tag, t, Floor, 1, Ct)).Samples.SingleOrDefault(); + + private async Task AtOrAfterAsync(IHistorianDataSource src, string tag, DateTime t) => + (await src.ReadRawAsync(tag, t, Ceiling, 1, Ct)).Samples.SingleOrDefault(); + + [Fact] + public async Task The_sample_at_or_before_a_time_is_the_latest_one_on_or_before_it() + { + await ConformanceData.SeedAAsync(B); + var src = B.CreateDataSource(); + + (await AtOrBeforeAsync(src, "A", At(10)))!.Value.ShouldBe(20d); // exactly on it + (await AtOrBeforeAsync(src, "A", At(15)))!.Value.ShouldBe(20d); // between samples + (await AtOrBeforeAsync(src, "A", At(1000)))!.Value.ShouldBe(50d); // past the last + (await AtOrBeforeAsync(src, "A", At(-1))).ShouldBeNull(); // before the first: no bound + (await AtOrBeforeAsync(src, "A", At(15)))!.SourceTimestampUtc!.Value.Ticks.ShouldBe(B.Round(At(10)).Ticks); + } + + [Fact] + public async Task The_sample_at_or_after_a_time_is_the_earliest_one_on_or_after_it() + { + await ConformanceData.SeedAAsync(B); + var src = B.CreateDataSource(); + + (await AtOrAfterAsync(src, "A", At(10)))!.Value.ShouldBe(20d); // exactly on it + (await AtOrAfterAsync(src, "A", At(15)))!.Value.ShouldBe(30d); // between samples + (await AtOrAfterAsync(src, "A", At(-100)))!.Value.ShouldBe(10d); // before the first + (await AtOrAfterAsync(src, "A", At(41))).ShouldBeNull(); // past the last: no bound + } + + [Fact] + public async Task The_sample_strictly_after_a_time_starts_one_storage_resolution_later() + { + await ConformanceData.SeedAAsync(B); + var src = B.CreateDataSource(); + + (await AtOrAfterAsync(src, "A", At(10).AddTicks(B.ResolutionTicks)))!.Value.ShouldBe(30d); + (await AtOrAfterAsync(src, "A", At(40).AddTicks(B.ResolutionTicks))).ShouldBeNull(); + } + + [Fact] + public async Task A_time_between_storage_ticks_finds_the_sample_the_backend_stores_at_its_truncation() + { + // The server compares "on the bound" at the storage resolution, so the lookups must agree with the window reads: + // a time a fraction of a resolution step after a stored sample is that sample's instant on a coarse backend. + await ConformanceData.SeedAAsync(B); + var src = B.CreateDataSource(); + var t = At(10).AddTicks(B.ResolutionTicks - 1); // == At(10) on a 1-tick backend + + (await AtOrBeforeAsync(src, "A", t))!.Value.ShouldBe(20d); + var after = await AtOrAfterAsync(src, "A", t); + after!.SourceTimestampUtc!.Value.Ticks.ShouldBeGreaterThanOrEqualTo(B.Round(t).Ticks); + after.Value.ShouldBe(B.Round(t) == B.Round(At(10)) ? 20d : 30d); + } + + [Fact] + public async Task A_bad_sample_or_an_outage_marker_is_a_bound_as_stored() + { + await B.SeedAsync("M", + (At(0), HistorianValue.FromDouble(10), Good), + (At(5), HistorianValue.Empty, BadNoCommunication), + (At(7), HistorianValue.FromDouble(99), Bad)); + var src = B.CreateDataSource(); + + var before = await AtOrBeforeAsync(src, "M", At(6)); + before!.Value.ShouldBeNull(); + before.StatusCode.ShouldBe(0x80310000u); + var after = await AtOrAfterAsync(src, "M", At(6)); + after!.Value.ShouldBe(99d); + after.StatusCode.ShouldBe(0x80000000u); + } + + [Fact] + public async Task An_unknown_tag_has_no_bounds() + { + var src = B.CreateDataSource(); + (await AtOrBeforeAsync(src, "nope", At(10))).ShouldBeNull(); + (await AtOrAfterAsync(src, "nope", At(10))).ShouldBeNull(); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/QuestDbConformance.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/QuestDbConformance.cs index a9987d1a..d84c755e 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/QuestDbConformance.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/QuestDbConformance.cs @@ -67,3 +67,10 @@ public sealed class QuestDbEventReadConformance(QuestDbFixture fx) : EventReadCo /// protected override Task CreateBackendAsync() => QuestDbConformanceBackend.CreateAsync(fx); } + +[Collection(QuestDbCollection.Name)] +public sealed class QuestDbBoundingValueConformance(QuestDbFixture fx) : BoundingValueConformance +{ + /// + protected override Task CreateBackendAsync() => QuestDbConformanceBackend.CreateAsync(fx); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/SqlServerConformance.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/SqlServerConformance.cs index 73b9862f..c08d50ee 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/SqlServerConformance.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/SqlServerConformance.cs @@ -67,3 +67,10 @@ public sealed class SqlServerEventReadConformance(SqlHistorianFixture fx) : Even /// protected override Task CreateBackendAsync() => SqlServerConformanceBackend.CreateAsync(fx); } + +[Collection(SqlHistorianCollection.Name)] +public sealed class SqlServerBoundingValueConformance(SqlHistorianFixture fx) : BoundingValueConformance +{ + /// + protected override Task CreateBackendAsync() => SqlServerConformanceBackend.CreateAsync(fx); +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/HistoryUpdateOverTheWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/HistoryUpdateOverTheWireTests.cs index 16f9ccc4..43fa05e2 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/HistoryUpdateOverTheWireTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/HistoryUpdateOverTheWireTests.cs @@ -97,6 +97,7 @@ public sealed class HistoryUpdateOverTheWireTests var details = new ReadRawModifiedDetails { + ReturnBounds = false, StartTime = T1.AddHours(-1), EndTime = T1.AddHours(1), NumValuesPerNode = 100, IsReadModified = true, }; var response = await session.HistoryReadAsync( diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAclEnforcementTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAclEnforcementTests.cs index 9dedcab8..a81e663d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAclEnforcementTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerAclEnforcementTests.cs @@ -384,6 +384,7 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable context, new ReadRawModifiedDetails { + ReturnBounds = false, StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, NumValuesPerNode = 10, diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs new file mode 100644 index 00000000..ebee310b --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs @@ -0,0 +1,450 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Server; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +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; + +/// Boots ONE real SDK server for the whole bounds suite (the cases swap the historian source per read). +public sealed class BoundsServerFixture : IAsyncLifetime +{ + private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-bounds-{Guid.NewGuid():N}"); + private OpcUaApplicationHost? _host; + + /// The node manager under test. + public OtOpcUaNodeManager Nm { get; private set; } = null!; + + /// The historized variable every case reads. + public NodeId NodeId { get; private set; } = null!; + + /// Serialises the cases (they share the node manager's source). + public SemaphoreSlim Gate { get; } = new(1, 1); + + public async ValueTask InitializeAsync() + { + var started = await OpcUaHostStart.StartAsync( + port => new OpcUaApplicationHost( + new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.BoundsTest", + ApplicationUri = $"urn:OtOpcUa.BoundsTest:{Guid.NewGuid():N}", + OpcUaPort = port, + PublicHostname = "localhost", + PkiStoreRoot = _pkiRoot, + }, + NullLogger.Instance), + () => new OtOpcUaSdkServer(), CancellationToken.None); + _host = started.Host; + Nm = started.Server.NodeManager!; + Nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore(); + Nm.EnsureVariable("eq-1/bounds", parentFolderNodeId: null, displayName: "Bounds", dataType: "Double", + writable: false, historianTagname: "WW.Bounds", realm: AddressSpaceRealm.Uns); + NodeId = Nm.TryGetVariable("eq-1/bounds")!.NodeId; + } + + public async ValueTask DisposeAsync() + { + if (_host is not null) await _host.DisposeAsync(); + if (Directory.Exists(_pkiRoot)) + { + try { Directory.Delete(_pkiRoot, recursive: true); } + catch { /* best-effort */ } + } + } +} + +/// +/// Gitea #691: HistoryRead-Raw ReturnBounds (OPC UA Part 11 §4.6 bounding values, §6.5.3.2). The oracle is Part 11 +/// Table 1 itself — every row, over its data set (samples at 5:00, 5:02, 5:03, 5:05, 5:06) — run against three backend +/// shapes: one that reads newest-first natively (SqlServer-like), one that reads forward only (Gateway-like, walked back +/// by ) and a microsecond one (QuestDB-like, +/// truncating its window bounds). +/// +public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) : IClassFixture +{ + private static readonly DateTime Day = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime Unspecified = DateTime.MinValue; + + private static DateTime T(string hhmm) + { + var parts = hhmm.Split(':'); + return Day.AddHours(int.Parse(parts[0])).AddMinutes(int.Parse(parts[1])); + } + + /// Part 11 Table 1's data: value = minutes past 5:00. + private static DataValueSnapshot[] TableData() => + new[] { "5:00", "5:02", "5:03", "5:05", "5:06" } + .Select(s => new DataValueSnapshot((double)(T(s) - T("5:00")).TotalMinutes, StatusCodes.Good, T(s), T(s))) + .ToArray(); + + public enum Shape { Reverse, ForwardOnly, Microsecond } + + private static IHistorianDataSource Source(Shape shape, IReadOnlyList series) => shape switch + { + Shape.Reverse => new SeriesSource(series, reverse: true, inclusiveForwardEnd: false, resolution: 1), + Shape.ForwardOnly => new SeriesSource(series, reverse: false, inclusiveForwardEnd: false, resolution: 1), + _ => new SeriesSource(series, reverse: true, inclusiveForwardEnd: false, resolution: 10), + }; + + /// + /// Part 11 Table 1, every row, verbatim. "FIRST"/"LAST" are Bad_BoundNotFound placeholders (null value) at the + /// StartTime/EndTime; a/b footnotes: one second after/before the previous returned value when that side is unspecified. + /// + public static TheoryData Table1 => new() + { + { "5:00", "5:05", 0, true, "5:00, 5:02, 5:03, 5:05" }, + { "5:00", "5:05", 0, false, "5:00, 5:02, 5:03" }, + { "5:01", "5:04", 0, true, "5:00, 5:02, 5:03, 5:05" }, + { "5:01", "5:04", 0, false, "5:02, 5:03" }, + { "5:05", "5:00", 0, true, "5:05, 5:03, 5:02, 5:00" }, + { "5:05", "5:00", 0, false, "5:05, 5:03, 5:02" }, + { "5:04", "5:01", 0, true, "5:05, 5:03, 5:02, 5:00" }, + { "5:04", "5:01", 0, false, "5:03, 5:02" }, + { "4:59", "5:05", 0, true, "FIRST, 5:00, 5:02, 5:03, 5:05" }, + { "4:59", "5:05", 0, false, "5:00, 5:02, 5:03" }, + { "5:01", "5:07", 0, true, "5:00, 5:02, 5:03, 5:05, 5:06, LAST" }, + { "5:01", "5:07", 0, false, "5:02, 5:03, 5:05, 5:06" }, + { "5:00", "5:05", 3, true, "5:00, 5:02, 5:03" }, + { "5:00", "5:05", 3, false, "5:00, 5:02, 5:03" }, + { "5:01", "5:04", 3, true, "5:00, 5:02, 5:03" }, + { "5:01", "5:04", 3, false, "5:02, 5:03" }, + { "5:05", "5:00", 3, true, "5:05, 5:03, 5:02" }, + { "5:05", "5:00", 3, false, "5:05, 5:03, 5:02" }, + { "5:04", "5:01", 3, true, "5:05, 5:03, 5:02" }, + { "5:04", "5:01", 3, false, "5:03, 5:02" }, + { "4:59", "5:05", 3, true, "FIRST, 5:00, 5:02" }, + { "4:59", "5:05", 3, false, "5:00, 5:02, 5:03" }, + { "5:01", "5:07", 3, true, "5:00, 5:02, 5:03" }, + { "5:01", "5:07", 3, false, "5:02, 5:03, 5:05" }, + { "5:00", "", 3, true, "5:00, 5:02, 5:03" }, + { "5:00", "", 3, false, "5:00, 5:02, 5:03" }, + { "5:00", "", 6, true, "5:00, 5:02, 5:03, 5:05, 5:06, LAST+" }, + { "5:00", "", 6, false, "5:00, 5:02, 5:03, 5:05, 5:06" }, + { "5:07", "", 6, true, "5:06, LAST+" }, + { "5:07", "", 6, false, "" }, + { "", "5:06", 3, true, "5:06, 5:05, 5:03" }, + { "", "5:06", 3, false, "5:06, 5:05, 5:03" }, + { "", "5:06", 6, true, "5:06, 5:05, 5:03, 5:02, 5:00, FIRST-" }, + { "", "5:06", 6, false, "5:06, 5:05, 5:03, 5:02, 5:00" }, + { "", "4:48", 6, true, "5:00, FIRST-" }, + { "", "4:48", 6, false, "" }, + { "4:48", "4:48", 0, true, "FIRST, 5:00" }, + { "4:48", "4:48", 0, false, "" }, + { "4:48", "4:48", 1, true, "FIRST" }, + { "4:48", "4:48", 1, false, "" }, + { "4:48", "4:48", 2, true, "FIRST, 5:00" }, + { "5:00", "5:00", 0, true, "5:00, 5:02" }, + { "5:00", "5:00", 0, false, "5:00" }, + { "5:00", "5:00", 1, true, "5:00" }, + { "5:00", "5:00", 1, false, "5:00" }, + { "5:01", "5:01", 0, true, "5:00, 5:02" }, + { "5:01", "5:01", 0, false, "" }, + { "5:01", "5:01", 1, true, "5:00" }, + { "5:01", "5:01", 1, false, "" }, + }; + + [Theory] + [MemberData(nameof(Table1))] + public async Task Part11_table1_first_page_on_a_reverse_capable_backend(string start, string end, uint max, bool bounds, string expected) => + await AssertFirstPage(Shape.Reverse, start, end, max, bounds, expected); + + [Theory] + [MemberData(nameof(Table1))] + public async Task Part11_table1_first_page_on_a_forward_only_backend(string start, string end, uint max, bool bounds, string expected) => + await AssertFirstPage(Shape.ForwardOnly, start, end, max, bounds, expected); + + [Theory] + [MemberData(nameof(Table1))] + public async Task Part11_table1_first_page_on_a_microsecond_backend(string start, string end, uint max, bool bounds, string expected) => + await AssertFirstPage(Shape.Microsecond, start, end, max, bounds, expected); + + private async Task AssertFirstPage(Shape shape, string start, string end, uint max, bool bounds, string expected) + { + var s = start.Length == 0 ? Unspecified : T(start); + var e = end.Length == 0 ? Unspecified : T(end); + var source = Source(shape, TableData()); + var (values, error, _) = await ReadAsync(source, s, e, max, bounds, cp: null); + + error.StatusCode.Code.ShouldBe(StatusCodes.Good); + Render(values, s, e).ShouldBe(expected); + (source as SeriesSource)?.ReverseWindowsOnForwardOnly.ShouldBe(0); + } + + /// + /// Part 11: bounds count toward NumValuesPerNode, the start bound comes with the first page and the end bound with the + /// last — so paging a bounded two-sided read at any page size concatenates to the unpaged (N = 0) answer. + /// + [Theory] + [InlineData(Shape.Reverse)] + [InlineData(Shape.ForwardOnly)] + [InlineData(Shape.Microsecond)] + public async Task Paging_a_bounded_read_concatenates_to_the_unpaged_answer(Shape shape) + { + var windows = new[] + { + ("5:00", "5:05"), ("5:01", "5:04"), ("5:05", "5:00"), ("5:04", "5:01"), ("4:59", "5:05"), ("5:01", "5:07"), + ("4:48", "4:48"), ("5:00", "5:00"), ("5:01", "5:01"), ("5:07", "4:00"), ("5:06", "5:06"), + }; + foreach (var (start, end) in windows) + { + var source = Source(shape, TableData()); + var all = await ReadAsync(source, T(start), T(end), 0, true, cp: null); + all.Cp.ShouldBeNull(); + var expected = Render(all.Values, T(start), T(end)); + for (uint n = 1; n <= 6; n++) + { + var paged = await DrainAsync(source, T(start), T(end), n, bounds: true); + Render(paged, T(start), T(end)).ShouldBe(expected, $"{start}->{end} paged by {n}"); + } + } + } + + /// One-sided reads page to the open placeholder exactly once (footnotes a/b), never past it. + [Theory] + [InlineData(Shape.Reverse)] + [InlineData(Shape.ForwardOnly)] + [InlineData(Shape.Microsecond)] + public async Task Paging_a_one_sided_bounded_read_ends_with_the_open_placeholder(Shape shape) + { + for (uint n = 1; n <= 7; n++) + { + var fwd = await DrainAsync(Source(shape, TableData()), T("5:01"), Unspecified, n, bounds: true); + Render(fwd, T("5:01"), Unspecified).ShouldBe("5:00, 5:02, 5:03, 5:05, 5:06, LAST+", $"forward by {n}"); + var rev = await DrainAsync(Source(shape, TableData()), Unspecified, T("5:04"), n, bounds: true); + Render(rev, Unspecified, T("5:04")).ShouldBe("5:05, 5:03, 5:02, 5:00, FIRST-", $"reverse by {n}"); + } + } + + /// A bound is a stored sample of any quality: a Bad sample before StartTime is the start bound, as stored. + [Theory] + [InlineData(Shape.Reverse)] + [InlineData(Shape.ForwardOnly)] + [InlineData(Shape.Microsecond)] + public async Task A_bad_sample_is_returned_as_the_bound_as_stored(Shape shape) + { + var series = TableData(); + series[0] = new DataValueSnapshot(null, StatusCodes.BadNoCommunication, T("5:00"), T("5:00")); + var (values, _, _) = await ReadAsync(Source(shape, series), T("5:01"), T("5:04"), 0, true, cp: null); + + values[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication); + values[0].SourceTimestamp.ShouldBe(T("5:00")); + values.Count.ShouldBe(4); + } + + /// + /// Ties on a bound: the start bound before a forward window is the LAST of a tie cluster (the sample immediately + /// before StartTime in the read's order); the end bound after it is the FIRST of one; and ties ON StartTime are all + /// data (the bound is the first of them, so nothing extra is added). + /// + [Theory] + [InlineData(Shape.Reverse)] + [InlineData(Shape.ForwardOnly)] + [InlineData(Shape.Microsecond)] + public async Task Tie_clusters_on_either_side_yield_one_bound_each(Shape shape) + { + var series = new List(); + void Add(string t, double v) => series.Add(new DataValueSnapshot(v, StatusCodes.Good, T(t), T(t))); + Add("5:00", 1); Add("5:00", 2); Add("5:00", 3); + Add("5:02", 4); + Add("5:05", 5); Add("5:05", 6); + + var (fwd, _, _) = await ReadAsync(Source(shape, series), T("5:01"), T("5:04"), 0, true, cp: null); + fwd.Select(v => (double)v.Value).ShouldBe([3d, 4d, 5d]); + + var (rev, _, _) = await ReadAsync(Source(shape, series), T("5:04"), T("5:01"), 0, true, cp: null); + rev.Select(v => (double)v.Value).ShouldBe([5d, 4d, 3d]); + + var (onStart, _, _) = await ReadAsync(Source(shape, series), T("5:00"), T("5:03"), 0, true, cp: null); + onStart.Select(v => (double)v.Value).ShouldBe([1d, 2d, 3d, 4d, 5d]); + } + + /// + /// QuestDB truncates the window bounds it is sent to the microsecond: a sample at ⌊StartTime⌋ is inside a window that + /// starts a few ticks later, so it is data — never returned a second time as the start bound. + /// + [Fact] + public async Task A_microsecond_backend_does_not_return_a_sample_at_the_truncated_start_twice() + { + var series = TableData(); + var (values, _, _) = await ReadAsync(Source(Shape.Microsecond, series), T("5:00").AddTicks(7), T("5:04"), 0, true, cp: null); + + values.Select(v => (double)v.Value).ShouldBe([0d, 2d, 3d, 5d]); + } + + /// + /// A backend whose forward end is inclusive (the gateway's own ReadRaw), or a resumed read that steps onto + /// EndTime, returns the sample AT EndTime as data — it must come back once, as the end bound. + /// + [Theory] + [InlineData(0u)] + [InlineData(1u)] + [InlineData(2u)] + [InlineData(3u)] + public async Task An_inclusive_forward_end_does_not_return_the_end_bound_twice(uint max) + { + var source = new SeriesSource(TableData(), reverse: false, inclusiveForwardEnd: true, resolution: 1); + var values = await DrainAsync(source, T("5:00"), T("5:05"), max, bounds: true); + Render(values, T("5:00"), T("5:05")).ShouldBe("5:00, 5:02, 5:03, 5:05"); + } + + /// Part 11 §6.5.3.3: a modified read with ReturnBounds is Bad_InvalidArgument. + [Fact] + public async Task A_modified_read_with_return_bounds_is_an_invalid_argument() + { + await fx.Gate.WaitAsync(TestContext.Current.CancellationToken); + try + { + fx.Nm.HistorianDataSource = Source(Shape.Reverse, TableData()); + var (_, error, cp) = Read(T("5:00"), T("5:05"), 0, bounds: true, cp: null, modified: true); + error.StatusCode.Code.ShouldBe(StatusCodes.BadInvalidArgument); + cp.ShouldBeNull(); + } + finally { fx.Gate.Release(); } + } + + // --- helpers -------------------------------------------------------------------------------- + + private static string Render(IReadOnlyList values, DateTime start, DateTime end) + { + var parts = new List(); + for (var i = 0; i < values.Count; i++) + { + var v = values[i]; + if (v.StatusCode.Code != StatusCodes.BadBoundNotFound) + { + v.Value.ShouldNotBeNull(); + parts.Add($"{v.SourceTimestamp.Hour}:{v.SourceTimestamp.Minute:00}"); + continue; + } + + v.Value.ShouldBeNull("a missing bound carries a null value"); + var isFirst = i == 0 && start != Unspecified && v.SourceTimestamp == start; + var isLast = end != Unspecified && v.SourceTimestamp == end; + if (isFirst) { parts.Add("FIRST"); continue; } + if (isLast) { parts.Add("LAST"); continue; } + // Open side (footnotes a/b): one second from the previous returned value. + i.ShouldBeGreaterThan(0); + var prev = values[i - 1].SourceTimestamp; + if (v.SourceTimestamp == prev.AddSeconds(1)) parts.Add("LAST+"); + else if (v.SourceTimestamp == prev.AddSeconds(-1)) parts.Add("FIRST-"); + else parts.Add($"?{v.SourceTimestamp:O}"); + } + + return string.Join(", ", parts); + } + + private async Task> DrainAsync(IHistorianDataSource source, DateTime start, DateTime end, uint max, bool bounds) + { + var all = new List(); + byte[]? cp = null; + var pages = 0; + do + { + var (values, error, next) = await ReadAsync(source, start, end, max, bounds, cp); + error.StatusCode.Code.ShouldBe(StatusCodes.Good); + if (max > 0) values.Count.ShouldBeLessThanOrEqualTo((int)max, "bounds count toward NumValuesPerNode"); + all.AddRange(values); + cp = next; + (++pages).ShouldBeLessThan(50, "paging must terminate"); + } + while (cp is not null); + + return all; + } + + private async Task<(IReadOnlyList Values, ServiceResult Error, byte[]? Cp)> ReadAsync( + IHistorianDataSource source, DateTime start, DateTime end, uint max, bool bounds, byte[]? cp) + { + await fx.Gate.WaitAsync(TestContext.Current.CancellationToken); + try + { + fx.Nm.HistorianDataSource = source; + return Read(start, end, max, bounds, cp, modified: false); + } + finally { fx.Gate.Release(); } + } + + private (IReadOnlyList Values, ServiceResult Error, byte[]? Cp) Read( + DateTime start, DateTime end, uint max, bool bounds, byte[]? cp, bool modified) + { + 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 = modified, + }; + 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. : serves start > end as (end, start] + /// newest-first (SqlServer/QuestDB); otherwise rejects it (the Gateway) and counts that. The forward read is + /// [start, end), or [start, end] when (the gateway's own end); + /// a point read is the instant. > 1 truncates every window bound to it first (QuestDB + /// µs); the series must already be stored at that resolution. + /// + private sealed class SeriesSource(IReadOnlyList series, bool reverse, bool inclusiveForwardEnd, long resolution) + : IHistorianDataSource + { + public int ReverseWindowsOnForwardOnly; + + public bool SupportsReverseRaw => reverse; + + public long TimestampResolutionTicks => resolution; + + private DateTime Trunc(DateTime t) => new(t.Ticks - t.Ticks % resolution, DateTimeKind.Utc); + + public Task ReadRawAsync( + string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, CancellationToken cancellationToken) + { + static DateTime Ts(DataValueSnapshot s) => s.SourceTimestampUtc ?? DateTime.MinValue; + startUtc = Trunc(startUtc); + endUtc = Trunc(endUtc); + List window; + if (startUtc > endUtc) + { + if (!reverse) + { + Interlocked.Increment(ref ReverseWindowsOnForwardOnly); + throw new InvalidOperationException("start_time must be less than or equal to end_time. (InvalidArgument)"); + } + + 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 && (inclusiveForwardEnd ? Ts(s) <= endUtc : Ts(s) < endUtc)).ToList(); + + 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() { } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadConcurrencyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadConcurrencyTests.cs index 93153acb..22a64e41 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadConcurrencyTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadConcurrencyTests.cs @@ -45,6 +45,7 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable var details = new ReadRawModifiedDetails { + ReturnBounds = false, StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, NumValuesPerNode = 10, @@ -78,6 +79,7 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable var nodeIds = MaterializeHistorizedNodes(nm, count: 12); var details = new ReadRawModifiedDetails { + ReturnBounds = false, StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, NumValuesPerNode = 10, @@ -115,6 +117,7 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable var nodeIds = MaterializeHistorizedNodes(nm, count: 8); var details = new ReadRawModifiedDetails { + ReturnBounds = false, StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, NumValuesPerNode = 10, @@ -157,6 +160,7 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable var nodeIds = MaterializeHistorizedNodes(nm, count: 4); var details = new ReadRawModifiedDetails { + ReturnBounds = false, StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, NumValuesPerNode = 10, @@ -190,6 +194,7 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable var ids = MaterializeHistorizedNodes(nm, count: 2); var details = new ReadRawModifiedDetails { + ReturnBounds = false, StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, NumValuesPerNode = 10, diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsPagingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsPagingTests.cs index dc0ecf85..7d4ec80f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsPagingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsPagingTests.cs @@ -160,7 +160,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable var ctx = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null); var res = new List { null! }; var errs = new List { null! }; - nm.HistoryRead(ctx, new ReadRawModifiedDetails { StartTime = Epoch, EndTime = Epoch.AddHours(1), NumValuesPerNode = 2, IsReadModified = false }, + nm.HistoryRead(ctx, new ReadRawModifiedDetails { ReturnBounds = false, StartTime = Epoch, EndTime = Epoch.AddHours(1), NumValuesPerNode = 2, IsReadModified = false }, TimestampsToReturn.Both, false, new List { new() { NodeId = n, ContinuationPoint = cp } }, res, errs); err = errs[0]; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadModifiedTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadModifiedTests.cs index 164638c1..48b10136 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadModifiedTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadModifiedTests.cs @@ -271,7 +271,7 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable var results = new List { null! }; var errors = new List { null! }; nm.HistoryRead(context, - new ReadRawModifiedDetails { StartTime = start, EndTime = end, NumValuesPerNode = max, IsReadModified = modified }, + new ReadRawModifiedDetails { ReturnBounds = false, StartTime = start, EndTime = end, NumValuesPerNode = max, IsReadModified = modified }, TimestampsToReturn.Both, false, new List { new() { NodeId = node, ContinuationPoint = cp } }, results, errors); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs index 747ac185..b881d737 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs @@ -967,6 +967,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable var details = new ReadRawModifiedDetails { + ReturnBounds = false, StartTime = start, EndTime = end, NumValuesPerNode = max, @@ -1000,6 +1001,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable 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 } }; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadTests.cs index e8a231ed..a3c8a266 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadTests.cs @@ -52,6 +52,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable var end = DateTime.UtcNow; var details = new ReadRawModifiedDetails { + ReturnBounds = false, StartTime = start, EndTime = end, NumValuesPerNode = 100, @@ -97,6 +98,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable var details = new ReadRawModifiedDetails { + ReturnBounds = false, StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, NumValuesPerNode = 10, @@ -129,6 +131,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable var details = new ReadRawModifiedDetails { + ReturnBounds = false, StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, NumValuesPerNode = 10, @@ -162,6 +165,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable var details = new ReadRawModifiedDetails { + ReturnBounds = false, StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, NumValuesPerNode = 10, @@ -195,6 +199,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable var details = new ReadRawModifiedDetails { + ReturnBounds = false, StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, NumValuesPerNode = 10, @@ -613,6 +618,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable var details = new ReadRawModifiedDetails { + ReturnBounds = false, StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, NumValuesPerNode = 10, @@ -658,6 +664,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable var details = new ReadRawModifiedDetails { + ReturnBounds = false, StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, NumValuesPerNode = 10,