test(historian): QuestDB-backed paging regression (T7 review)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+118
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user