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.