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:
@@ -107,4 +107,19 @@ public interface IHistorianDataSource : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>The current health snapshot for the historian backend.</returns>
|
/// <returns>The current health snapshot for the historian backend.</returns>
|
||||||
HistorianHealthSnapshot GetHealthSnapshot();
|
HistorianHealthSnapshot GetHealthSnapshot();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The backend's storage timestamp resolution, in <see cref="DateTime"/> ticks (100 ns): the smallest step
|
||||||
|
/// between two distinct stored timestamps. <c>1</c> (the default) means full tick precision; QuestDB stores
|
||||||
|
/// microseconds and reports <c>10</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The server's HistoryRead-Raw paging steps OFF a drained timestamp by this amount (forward
|
||||||
|
/// <c>T + resolution</c>, reverse <c>T − resolution</c>). Stepping by less than the resolution is not
|
||||||
|
/// progress on a backend that truncates its inputs: <c>T + 1 tick</c> is truncated back to <c>T</c>, 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 <c>T</c> and <c>T ± resolution</c>.
|
||||||
|
/// Same-timestamp ties are unaffected (they are paged within the timestamp, never stepped over).
|
||||||
|
/// </remarks>
|
||||||
|
long TimestampResolutionTicks => 1;
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-8
@@ -19,8 +19,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb;
|
|||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Timestamps (R6).</b> Every inbound time is normalised with <see cref="QuestDbConnection.Round"/> and every
|
/// <b>Timestamps (R6).</b> Every inbound time is normalised with <see cref="QuestDbConnection.Round"/> and every
|
||||||
/// <see cref="DateTime"/> is bound only through <see cref="QuestDbConnection.BindTimestamp"/>. Read bounds are not
|
/// <see cref="DateTime"/> is bound only through <see cref="QuestDbConnection.BindTimestamp"/>. Read bounds are not
|
||||||
/// clamped to the epoch (a pre-1970 bound is valid); only <c>MinValue</c>/<c>MaxValue</c> are nudged. Results
|
/// clamped to the epoch (a pre-1970 bound is valid); only <c>MinValue</c>/<c>MaxValue</c> are nudged. Raw and
|
||||||
/// carry the rounded times, so an AtTime result is stamped at the rounded request time.
|
/// 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.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Reads never create tags (R7).</b> A tag resolves only through <see cref="QuestDbTagCatalog.ExistsAsync"/>;
|
/// <b>Reads never create tags (R7).</b> A tag resolves only through <see cref="QuestDbTagCatalog.ExistsAsync"/>;
|
||||||
@@ -44,6 +46,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class QuestDbHistorianDataSource : IHistorianDataSource
|
public sealed class QuestDbHistorianDataSource : IHistorianDataSource
|
||||||
{
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>QuestDB stores microseconds: 10 ticks. HistoryRead paging steps off a drained timestamp by this much.</remarks>
|
||||||
|
public long TimestampResolutionTicks => 10;
|
||||||
|
|
||||||
/// <summary>Most AtTime request times per statement (two <c>UNION ALL</c> branches each; QuestDB 10.0.1 refuses more than 126 branches).</summary>
|
/// <summary>Most AtTime request times per statement (two <c>UNION ALL</c> branches each; QuestDB 10.0.1 refuses more than 126 branches).</summary>
|
||||||
public const int AtTimeChunkSize = 50;
|
public const int AtTimeChunkSize = 50;
|
||||||
|
|
||||||
@@ -139,8 +145,9 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
|
|||||||
/// <c>LIMIT 1</c> seeks, <c>UNION ALL</c>-ed, at most <see cref="AtTimeChunkSize"/> times per statement (spec §3,
|
/// <c>LIMIT 1</c> seeks, <c>UNION ALL</c>-ed, at most <see cref="AtTimeChunkSize"/> times per statement (spec §3,
|
||||||
/// T0 Q9). <see cref="AtTimeEvaluator.Evaluate"/> then decides each value exactly as for SQL Server (exact hit,
|
/// T0 Q9). <see cref="AtTimeEvaluator.Evaluate"/> then decides each value exactly as for SQL Server (exact hit,
|
||||||
/// no previous ⇒ <c>BadNoData</c>, inside an outage, held before an outage, linear, stepped, after now). The
|
/// no previous ⇒ <c>BadNoData</c>, 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
|
/// result matches the request in length and order (duplicates included). Each entry is evaluated at its rounded
|
||||||
/// time. Unknown tag ⇒ every entry <c>BadNoData</c>.
|
/// time but stamped with the requested time itself (UTC-normalised as <see cref="QuestDbConnection.Round"/> does,
|
||||||
|
/// not truncated), matching the SQL Server backend. Unknown tag ⇒ every entry <c>BadNoData</c>.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public async Task<HistoryReadResult> ReadAtTimeAsync(
|
public async Task<HistoryReadResult> ReadAtTimeAsync(
|
||||||
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
|
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
|
||||||
@@ -197,10 +204,11 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
|
|||||||
var result = new DataValueSnapshot[rounded.Length];
|
var result = new DataValueSnapshot[rounded.Length];
|
||||||
for (var i = 0; i < result.Length; i++)
|
for (var i = 0; i < result.Length; i++)
|
||||||
{
|
{
|
||||||
var t = rounded[i];
|
// Seek/evaluate at the rounded time; stamp the client's own requested time (M3).
|
||||||
result[i] = byTicks.TryGetValue(t.Ticks, out var v)
|
var requested = AsUtc(timestampsUtc[i]);
|
||||||
? v
|
result[i] = byTicks.TryGetValue(rounded[i].Ticks, out var v)
|
||||||
: new DataValueSnapshot(null, HistorianStatusCodes.BadNoData, t, now);
|
? v with { SourceTimestampUtc = requested }
|
||||||
|
: new DataValueSnapshot(null, HistorianStatusCodes.BadNoData, requested, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
RecordSuccess();
|
RecordSuccess();
|
||||||
@@ -213,6 +221,14 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The UTC normalisation of <see cref="QuestDbConnection.Round"/> without its truncation or nudging.</summary>
|
||||||
|
private static DateTime AsUtc(DateTime t) => t.Kind switch
|
||||||
|
{
|
||||||
|
DateTimeKind.Local => t.ToUniversalTime(),
|
||||||
|
DateTimeKind.Utc => t,
|
||||||
|
_ => DateTime.SpecifyKind(t, DateTimeKind.Utc),
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The T0 Q9 statement for <paramref name="count"/> request times: <c>$1</c> = tag, <c>$(k+2)</c> = time <c>k</c>.
|
/// The T0 Q9 statement for <paramref name="count"/> request times: <c>$1</c> = tag, <c>$(k+2)</c> = time <c>k</c>.
|
||||||
/// Columns: <c>req, dir</c> (0 = previous-or-equal, 1 = next), then <c>ts, kind, num_value, int_value, str_value,
|
/// Columns: <c>req, dir</c> (0 = previous-or-equal, 1 = next), then <c>ts, kind, num_value, int_value, str_value,
|
||||||
|
|||||||
@@ -212,25 +212,34 @@ internal static class HistoryPaging
|
|||||||
/// after <paramref name="endUtc"/> (the reverse window excludes <c>end</c>; a resume at exactly <c>end</c>
|
/// after <paramref name="endUtc"/> (the reverse window excludes <c>end</c>; a resume at exactly <c>end</c>
|
||||||
/// would turn into a <c>start == end</c> point read and return the excluded sample). Forward is unchanged.
|
/// would turn into a <c>start == end</c> point read and return the excluded sample). Forward is unchanged.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="resolutionTicks">
|
||||||
|
/// The backend's storage timestamp resolution (<c>IHistorianDataSource.TimestampResolutionTicks</c>, >= 1). A
|
||||||
|
/// drained cluster steps off <paramref name="boundaryT"/> by this many ticks rather than by one: a backend that
|
||||||
|
/// truncates its inputs to its resolution (QuestDB, 10 ticks) would otherwise truncate <c>T + 1 tick</c> back
|
||||||
|
/// to <c>T</c> and return the drained sample again, forever. Lossless for the same reason one tick is at tick
|
||||||
|
/// precision: no stored timestamp lies strictly between <c>T</c> and <c>T ± resolution</c>. The default
|
||||||
|
/// <c>1</c> keeps the tick-precision behaviour exactly.
|
||||||
|
/// </param>
|
||||||
public static void SliceTieCluster(
|
public static void SliceTieCluster(
|
||||||
int clusterCount, int skip, uint cap, DateTime boundaryT, DateTime endUtc,
|
int clusterCount, int skip, uint cap, DateTime boundaryT, DateTime endUtc,
|
||||||
out int sliceStart, out int sliceCount, out DateTime? nextStartUtc, out int nextSkip,
|
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);
|
sliceStart = Math.Min(skip, clusterCount);
|
||||||
sliceCount = Math.Min((int)cap, clusterCount - sliceStart);
|
sliceCount = Math.Min((int)cap, clusterCount - sliceStart);
|
||||||
var emitted = sliceStart + sliceCount;
|
var emitted = sliceStart + sliceCount;
|
||||||
if (emitted < clusterCount) { nextStartUtc = boundaryT; nextSkip = emitted; }
|
if (emitted < clusterCount) { nextStartUtc = boundaryT; nextSkip = emitted; }
|
||||||
else if (reverse)
|
else if (reverse)
|
||||||
{
|
{
|
||||||
// DateTime.MinValue cannot step down; nothing can lie below it anyway.
|
// Nothing can lie below DateTime.MinValue, so a boundary within one resolution step of it cannot step down.
|
||||||
var more = boundaryT > DateTime.MinValue && boundaryT.AddTicks(-1) > endUtc;
|
var more = boundaryT.Ticks >= resolutionTicks && boundaryT.AddTicks(-resolutionTicks) > endUtc;
|
||||||
nextStartUtc = more ? boundaryT.AddTicks(-1) : null;
|
nextStartUtc = more ? boundaryT.AddTicks(-resolutionTicks) : null;
|
||||||
nextSkip = 0;
|
nextSkip = 0;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var next = boundaryT.AddTicks(1);
|
var next = boundaryT.AddTicks(resolutionTicks);
|
||||||
if (next <= endUtc) { nextStartUtc = next; nextSkip = 0; }
|
if (next <= endUtc) { nextStartUtc = next; nextSkip = 0; }
|
||||||
else { nextStartUtc = null; nextSkip = 0; }
|
else { nextStartUtc = null; nextSkip = 0; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5931,7 +5931,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
HistoryPaging.SliceTieCluster(
|
HistoryPaging.SliceTieCluster(
|
||||||
cluster.Count, boundarySkip, numValuesPerNode, startUtc, endUtc,
|
cluster.Count, boundarySkip, numValuesPerNode, startUtc, endUtc,
|
||||||
out var sliceStart, out var sliceCount, out var nextStartUtc, out var nextSkip,
|
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<DataValueSnapshot>(sliceCount);
|
var slice = new List<DataValueSnapshot>(sliceCount);
|
||||||
for (var i = sliceStart; i < sliceStart + sliceCount; i++) slice.Add(cluster[i]);
|
for (var i = sliceStart; i < sliceStart + sliceCount; i++) slice.Add(cluster[i]);
|
||||||
|
|||||||
+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);
|
new QuestDbHistorianDataSource(Conn, Boot, NewCatalog(), timeProvider: clock);
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <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) =>
|
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/>
|
/// <inheritdoc/>
|
||||||
/// <remarks>TODO(T7): build the T6 <c>QuestDbHistorianDataUpdater</c> here once T6 is merged.</remarks>
|
/// <remarks>The T6 soft-delete updater over a fresh catalog and this instance's WAL wait.</remarks>
|
||||||
public IHistorianDataUpdater CreateUpdater() =>
|
public IHistorianDataUpdater CreateUpdater() => new QuestDbHistorianDataUpdater(Conn, NewCatalog(), Wal);
|
||||||
throw new NotSupportedException("TODO(T7): the QuestDB history updater (T6) is not merged into this branch yet.");
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
|||||||
@@ -345,6 +345,51 @@ public sealed class HistoryPagingTests
|
|||||||
store.TryTake(null, cp3).ShouldNotBeNull(); // retained
|
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<ArgumentOutOfRangeException>(() => HistoryPaging.SliceTieCluster(
|
||||||
|
1, 1, 1, DateTime.UtcNow, DateTime.UtcNow.AddHours(1), out _, out _, out _, out _, resolutionTicks: 0));
|
||||||
|
|
||||||
private static HistoryContinuationState RawState() => new(
|
private static HistoryContinuationState RawState() => new(
|
||||||
"WW.Tag", DateTime.UtcNow, DateTime.UtcNow.AddHours(1),
|
"WW.Tag", DateTime.UtcNow, DateTime.UtcNow.AddHours(1),
|
||||||
BoundarySkipCount: 1, NumValuesPerNode: 50);
|
BoundarySkipCount: 1, NumValuesPerNode: 50);
|
||||||
|
|||||||
@@ -357,6 +357,56 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
|||||||
await host.DisposeAsync();
|
await host.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Forward paging at <c>NumValuesPerNode = 1</c> over a MICROSECOND backend (QuestDB: it truncates every inbound
|
||||||
|
/// time to 10 ticks and reports <c>TimestampResolutionTicks = 10</c>) returns every sample exactly once and
|
||||||
|
/// terminates. Before the fix the drained one-sample "cluster" resumed at <c>T + 1 tick</c>, which the backend
|
||||||
|
/// truncated back to <c>T</c>: the same sample came back with no boundary skip, and the read looped forever.
|
||||||
|
/// </summary>
|
||||||
|
[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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The reverse mirror on the same microsecond backend: newest first, each sample once.</summary>
|
||||||
|
[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();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Pages a Raw read to completion and returns the union of the pages in arrival order.</summary>
|
/// <summary>Pages a Raw read to completion and returns the union of the pages in arrival order.</summary>
|
||||||
private static Task<List<double>> DrainAsync(OtOpcUaNodeManager nm, NodeId nodeId, DateTime start, DateTime end, uint max)
|
private static Task<List<double>> DrainAsync(OtOpcUaNodeManager nm, NodeId nodeId, DateTime start, DateTime end, uint max)
|
||||||
{
|
{
|
||||||
@@ -520,6 +570,43 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
|||||||
public void Dispose() { }
|
public void Dispose() { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A microsecond-resolution backend, shaped like QuestDB: every inbound window bound is TRUNCATED to 10 ticks
|
||||||
|
/// (as <c>QuestDbConnection.Round</c> does) before the series is filtered, and the source reports
|
||||||
|
/// <see cref="TimestampResolutionTicks"/> = 10.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class MicrosecondHistorianDataSource(IReadOnlyList<DataValueSnapshot> 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<HistorianRead> ReadRawAsync(
|
||||||
|
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
_inner.ReadRawAsync(fullReference, Truncate(startUtc), Truncate(endUtc), maxValuesPerNode, cancellationToken);
|
||||||
|
|
||||||
|
public Task<HistorianRead> ReadProcessedAsync(
|
||||||
|
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
|
||||||
|
HistoryAggregateType aggregate, CancellationToken cancellationToken) =>
|
||||||
|
_inner.ReadProcessedAsync(fullReference, startUtc, endUtc, interval, aggregate, cancellationToken);
|
||||||
|
|
||||||
|
public Task<HistorianRead> ReadAtTimeAsync(
|
||||||
|
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken) =>
|
||||||
|
_inner.ReadAtTimeAsync(fullReference, timestampsUtc, cancellationToken);
|
||||||
|
|
||||||
|
public Task<HistoricalEventsResult> 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()
|
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
||||||
{
|
{
|
||||||
var applicationUri = $"urn:OtOpcUa.HistoryPagingTest:{Guid.NewGuid():N}";
|
var applicationUri = $"urn:OtOpcUa.HistoryPagingTest:{Guid.NewGuid():N}";
|
||||||
|
|||||||
Reference in New Issue
Block a user