diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs
index 9942954e..beb0dea2 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs
@@ -107,4 +107,19 @@ public interface IHistorianDataSource : IDisposable
///
/// The current health snapshot for the historian backend.
HistorianHealthSnapshot GetHealthSnapshot();
+
+ ///
+ /// The backend's storage timestamp resolution, in ticks (100 ns): the smallest step
+ /// between two distinct stored timestamps. 1 (the default) means full tick precision; QuestDB stores
+ /// microseconds and reports 10.
+ ///
+ ///
+ /// The server's HistoryRead-Raw paging steps OFF a drained timestamp by this amount (forward
+ /// T + resolution, reverse T − resolution). Stepping by less than the resolution is not
+ /// progress on a backend that truncates its inputs: T + 1 tick is truncated back to T, the
+ /// same sample comes back, and a one-value-per-page forward read loops forever. Stepping by the resolution is
+ /// still lossless, because no stored timestamp can lie strictly between T and T ± resolution.
+ /// Same-timestamp ties are unaffected (they are paged within the timestamp, never stepped over).
+ ///
+ long TimestampResolutionTicks => 1;
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs
index f4b97539..b1d92d01 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs
@@ -19,8 +19,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb;
///
/// Timestamps (R6). Every inbound time is normalised with and every
/// is bound only through . Read bounds are not
-/// clamped to the epoch (a pre-1970 bound is valid); only MinValue/MaxValue are nudged. Results
-/// carry the rounded times, so an AtTime result is stamped at the rounded request time.
+/// clamped to the epoch (a pre-1970 bound is valid); only MinValue/MaxValue are nudged. Raw and
+/// Processed results carry stored (microsecond) times. An AtTime result is evaluated at the rounded request time
+/// but stamped with the client's ORIGINAL requested time (as the SQL Server backend does), so a client can match
+/// each answer to its request.
///
///
/// Reads never create tags (R7). A tag resolves only through ;
@@ -44,6 +46,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb;
///
public sealed class QuestDbHistorianDataSource : IHistorianDataSource
{
+ ///
+ /// QuestDB stores microseconds: 10 ticks. HistoryRead paging steps off a drained timestamp by this much.
+ public long TimestampResolutionTicks => 10;
+
/// Most AtTime request times per statement (two UNION ALL branches each; QuestDB 10.0.1 refuses more than 126 branches).
public const int AtTimeChunkSize = 50;
@@ -139,8 +145,9 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
/// LIMIT 1 seeks, UNION ALL-ed, at most times per statement (spec §3,
/// T0 Q9). then decides each value exactly as for SQL Server (exact hit,
/// no previous ⇒ BadNoData, inside an outage, held before an outage, linear, stepped, after now). The
- /// result matches the request in length and order (duplicates included), each entry stamped at its rounded
- /// time. Unknown tag ⇒ every entry BadNoData.
+ /// result matches the request in length and order (duplicates included). Each entry is evaluated at its rounded
+ /// time but stamped with the requested time itself (UTC-normalised as does,
+ /// not truncated), matching the SQL Server backend. Unknown tag ⇒ every entry BadNoData.
///
public async Task ReadAtTimeAsync(
string fullReference, IReadOnlyList timestampsUtc, CancellationToken cancellationToken)
@@ -197,10 +204,11 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
var result = new DataValueSnapshot[rounded.Length];
for (var i = 0; i < result.Length; i++)
{
- var t = rounded[i];
- result[i] = byTicks.TryGetValue(t.Ticks, out var v)
- ? v
- : new DataValueSnapshot(null, HistorianStatusCodes.BadNoData, t, now);
+ // Seek/evaluate at the rounded time; stamp the client's own requested time (M3).
+ var requested = AsUtc(timestampsUtc[i]);
+ result[i] = byTicks.TryGetValue(rounded[i].Ticks, out var v)
+ ? v with { SourceTimestampUtc = requested }
+ : new DataValueSnapshot(null, HistorianStatusCodes.BadNoData, requested, now);
}
RecordSuccess();
@@ -213,6 +221,14 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
}
}
+ /// The UTC normalisation of without its truncation or nudging.
+ private static DateTime AsUtc(DateTime t) => t.Kind switch
+ {
+ DateTimeKind.Local => t.ToUniversalTime(),
+ DateTimeKind.Utc => t,
+ _ => DateTime.SpecifyKind(t, DateTimeKind.Utc),
+ };
+
///
/// The T0 Q9 statement for request times: $1 = tag, $(k+2) = time k.
/// Columns: req, dir (0 = previous-or-equal, 1 = next), then ts, kind, num_value, int_value, str_value,
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs
index d1ffae37..6fa8c12b 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/HistoryPaging.cs
@@ -212,25 +212,34 @@ internal static class HistoryPaging
/// after (the reverse window excludes end; a resume at exactly end
/// would turn into a start == end point read and return the excluded sample). Forward is unchanged.
///
+ ///
+ /// The backend's storage timestamp resolution (IHistorianDataSource.TimestampResolutionTicks, >= 1). A
+ /// drained cluster steps off by this many ticks rather than by one: a backend that
+ /// truncates its inputs to its resolution (QuestDB, 10 ticks) would otherwise truncate T + 1 tick back
+ /// to T and return the drained sample again, forever. Lossless for the same reason one tick is at tick
+ /// precision: no stored timestamp lies strictly between T and T ± resolution. The default
+ /// 1 keeps the tick-precision behaviour exactly.
+ ///
public static void SliceTieCluster(
int clusterCount, int skip, uint cap, DateTime boundaryT, DateTime endUtc,
out int sliceStart, out int sliceCount, out DateTime? nextStartUtc, out int nextSkip,
- bool reverse = false)
+ bool reverse = false, long resolutionTicks = 1)
{
+ ArgumentOutOfRangeException.ThrowIfLessThan(resolutionTicks, 1L);
sliceStart = Math.Min(skip, clusterCount);
sliceCount = Math.Min((int)cap, clusterCount - sliceStart);
var emitted = sliceStart + sliceCount;
if (emitted < clusterCount) { nextStartUtc = boundaryT; nextSkip = emitted; }
else if (reverse)
{
- // DateTime.MinValue cannot step down; nothing can lie below it anyway.
- var more = boundaryT > DateTime.MinValue && boundaryT.AddTicks(-1) > endUtc;
- nextStartUtc = more ? boundaryT.AddTicks(-1) : null;
+ // Nothing can lie below DateTime.MinValue, so a boundary within one resolution step of it cannot step down.
+ var more = boundaryT.Ticks >= resolutionTicks && boundaryT.AddTicks(-resolutionTicks) > endUtc;
+ nextStartUtc = more ? boundaryT.AddTicks(-resolutionTicks) : null;
nextSkip = 0;
}
else
{
- var next = boundaryT.AddTicks(1);
+ var next = boundaryT.AddTicks(resolutionTicks);
if (next <= endUtc) { nextStartUtc = next; nextSkip = 0; }
else { nextStartUtc = null; nextSkip = 0; }
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index 57f249c2..859b69b2 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -5931,7 +5931,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
HistoryPaging.SliceTieCluster(
cluster.Count, boundarySkip, numValuesPerNode, startUtc, endUtc,
out var sliceStart, out var sliceCount, out var nextStartUtc, out var nextSkip,
- reverse: reverse);
+ reverse: reverse, resolutionTicks: Math.Max(1L, HistorianDataSource.TimestampResolutionTicks));
var slice = new List(sliceCount);
for (var i = sliceStart; i < sliceStart + sliceCount; i++) slice.Add(cluster[i]);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/AtTimeRegressionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/AtTimeRegressionTests.cs
new file mode 100644
index 00000000..6f5a40fa
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/AtTimeRegressionTests.cs
@@ -0,0 +1,85 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
+using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests;
+
+///
+/// QuestDB AtTime regressions found in the T5 review (M1, M3), and the data source's reported timestamp
+/// resolution (the HistoryRead paging step, C1).
+///
+[Collection(QuestDbCollection.Name)]
+public sealed class AtTimeRegressionTests(QuestDbFixture fx) : IAsyncLifetime
+{
+ private static readonly CancellationToken Ct = CancellationToken.None;
+ private const uint GoodInterpolated = 0x00000402u;
+
+ private QuestDbConformanceBackend _b = null!;
+
+ private static DateTime At(double s) => ConformanceData.At(s);
+
+ public async ValueTask InitializeAsync() => _b = await QuestDbConformanceBackend.CreateConcreteAsync(fx);
+
+ public async ValueTask DisposeAsync()
+ {
+ if (_b is not null) await _b.DisposeAsync();
+ }
+
+ ///
+ /// M3: a request time with a sub-microsecond remainder is answered stamped with THAT time (as SQL Server does),
+ /// not with the rounded time the seek used. The value is still the one at the rounded time.
+ ///
+ [Fact]
+ public async Task AtTime_result_is_stamped_with_the_original_sub_microsecond_request_time()
+ {
+ await ConformanceData.SeedAAsync(_b); // +0=10, +10=20, ...
+ var requested = _b.Round(At(5)).AddTicks(3); // 0.3 µs past a microsecond (T0 itself is not µs-aligned)
+ var unspecified = DateTime.SpecifyKind(_b.Round(At(7)).AddTicks(7), DateTimeKind.Unspecified);
+
+ var s = (await _b.CreateDataSource().ReadAtTimeAsync("A", [requested, unspecified], Ct)).Samples;
+
+ s[0].SourceTimestampUtc.ShouldBe(requested);
+ s[0].SourceTimestampUtc!.Value.Kind.ShouldBe(DateTimeKind.Utc);
+ s[0].Value.ShouldBe(15d); // evaluated at the rounded time Round(At(5))
+ s[0].StatusCode.ShouldBe(GoodInterpolated);
+ s[1].SourceTimestampUtc.ShouldBe(DateTime.SpecifyKind(unspecified, DateTimeKind.Utc));
+ s[1].SourceTimestampUtc!.Value.Ticks.ShouldBe(unspecified.Ticks);
+ }
+
+ /// M3 on the unknown-tag path: every BadNoData entry carries its own requested time.
+ [Fact]
+ public async Task AtTime_unknown_tag_entries_are_stamped_with_the_original_request_time()
+ {
+ var requested = _b.Round(At(5)).AddTicks(3);
+ var s = (await _b.CreateDataSource().ReadAtTimeAsync("nope", [requested], Ct)).Samples;
+ s.Single().StatusCode.ShouldBe(HistorianStatusCodes.BadNoData);
+ s.Single().SourceTimestampUtc.ShouldBe(requested);
+ }
+
+ ///
+ /// M1: the NEXT seek skips a tombstone and finds the next live row. With +10 deleted, At(5) lies between the
+ /// live +0 (10) and the live +20 (30), so it interpolates to 15, Good. Seeing the tombstone as "next" would
+ /// instead hold 10 as Uncertain (an Empty BadNoData neighbour is not usable).
+ ///
+ [Fact]
+ public async Task AtTime_next_seek_skips_a_tombstone_and_finds_the_next_live_row()
+ {
+ await _b.SeedAsync("M",
+ (At(0), HistorianValue.FromDouble(10), ConformanceData.Good),
+ (At(10), HistorianValue.FromDouble(20), ConformanceData.Good),
+ (At(20), HistorianValue.FromDouble(30), ConformanceData.Good));
+ await _b.InsertRowsAsync("M", [(At(10), HistorianValue.Empty, HistorianStatusCodes.BadNoData, true)]);
+
+ var s = (await _b.CreateDataSource().ReadAtTimeAsync("M", [At(5)], Ct)).Samples.Single();
+
+ s.Value.ShouldBe(15d); // 10 + (30 - 10) * 5/20
+ s.StatusCode.ShouldBe(GoodInterpolated);
+ }
+
+ /// C1: the data source reports QuestDB's microsecond storage resolution to the paging code.
+ [Fact]
+ public void Data_source_reports_a_ten_tick_timestamp_resolution() =>
+ ((IHistorianDataSource)_b.CreateDataSource()).TimestampResolutionTicks.ShouldBe(10);
+}
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
new file mode 100644
index 00000000..63477891
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/QuestDbConformance.cs
@@ -0,0 +1,55 @@
+using ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests;
+
+// One concrete subclass per conformance class: the suite's tests run here against QuestDB.
+
+[Collection(QuestDbCollection.Name)]
+public sealed class QuestDbRawReadConformance(QuestDbFixture fx) : RawReadConformance
+{
+ ///
+ protected override Task CreateBackendAsync() => QuestDbConformanceBackend.CreateAsync(fx);
+}
+
+[Collection(QuestDbCollection.Name)]
+public sealed class QuestDbAtTimeConformance(QuestDbFixture fx) : AtTimeConformance
+{
+ ///
+ protected override Task CreateBackendAsync() => QuestDbConformanceBackend.CreateAsync(fx);
+}
+
+[Collection(QuestDbCollection.Name)]
+public sealed class QuestDbProcessedConformance(QuestDbFixture fx) : ProcessedConformance
+{
+ ///
+ protected override Task CreateBackendAsync() => QuestDbConformanceBackend.CreateAsync(fx);
+}
+
+[Collection(QuestDbCollection.Name)]
+public sealed class QuestDbQualityContractConformance(QuestDbFixture fx) : QualityContractConformance
+{
+ ///
+ protected override Task CreateBackendAsync() => QuestDbConformanceBackend.CreateAsync(fx);
+}
+
+[Collection(QuestDbCollection.Name)]
+public sealed class QuestDbLiveWriteConformance(QuestDbFixture fx) : LiveWriteConformance
+{
+ ///
+ protected override Task CreateBackendAsync() => QuestDbConformanceBackend.CreateAsync(fx);
+}
+
+[Collection(QuestDbCollection.Name)]
+public sealed class QuestDbUpdateConformance(QuestDbFixture fx) : UpdateConformance
+{
+ ///
+ protected override Task CreateBackendAsync() => QuestDbConformanceBackend.CreateAsync(fx);
+}
+
+[Collection(QuestDbCollection.Name)]
+public sealed class QuestDbTagNameConformance(QuestDbFixture fx) : TagNameConformance
+{
+ ///
+ protected override Task CreateBackendAsync() => QuestDbConformanceBackend.CreateAsync(fx);
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/QuestDbConformanceBackend.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/QuestDbConformanceBackend.cs
index 5dfc6f4c..bf863363 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/QuestDbConformanceBackend.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/QuestDbConformanceBackend.cs
@@ -76,14 +76,13 @@ internal sealed class QuestDbConformanceBackend : IConformanceBackend
new QuestDbHistorianDataSource(Conn, Boot, NewCatalog(), timeProvider: clock);
///
- /// TODO(T7): build the T4 QuestDbHistorianValueWriter here once T4 is merged.
+ /// The T4 live-value writer over a fresh catalog (fresh positive cache) and this instance's WAL wait.
public IHistorianValueWriter CreateValueWriter(Func? utcNow = null) =>
- throw new NotSupportedException("TODO(T7): the QuestDB value writer (T4) is not merged into this branch yet.");
+ new QuestDbHistorianValueWriter(Conn, NewCatalog(), Wal, utcNow: utcNow);
///
- /// TODO(T7): build the T6 QuestDbHistorianDataUpdater here once T6 is merged.
- public IHistorianDataUpdater CreateUpdater() =>
- throw new NotSupportedException("TODO(T7): the QuestDB history updater (T6) is not merged into this branch yet.");
+ /// The T6 soft-delete updater over a fresh catalog and this instance's WAL wait.
+ public IHistorianDataUpdater CreateUpdater() => new QuestDbHistorianDataUpdater(Conn, NewCatalog(), Wal);
///
///
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/HistoryPagingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/HistoryPagingTests.cs
index b97d859a..aeab3c5e 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/HistoryPagingTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/HistoryPagingTests.cs
@@ -345,6 +345,51 @@ public sealed class HistoryPagingTests
store.TryTake(null, cp3).ShouldNotBeNull(); // retained
}
+ [Fact]
+ public void SliceTieCluster_forward_drain_steps_by_the_backend_resolution()
+ {
+ // A microsecond backend (QuestDB, 10 ticks) truncates T + 1 tick back to T, so the drained cluster must be
+ // stepped off by the resolution, not by one tick, or the same sample comes back forever.
+ var t = new DateTime(2026, 1, 1, 0, 0, 5, DateTimeKind.Utc);
+
+ HistoryPaging.SliceTieCluster(
+ clusterCount: 1, skip: 1, cap: 1, boundaryT: t, endUtc: t.AddHours(1),
+ out _, out var sliceCount, out var nextStartUtc, out var nextSkip, resolutionTicks: 10);
+
+ sliceCount.ShouldBe(0);
+ nextStartUtc.ShouldBe(t.AddTicks(10));
+ nextSkip.ShouldBe(0);
+ }
+
+ [Fact]
+ public void SliceTieCluster_reverse_drain_steps_down_by_the_backend_resolution()
+ {
+ var t = new DateTime(2026, 1, 1, 0, 0, 5, DateTimeKind.Utc);
+
+ HistoryPaging.SliceTieCluster(
+ clusterCount: 1, skip: 1, cap: 1, boundaryT: t, endUtc: t.AddHours(-1),
+ out _, out _, out var nextStartUtc, out _, reverse: true, resolutionTicks: 10);
+
+ nextStartUtc.ShouldBe(t.AddTicks(-10));
+ }
+
+ [Fact]
+ public void SliceTieCluster_forward_drain_terminates_when_one_resolution_step_passes_the_end()
+ {
+ var t = new DateTime(2026, 1, 1, 0, 0, 5, DateTimeKind.Utc);
+
+ HistoryPaging.SliceTieCluster(
+ clusterCount: 1, skip: 1, cap: 1, boundaryT: t, endUtc: t.AddTicks(9),
+ out _, out _, out var nextStartUtc, out _, resolutionTicks: 10);
+
+ nextStartUtc.ShouldBeNull();
+ }
+
+ [Fact]
+ public void SliceTieCluster_rejects_a_resolution_below_one() =>
+ Should.Throw(() => HistoryPaging.SliceTieCluster(
+ 1, 1, 1, DateTime.UtcNow, DateTime.UtcNow.AddHours(1), out _, out _, out _, out _, resolutionTicks: 0));
+
private static HistoryContinuationState RawState() => new(
"WW.Tag", DateTime.UtcNow, DateTime.UtcNow.AddHours(1),
BoundarySkipCount: 1, NumValuesPerNode: 50);
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 b9388b55..80785796 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadPagingTests.cs
@@ -357,6 +357,56 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
await host.DisposeAsync();
}
+ ///
+ /// Forward paging at NumValuesPerNode = 1 over a MICROSECOND backend (QuestDB: it truncates every inbound
+ /// time to 10 ticks and reports TimestampResolutionTicks = 10) returns every sample exactly once and
+ /// terminates. Before the fix the drained one-sample "cluster" resumed at T + 1 tick, which the backend
+ /// truncated back to T: the same sample came back with no boundary skip, and the read looped forever.
+ ///
+ [Fact]
+ public async Task Raw_forward_read_with_one_value_per_page_on_a_microsecond_backend_returns_every_sample_once()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+ nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore();
+ // Microsecond-spaced samples, so a one-tick step would land strictly between two of them.
+ var series = Enumerable.Range(0, 6)
+ .Select(i => { var t = Epoch.AddTicks(10L * i); return new DataValueSnapshot((double)i, StatusCodes.Good, t, t); })
+ .ToArray();
+ nm.HistorianDataSource = new MicrosecondHistorianDataSource(series);
+
+ nm.EnsureVariable("eq-1/us", parentFolderNodeId: null, displayName: "Us", dataType: "Double",
+ writable: false, historianTagname: "WW.Us", realm: AddressSpaceRealm.Uns);
+ var nodeId = nm.TryGetVariable("eq-1/us")!.NodeId;
+
+ var collected = await DrainAsync(nm, nodeId, start: Epoch, end: Epoch.AddHours(1), max: 1);
+
+ collected.ShouldBe([0d, 1d, 2d, 3d, 4d, 5d]);
+ await host.DisposeAsync();
+ }
+
+ /// The reverse mirror on the same microsecond backend: newest first, each sample once.
+ [Fact]
+ public async Task Raw_reverse_read_with_one_value_per_page_on_a_microsecond_backend_returns_every_sample_once()
+ {
+ var (host, server) = await BootAsync();
+ var nm = server.NodeManager!;
+ nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore();
+ var series = Enumerable.Range(0, 6)
+ .Select(i => { var t = Epoch.AddTicks(10L * (i + 1)); return new DataValueSnapshot((double)i, StatusCodes.Good, t, t); })
+ .ToArray();
+ nm.HistorianDataSource = new MicrosecondHistorianDataSource(series);
+
+ nm.EnsureVariable("eq-1/usrev", parentFolderNodeId: null, displayName: "UsRev", dataType: "Double",
+ writable: false, historianTagname: "WW.UsRev", realm: AddressSpaceRealm.Uns);
+ var nodeId = nm.TryGetVariable("eq-1/usrev")!.NodeId;
+
+ var collected = await DrainAsync(nm, nodeId, start: Epoch.AddHours(1), end: Epoch, max: 1);
+
+ collected.ShouldBe([5d, 4d, 3d, 2d, 1d, 0d]);
+ await host.DisposeAsync();
+ }
+
/// Pages a Raw read to completion and returns the union of the pages in arrival order.
private static Task> DrainAsync(OtOpcUaNodeManager nm, NodeId nodeId, DateTime start, DateTime end, uint max)
{
@@ -520,6 +570,43 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
public void Dispose() { }
}
+ ///
+ /// A microsecond-resolution backend, shaped like QuestDB: every inbound window bound is TRUNCATED to 10 ticks
+ /// (as QuestDbConnection.Round does) before the series is filtered, and the source reports
+ /// = 10.
+ ///
+ private sealed class MicrosecondHistorianDataSource(IReadOnlyList series) : IHistorianDataSource
+ {
+ private readonly SeriesHistorianDataSource _inner = new(series);
+
+ public long TimestampResolutionTicks => 10;
+
+ private static DateTime Truncate(DateTime t) => new(t.Ticks - t.Ticks % 10, DateTimeKind.Utc);
+
+ public Task ReadRawAsync(
+ string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
+ CancellationToken cancellationToken) =>
+ _inner.ReadRawAsync(fullReference, Truncate(startUtc), Truncate(endUtc), maxValuesPerNode, cancellationToken);
+
+ public Task ReadProcessedAsync(
+ string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
+ HistoryAggregateType aggregate, CancellationToken cancellationToken) =>
+ _inner.ReadProcessedAsync(fullReference, startUtc, endUtc, interval, aggregate, cancellationToken);
+
+ public Task ReadAtTimeAsync(
+ string fullReference, IReadOnlyList timestampsUtc, CancellationToken cancellationToken) =>
+ _inner.ReadAtTimeAsync(fullReference, timestampsUtc, cancellationToken);
+
+ public Task ReadEventsAsync(
+ string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
+ CancellationToken cancellationToken) =>
+ _inner.ReadEventsAsync(sourceName, startUtc, endUtc, maxEvents, cancellationToken);
+
+ public HistorianHealthSnapshot GetHealthSnapshot() => _inner.GetHealthSnapshot();
+
+ public void Dispose() { }
+ }
+
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
{
var applicationUri = $"urn:OtOpcUa.HistoryPagingTest:{Guid.NewGuid():N}";