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>
|
||||
/// <returns>The current health snapshot for the historian backend.</returns>
|
||||
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>
|
||||
/// <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,
|
||||
|
||||
@@ -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>
|
||||
/// would turn into a <c>start == end</c> point read and return the excluded sample). Forward is unchanged.
|
||||
/// </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(
|
||||
int clusterCount, int skip, uint cap, DateTime boundaryT, DateTime endUtc,
|
||||
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);
|
||||
sliceCount = Math.Min((int)cap, clusterCount - sliceStart);
|
||||
var emitted = sliceStart + sliceCount;
|
||||
if (emitted < clusterCount) { nextStartUtc = boundaryT; nextSkip = emitted; }
|
||||
else if (reverse)
|
||||
{
|
||||
// DateTime.MinValue cannot step down; nothing can lie below it anyway.
|
||||
var more = boundaryT > DateTime.MinValue && boundaryT.AddTicks(-1) > endUtc;
|
||||
nextStartUtc = more ? boundaryT.AddTicks(-1) : null;
|
||||
// Nothing can lie below DateTime.MinValue, so a boundary within one resolution step of it cannot step down.
|
||||
var more = boundaryT.Ticks >= resolutionTicks && boundaryT.AddTicks(-resolutionTicks) > endUtc;
|
||||
nextStartUtc = more ? boundaryT.AddTicks(-resolutionTicks) : null;
|
||||
nextSkip = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
var next = boundaryT.AddTicks(1);
|
||||
var next = boundaryT.AddTicks(resolutionTicks);
|
||||
if (next <= endUtc) { nextStartUtc = next; nextSkip = 0; }
|
||||
else { nextStartUtc = null; nextSkip = 0; }
|
||||
}
|
||||
|
||||
@@ -5931,7 +5931,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
HistoryPaging.SliceTieCluster(
|
||||
cluster.Count, boundarySkip, numValuesPerNode, startUtc, endUtc,
|
||||
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);
|
||||
for (var i = sliceStart; i < sliceStart + sliceCount; i++) slice.Add(cluster[i]);
|
||||
|
||||
Reference in New Issue
Block a user