test(historian): QuestDB-backed paging regression (T7 review)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-11 03:03:13 -04:00
parent 0e2bffc74a
commit f661c084d8
@@ -0,0 +1,118 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests;
/// <summary>
/// C1 (T7 review I-1): the facts the server's HistoryRead-Raw paging relies on, pinned against the REAL QuestDB data
/// source (the node-manager walk in <c>NodeManagerHistoryReadPagingTests</c> runs on a fake that models them).
/// <c>HistoryPaging</c> is internal to OpcUaServer and not visible from this project, so the end-to-end walk below
/// re-states the node manager's cursor arithmetic (resume inclusive + boundary trim; stalled ⇒ point-read the
/// cluster and step off by <see cref="IHistorianDataSource.TimestampResolutionTicks"/>) over the real reads.
/// </summary>
[Collection(QuestDbCollection.Name)]
public sealed class PagingRegressionTests(QuestDbFixture fx) : IAsyncLifetime
{
private static readonly CancellationToken Ct = CancellationToken.None;
private static readonly DateTime T0 = new(2026, 3, 1, 12, 0, 0, DateTimeKind.Utc); // µs-aligned
private QuestDbConformanceBackend _b = null!;
/// <summary>Sample i at T0 + i µs (adjacent microseconds: a one-tick step lands strictly between two of them).</summary>
private static DateTime S(int i) => T0.AddTicks(10L * i);
public async ValueTask InitializeAsync()
{
_b = await QuestDbConformanceBackend.CreateConcreteAsync(fx);
await _b.SeedAsync("P", Enumerable.Range(0, 6)
.Select(i => (S(i), HistorianValue.FromDouble(i), ConformanceData.Good)).ToArray());
}
public async ValueTask DisposeAsync()
{
if (_b is not null) await _b.DisposeAsync();
}
private async Task<DateTime?[]> RawAsync(IHistorianDataSource ds, DateTime start, DateTime end, uint max) =>
(await ds.ReadRawAsync("P", start, end, max, Ct)).Samples.Select(s => s.SourceTimestampUtc).ToArray();
[Fact]
public async Task Forward_raw_reads_truncate_one_tick_but_advance_by_one_resolution_step()
{
var ds = _b.CreateDataSource();
var end = T0.AddHours(1);
var res = ds.TimestampResolutionTicks;
(await RawAsync(ds, S(2).AddTicks(1), end, 1)).ShouldBe([S(2)]); // T + 1 tick ⇒ back to T (the C1 loop)
(await RawAsync(ds, S(2).AddTicks(res), end, 1)).ShouldBe([S(3)]); // T + resolution ⇒ the next sample
(await RawAsync(ds, S(2), S(2), 10)).ShouldBe([S(2)]); // point read at T ⇒ T
}
[Fact]
public async Task Reverse_raw_reads_step_down_by_one_resolution_step()
{
var ds = _b.CreateDataSource();
var end = T0.AddHours(-1);
var res = ds.TimestampResolutionTicks;
(await RawAsync(ds, S(3).AddTicks(-1), end, 1)).ShouldBe([S(2)]); // T − 1 tick truncates DOWN ⇒ previous
(await RawAsync(ds, S(3).AddTicks(-res), end, 1)).ShouldBe([S(2)]); // T − resolution ⇒ previous
(await RawAsync(ds, S(3), end, 1)).ShouldBe([S(3)]); // reverse window (end, start] includes start
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task One_value_per_page_walk_returns_every_sample_once_and_terminates(bool reverse)
{
var ds = _b.CreateDataSource();
var (start, end) = reverse ? (T0.AddHours(1), T0.AddHours(-1)) : (T0.AddHours(-1), T0.AddHours(1));
var collected = await WalkAsync(ds, start, end);
var expected = Enumerable.Range(0, 6).Select(i => (DateTime?)S(i));
collected.ShouldBe(reverse ? expected.Reverse() : expected);
}
/// <summary>The node manager's Raw paging loop at <c>NumValuesPerNode = 1</c> (see <c>ServeRawPagedAsync</c>).</summary>
private static async Task<List<DateTime?>> WalkAsync(IHistorianDataSource ds, DateTime start, DateTime end)
{
const uint cap = 1;
var res = ds.TimestampResolutionTicks;
var reverse = start > end;
var collected = new List<DateTime?>();
DateTime? cursor = start;
var skip = 0;
var resumed = false;
for (var pages = 0; cursor is { } from; pages++)
{
pages.ShouldBeLessThan(60, "paging must terminate, not loop");
var raw = (await ds.ReadRawAsync("P", from, end, cap, Ct)).Samples;
var full = raw.Count >= cap;
var page = raw.ToList();
if (resumed)
for (var dropped = 0; dropped < skip && page.Count > 0 && page[0].SourceTimestampUtc == from; dropped++)
page.RemoveAt(0);
if (resumed && full && (page.Count == 0 || page[^1].SourceTimestampUtc == from))
{
// Stalled on the boundary "cluster": point-read it, emit what is left, step off by the resolution.
var cluster = (await ds.ReadRawAsync("P", from, from, 1000, Ct)).Samples;
var sliceStart = Math.Min(skip, cluster.Count);
var slice = cluster.Skip(sliceStart).Take((int)cap).ToList();
collected.AddRange(slice.Select(s => s.SourceTimestampUtc));
if (sliceStart + slice.Count < cluster.Count) { skip = sliceStart + slice.Count; continue; }
var next = from.AddTicks(reverse ? -res : res);
cursor = reverse ? (next > end ? next : null) : (next <= end ? next : null);
skip = 0;
continue;
}
collected.AddRange(page.Select(s => s.SourceTimestampUtc));
if (!full || page.Count == 0) break;
cursor = page[^1].SourceTimestampUtc;
skip = page.Count(s => s.SourceTimestampUtc == cursor);
resumed = true;
}
return collected;
}
}