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}";