fix(historian): live-gate 8 F1 — per-sample counts and picks from one table version (#680 follow-up)
QuestDB Start/End/Delta reads of a tag being written intermittently failed BadHistoryOperationUnsupported (InvalidCastException: Column 'ts_3' is null): the StartEnd query referenced its CTE four times and QuestDB opens a table reader per reference, so a row committed between two of them was counted but not picked. MinimumActualTime/MaximumActualTime had the same race and returned a value-less Good. (a) SQL - QuestDB StartEnd: one scan, one GROUP BY — first()/last() for the any-quality picks, arg_min/arg_max keyed on the usable rows' time for the usable picks (no str_value: arg_min has no VARCHAR form; only Delta reads them). Byte-identical output to the old query on a 1M-row tag, ~15% faster. - SqlServer StartEnd: one windowed pass + conditional MAX (was five CTE references); identical output, 1.4-3x faster. - ActualTime kept on both backends: every single-scan form measured 1.2-2.4x slower; its inconsistency is absorbed by (b). (b) Defence in depth on both backends - ReadBucket takes DbDataReader and reads absent picks as null (ReadStoredOrNull) instead of throwing. - AggregateEvaluator: Delta with a missing first/last usable pick and ActualTime with a missing extreme are BadNoData (were BadAggregateInvalidInputs / a value-less Good); Start/End with no pick already were. - Unit tests (Sql.Common evaluator, QuestDb + SqlServer mappers over a DataTableReader) fail with the guards removed. LiveWriteReadRaceStressTests (QuestDB integration, opt-in via HISTORIAN_QUESTDB_STRESS_SECONDS, Category=SlowUnit): single-row writer vs 4 tight-loop readers. Old code: 6 faults in ~105k reads; new: 0 in 106k. Docs: Historian.md QuestDB processed-read note; F2 (Min/Max on the interval start: SDK raw, ours Calculated) and F3 (integer tags: SDK casts interpolated values to the integer type, ours Double) added to the SDK-parity deviations.
This commit is contained in:
@@ -83,33 +83,54 @@ internal static class QuestDbAggregateQueries
|
||||
/// (<c>ts, kind, num_value, int_value, str_value, status</c>) and <c>22..27</c> the last (same order), present for
|
||||
/// every returned (non-empty) bucket.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>One scan, one GROUP BY (live-gate 8 F1).</b> The counts and all four picked samples come from a single read
|
||||
/// of the sample table. The first/last sample of any quality are QuestDB's <c>first()</c>/<c>last()</c> (the
|
||||
/// first/last row of the group in designated-timestamp order — null values included, so every column comes from
|
||||
/// the same row); the first/last <i>usable</i> sample are <c>arg_min</c>/<c>arg_max</c> keyed on <c>uts</c>, the
|
||||
/// row's time when usable and NULL otherwise (a NULL key is skipped, a NULL value is returned as is, and
|
||||
/// <c>(ts, tag)</c> is unique — DEDUP — so there are no ties). <c>arg_min</c> has no VARCHAR form, so the usable
|
||||
/// picks carry no <c>str_value</c>: only Delta reads them, and only when the bucket has no usable non-numeric
|
||||
/// sample.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The previous form referenced its CTE four times (counts + three self-joins); QuestDB opens a separate table
|
||||
/// reader per reference, so a row committed between two of them produced a bucket whose counts included a sample
|
||||
/// its first/last join could not find (a NULL first-of-any-quality time that threw) — ~1 in 90k reads of a tag
|
||||
/// being written. One reference means one reader at one table version. A windowed (<c>row_number()</c>)
|
||||
/// single-scan form was also correct but ~2× slower; this one is not slower than the old query.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="sample">Prefixed sample table name.</param>
|
||||
/// <returns>The SQL text.</returns>
|
||||
/// <param name="reverse">True for a reverse read (window <c>($4, $5]</c>).</param>
|
||||
public static string StartEnd(string sample, bool reverse = false) =>
|
||||
"WITH s AS (" + Classified(sample, reverse) + "), " +
|
||||
"st AS (SELECT b, " + CommonCounts() + " FROM s GROUP BY b), " +
|
||||
"f AS (SELECT b, kind, num_value, int_value, str_value, ts, " +
|
||||
"row_number() OVER (PARTITION BY b ORDER BY ts ASC) AS rf, " +
|
||||
"row_number() OVER (PARTITION BY b ORDER BY ts DESC) AS rl FROM s WHERE u = 1), " +
|
||||
"a AS (SELECT b, ts, kind, num_value, int_value, str_value, status, " +
|
||||
"row_number() OVER (PARTITION BY b ORDER BY ts ASC) AS rf, " +
|
||||
"row_number() OVER (PARTITION BY b ORDER BY ts DESC) AS rl FROM s) " +
|
||||
"SELECT st.*, " +
|
||||
"x.kind, x.num_value, x.int_value, x.str_value, x.ts, " +
|
||||
"y.kind, y.num_value, y.int_value, y.str_value, y.ts, " +
|
||||
"p.ts, p.kind, p.num_value, p.int_value, p.str_value, p.status, " +
|
||||
"q.ts, q.kind, q.num_value, q.int_value, q.str_value, q.status " +
|
||||
"FROM st LEFT JOIN (SELECT * FROM f WHERE rf = 1) x ON x.b = st.b " +
|
||||
"LEFT JOIN (SELECT * FROM f WHERE rl = 1) y ON y.b = st.b " +
|
||||
"LEFT JOIN (SELECT * FROM a WHERE rf = 1) p ON p.b = st.b " +
|
||||
"LEFT JOIN (SELECT * FROM a WHERE rl = 1) q ON q.b = st.b ORDER BY st.b";
|
||||
public static string StartEnd(string sample, bool reverse = false)
|
||||
{
|
||||
// The usable picks: kind, num_value, int_value, str_value (not available — see remarks), ts.
|
||||
static string Usable(string f, string g) =>
|
||||
$"cast({f}(kind, uts) AS SHORT), {f}(num_value, uts), {f}(int_value, uts), cast(NULL AS VARCHAR), {g}(uts)";
|
||||
// The any-quality picks: ts, kind, num_value, int_value, str_value, status — all from the group's first / last row.
|
||||
static string Any(string f) =>
|
||||
$"{f}(ts), cast({f}(kind) AS SHORT), {f}(num_value), {f}(int_value), {f}(str_value), {f}(status)";
|
||||
return "SELECT b, " + CommonCounts() + ", " +
|
||||
Usable("arg_min", "min") + ", " + Usable("arg_max", "max") + ", " + Any("first") + ", " + Any("last") + " " +
|
||||
"FROM (SELECT b, ts, kind, num_value, int_value, str_value, status, u, g, CASE WHEN u = 1 THEN ts END AS uts " +
|
||||
"FROM (" + Classified(sample, reverse) + ")) GROUP BY b ORDER BY b";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MinimumActualTime / MaximumActualTime: the usable sample with the smallest (largest) <c>num_value</c>, ties
|
||||
/// broken by the earliest <c>ts</c>. Family columns: <c>6 num_value, 7 ts</c> (NULL when the bucket has no usable
|
||||
/// numeric sample).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately still two references to the classified rows (counts + the ranked extreme), unlike
|
||||
/// <see cref="StartEnd"/>: the single-scan <c>row_number()</c> form measured ~2× slower, and QuestDB's
|
||||
/// <c>arg_max</c>/<c>arg_min</c> cannot express the earliest-time tie-break. Live-gate 8 F1's inconsistency (a row
|
||||
/// committed between the two readers — counted but not ranked) is absorbed by the reader and evaluator instead: a
|
||||
/// NULL extreme in a bucket with usable samples evaluates to <c>BadNoData</c>, never a value-less Good.
|
||||
/// </remarks>
|
||||
/// <param name="sample">Prefixed sample table name.</param>
|
||||
/// <param name="maximum">True for MaximumActualTime.</param>
|
||||
/// <returns>The SQL text.</returns>
|
||||
|
||||
Reference in New Issue
Block a user