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:
Joseph Doherty
2026-10-11 02:30:25 -04:00
parent 13d628a07f
commit d2be213e86
9 changed files with 330 additions and 19 deletions
@@ -19,8 +19,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb;
/// <para>
/// <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
/// clamped to the epoch (a pre-1970 bound is valid); only <c>MinValue</c>/<c>MaxValue</c> 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 <c>MinValue</c>/<c>MaxValue</c> 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.
/// </para>
/// <para>
/// <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>
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>
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,
/// 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
/// result matches the request in length and order (duplicates included), each entry stamped at its rounded
/// time. Unknown tag ⇒ every entry <c>BadNoData</c>.
/// 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 <see cref="QuestDbConnection.Round"/> does,
/// not truncated), matching the SQL Server backend. Unknown tag ⇒ every entry <c>BadNoData</c>.
/// </remarks>
public async Task<HistoryReadResult> ReadAtTimeAsync(
string fullReference, IReadOnlyList<DateTime> 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
}
}
/// <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>
/// 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,