fix(historian): page by the backend's timestamp resolution; QuestDB AtTime stamps the requested time
IHistorianDataSource.TimestampResolutionTicks (default 1; QuestDB 10). A drained tie cluster now steps off T by the resolution, so a forward one-value-per-page read on a microsecond backend no longer re-reads the same sample forever (C1). QuestDB AtTime results carry the client's original request time (M3); regression for the NEXT seek skipping a tombstone (M1). Conformance backend wires the real writer and updater. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+85
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// QuestDB AtTime regressions found in the T5 review (M1, M3), and the data source's reported timestamp
|
||||
/// resolution (the HistoryRead paging step, C1).
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>M3 on the unknown-tag path: every BadNoData entry carries its own requested time.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>C1: the data source reports QuestDB's microsecond storage resolution to the paging code.</summary>
|
||||
[Fact]
|
||||
public void Data_source_reports_a_ten_tick_timestamp_resolution() =>
|
||||
((IHistorianDataSource)_b.CreateDataSource()).TimestampResolutionTicks.ShouldBe(10);
|
||||
}
|
||||
+55
@@ -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
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
protected override Task<IConformanceBackend> CreateBackendAsync() => QuestDbConformanceBackend.CreateAsync(fx);
|
||||
}
|
||||
|
||||
[Collection(QuestDbCollection.Name)]
|
||||
public sealed class QuestDbAtTimeConformance(QuestDbFixture fx) : AtTimeConformance
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
protected override Task<IConformanceBackend> CreateBackendAsync() => QuestDbConformanceBackend.CreateAsync(fx);
|
||||
}
|
||||
|
||||
[Collection(QuestDbCollection.Name)]
|
||||
public sealed class QuestDbProcessedConformance(QuestDbFixture fx) : ProcessedConformance
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
protected override Task<IConformanceBackend> CreateBackendAsync() => QuestDbConformanceBackend.CreateAsync(fx);
|
||||
}
|
||||
|
||||
[Collection(QuestDbCollection.Name)]
|
||||
public sealed class QuestDbQualityContractConformance(QuestDbFixture fx) : QualityContractConformance
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
protected override Task<IConformanceBackend> CreateBackendAsync() => QuestDbConformanceBackend.CreateAsync(fx);
|
||||
}
|
||||
|
||||
[Collection(QuestDbCollection.Name)]
|
||||
public sealed class QuestDbLiveWriteConformance(QuestDbFixture fx) : LiveWriteConformance
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
protected override Task<IConformanceBackend> CreateBackendAsync() => QuestDbConformanceBackend.CreateAsync(fx);
|
||||
}
|
||||
|
||||
[Collection(QuestDbCollection.Name)]
|
||||
public sealed class QuestDbUpdateConformance(QuestDbFixture fx) : UpdateConformance
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
protected override Task<IConformanceBackend> CreateBackendAsync() => QuestDbConformanceBackend.CreateAsync(fx);
|
||||
}
|
||||
|
||||
[Collection(QuestDbCollection.Name)]
|
||||
public sealed class QuestDbTagNameConformance(QuestDbFixture fx) : TagNameConformance
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
protected override Task<IConformanceBackend> CreateBackendAsync() => QuestDbConformanceBackend.CreateAsync(fx);
|
||||
}
|
||||
+4
-5
@@ -76,14 +76,13 @@ internal sealed class QuestDbConformanceBackend : IConformanceBackend
|
||||
new QuestDbHistorianDataSource(Conn, Boot, NewCatalog(), timeProvider: clock);
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>TODO(T7): build the T4 <c>QuestDbHistorianValueWriter</c> here once T4 is merged.</remarks>
|
||||
/// <remarks>The T4 live-value writer over a fresh catalog (fresh positive cache) and this instance's WAL wait.</remarks>
|
||||
public IHistorianValueWriter CreateValueWriter(Func<DateTime>? 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);
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>TODO(T7): build the T6 <c>QuestDbHistorianDataUpdater</c> here once T6 is merged.</remarks>
|
||||
public IHistorianDataUpdater CreateUpdater() =>
|
||||
throw new NotSupportedException("TODO(T7): the QuestDB history updater (T6) is not merged into this branch yet.");
|
||||
/// <remarks>The T6 soft-delete updater over a fresh catalog and this instance's WAL wait.</remarks>
|
||||
public IHistorianDataUpdater CreateUpdater() => new QuestDbHistorianDataUpdater(Conn, NewCatalog(), Wal);
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
|
||||
Reference in New Issue
Block a user