diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index a40e1957..e9a9a123 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -5174,7 +5174,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // to an open upper bound, or the cursor would store MinValue and a backend would swap the reversed // resume window. A resumed read is always forward (the cursor is only ever stored for a forward read), // so its direction comes from the cursor, not from the resume request's details. - if (!resumed && endUtc == DateTime.MinValue && startUtc != DateTime.MinValue) endUtc = DateTime.MaxValue; + if (!resumed && endUtc == DateTime.MinValue && startUtc != DateTime.MinValue) + endUtc = new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc); // Utc kind: a proto Timestamp conversion rejects Unspecified // Reverse event reads (start > end) are not paged: the cursor below is forward-only. var forward = resumed || startUtc <= endUtc; @@ -5966,6 +5967,17 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 tagname = resolved; startUtc = startTimeUtc; + + // Part 11: with NumValuesPerNode > 0 one bound may be left unset. Only a StartTime ⇒ page FORWARD from + // it to an open upper bound; only an EndTime ⇒ page BACKWARD from it (a reverse read, start > end). + // Without this an unset EndTime (MinValue) looked like a reverse read to a lower bound of "now". + if (numValuesPerNode > 0) + { + if (endUtc == DateTime.MinValue && startUtc != DateTime.MinValue) + endUtc = new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc); + else if (startUtc == DateTime.MinValue && endUtc != DateTime.MinValue) + (startUtc, endUtc) = (endUtc, new DateTime(0, DateTimeKind.Utc)); + } } // HistoryRead is NOT under the node-manager Lock — awaiting the async source is safe. diff --git a/tests/Drivers/Historian.Conformance/RawReadConformance.cs b/tests/Drivers/Historian.Conformance/RawReadConformance.cs index 07380824..41282291 100644 --- a/tests/Drivers/Historian.Conformance/RawReadConformance.cs +++ b/tests/Drivers/Historian.Conformance/RawReadConformance.cs @@ -40,6 +40,22 @@ public abstract class RawReadConformance : ConformanceBase Values(await B.CreateDataSource().ReadRawAsync("A", At(-1), At(100), uint.MaxValue, Ct)).Length.ShouldBe(5); } + /// + /// #669: the node manager now pages an open-ended read (Part 11: only a StartTime / only an EndTime) with + /// DateTime.MaxValue / DateTime.MinValue as the missing bound, so every backend must bind both + /// extremes without overflow, ±infinity or a cast error. + /// + [Fact] + public async Task Extreme_bounds_MaxValue_and_MinValue_are_safe() + { + await ConformanceData.SeedAAsync(B); + var max = new DateTime(DateTime.MaxValue.Ticks, DateTimeKind.Utc); + var min = new DateTime(0, DateTimeKind.Utc); + Values(await B.CreateDataSource().ReadRawAsync("A", At(10), max, 2, Ct)).ShouldBe([20d, 30d]); + Values(await B.CreateDataSource().ReadRawAsync("A", At(100), min, 2, Ct)).ShouldBe([50d, 40d]); + Values(await B.CreateDataSource().ReadRawAsync("A", At(10), max, 0, Ct)).ShouldBe([20d, 30d, 40d, 50d]); + } + [Fact] public async Task Reverse_read_is_descending_over_end_exclusive_start_inclusive() { 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 80785796..43ba469f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs @@ -73,6 +73,65 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable await host.DisposeAsync(); } + /// Part 11: only a StartTime (EndTime unset = MinValue) with a cap pages FORWARD to an open upper bound — + /// it must not be mistaken for a reverse read, and the stored cursor must not carry MinValue as its end. + [Fact] + public async Task Raw_open_ended_forward_read_pages_ascending_exactly_once() + { + var (host, server) = await BootAsync(); + var nm = server.NodeManager!; + nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore(); + nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1)); + nm.EnsureVariable("eq-1/open", parentFolderNodeId: null, displayName: "Open", dataType: "Double", + writable: false, historianTagname: "WW.Open", realm: AddressSpaceRealm.Uns); + var nodeId = nm.TryGetVariable("eq-1/open")!.NodeId; + + var collected = new List(); + byte[]? cp = null; + var pages = 0; + do + { + var (values, error, next) = ReadRaw(nm, nodeId, Epoch, DateTime.MinValue, max: 100, inboundCp: cp); + error.StatusCode.Code.ShouldBe(StatusCodes.Good); + collected.AddRange(values); + cp = next; + (++pages).ShouldBeLessThan(20); + } + while (cp is not null); + + collected.ShouldBe(Enumerable.Range(0, 250).Select(i => (double)i)); + await host.DisposeAsync(); + } + + /// Part 11: only an EndTime (StartTime unset = MinValue) with a cap reads BACKWARD from the EndTime. + [Fact] + public async Task Raw_end_only_read_pages_descending_from_the_end_time() + { + var (host, server) = await BootAsync(); + var nm = server.NodeManager!; + nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore(); + nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(count: 250, stepSeconds: 1)); + nm.EnsureVariable("eq-1/endonly", parentFolderNodeId: null, displayName: "EndOnly", dataType: "Double", + writable: false, historianTagname: "WW.EndOnly", realm: AddressSpaceRealm.Uns); + var nodeId = nm.TryGetVariable("eq-1/endonly")!.NodeId; + + var collected = new List(); + byte[]? cp = null; + var pages = 0; + do + { + var (values, error, next) = ReadRaw(nm, nodeId, DateTime.MinValue, Epoch.AddHours(1), max: 100, inboundCp: cp); + error.StatusCode.Code.ShouldBe(StatusCodes.Good); + collected.AddRange(values); + cp = next; + (++pages).ShouldBeLessThan(20); + } + while (cp is not null); + + collected.ShouldBe(Enumerable.Range(0, 250).Reverse().Select(i => (double)i)); + await host.DisposeAsync(); + } + /// An exactly-full series whose total is a multiple of the cap pages to a trailing /// EMPTY page (GoodNoData, no CP) — the server can't know the prior page was the last until it reads /// past the end.