fix(historian): #683 live gate 9 F1 — force an exact-key seek for every StartEnd pick

The window-bounded pick joins (4a3b1256) let the optimizer choose, under
understated statistics, a nested-loops RANGE seek over the window with the pick
time as a residual, once per bucket (O(buckets x window rows)); the cached plan
was then reused for wide reads: gate 9 1d@1min 20 s, here 47 s / 3.6M reads.
Each pick join now carries WITH (FORCESEEK (PK_Sample (TagId, SourceTimeUtc))):
an exact-key seek on both columns per bucket under any statistics, and no
hash/merge join over the tag's history can exist (forcing one is error 8622), so
the window bound is dropped from the join. Output identical (EXCEPT both ways,
fwd/rev, incl. a mixed Double/Int64/Boolean/String/DateTime/outage tag).

Measured (6M-row tag + 86k mixed-kinds tag; ms / logical reads), understated
stats: 1d@1min 47,223/3.6M -> 253/25.6k; mixed 1d@1min 20,164/1.66M -> 137/24k;
accurate stats: 1d@1min 121 -> 160 ms, 1d@1h 79 -> 117 ms. Rejected: OUTER
APPLY TOP(1) FORCESEEK (identical), OPTION (RECOMPILE) (still 20 s under the bad
stats, and a compile per read).

StartEndJoinBackTests rewritten: understated statistics + compile-small/reuse-wide
(18,645 reads; the merged query read 1,645,845 / 23 s and failed it), accurate
statistics with 300k history rows either side (22,616), forced HASH/MERGE
refused (8622 — also failed against the merged query).

Docs: perf table + explanation; SDK-parity N2 (*ActualTime Calculated bit) and
N3 (Good String => BadAggregateInvalidInputs, SDK skips it).
This commit is contained in:
Joseph Doherty
2026-10-12 13:14:24 -04:00
parent fe644d7eda
commit ce3369ebca
3 changed files with 143 additions and 95 deletions
@@ -9,58 +9,53 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
/// <summary>
/// #683: <see cref="SqlAggregateQueries.StartEnd"/> aggregates the pick times, then fetches each pick by a key join.
/// The joins must be bounded to the window: keyed on the pick time alone, a hash/merge/adaptive join reads the tag's
/// whole history (review of 910befc8: 43,657 logical reads vs 361 on a tag with 3M rows outside a 1 d window). The
/// plan choice depends on estimates, so the test FORCES the worst shape (<c>OPTION (HASH JOIN)</c>) on a tag with long
/// history either side of the window and asserts the statement reads only window-sized pages — and returns exactly
/// what the unhinted plan and the data source return.
/// Every pick must be an exact-key seek whatever plan the optimizer would like, because both alternatives it finds are
/// pathological and plans are cached and reused across reads/tags:
/// <list type="bullet">
/// <item>keyed on the pick time alone, a hash/merge/adaptive join reads the tag's whole history (review of 910befc8);</item>
/// <item>bounded to the window, a nested-loops RANGE seek over the window per bucket — O(buckets × window rows) — chosen
/// when the statistics understate the table and then reused for a wide read (live gate 9 F1: 1 d @ 1 min 20 s).</item>
/// </list>
/// The tests use a tag with one sample a second over a day plus long history either side, and bound the session's
/// logical reads (<c>sys.dm_exec_sessions.logical_reads</c> before/after — STATISTICS IO messages do not reach
/// SqlClient's InfoMessage here).
/// </summary>
[Collection(SqlHistorianCollection.Name)]
public sealed class StartEndJoinBackTests(SqlHistorianFixture fx)
{
private static readonly CancellationToken Ct = CancellationToken.None;
private static DateTime At(double s) => ReadTestDb.At(s);
private const int WindowSeconds = 86_400;
private const int HistoryRowsEachSide = 150_000;
/// <summary>
/// Tag <c>H</c>: one sample a minute over the hour [+0, +3600) of mixed quality (Good, Uncertain, Bad, an outage
/// marker, a string), plus <see cref="HistoryRowsEachSide"/> rows a second before +0 and after +3600.
/// Tag <c>H</c>: one sample a second at <c>+i.25 s</c> over [+0, +1 d) of mixed quality (Good, Bad, Uncertain, an
/// outage marker, a String), plus <see cref="HistoryRowsEachSide"/> rows a second before +0 and after +1 d.
/// </summary>
private static async Task<(ReadTestDb Db, int Id, List<(DateTime T, HistorianValue V, uint S)> Window)> SeedAsync(SqlHistorianFixture fx)
private static async Task<(ReadTestDb Db, int Id)> SeedAsync(SqlHistorianFixture fx)
{
var db = await ReadTestDb.CreateAsync(fx);
var window = new List<(DateTime T, HistorianValue V, uint S)>();
for (var i = 0; i < 60; i++)
{
var (v, s) = (i % 7) switch
{
3 => (HistorianValue.FromDouble(i), ReadTestDb.Bad),
4 => (HistorianValue.FromDouble(i), ReadTestDb.Uncertain),
5 => (HistorianValue.Empty, ReadTestDb.BadNoCommunication),
6 => (HistorianValue.FromString("s" + i), ReadTestDb.Good),
_ => (HistorianValue.FromDouble(i), ReadTestDb.Good),
};
window.Add((At(i * 60 + 7), v, s));
}
var id = await db.SeedAsync("H", window.ToArray());
// The history: inserted set-based (a per-row INSERT of 300k rows would dominate the test).
var id = await db.SeedAsync("H", (At(-1e6), HistorianValue.FromDouble(0), ReadTestDb.Good));
string T(DateTime t) => $"CAST('{t:yyyy-MM-dd HH:mm:ss.fffffff}' AS datetime2(7))";
var cols = "(TagId, SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode, ServerTimeUtc)";
await db.ScalarAsync(
$"WITH n AS (SELECT TOP ({HistoryRowsEachSide}) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS x " +
$"WITH n AS (SELECT TOP ({Math.Max(WindowSeconds, HistoryRowsEachSide)}) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) - 1 AS x " +
"FROM sys.all_objects a CROSS JOIN sys.all_objects b) " +
$"INSERT {db.Conn.Q("Sample")} (TagId, SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode, ServerTimeUtc) " +
$"SELECT {id}, DATEADD(second, -x, CAST('{At(0):yyyy-MM-dd HH:mm:ss.fffffff}' AS datetime2(7))), 3, x, NULL, NULL, 0, SYSUTCDATETIME() FROM n " +
$"UNION ALL SELECT {id}, DATEADD(second, x, CAST('{At(3600):yyyy-MM-dd HH:mm:ss.fffffff}' AS datetime2(7))), 3, x, NULL, NULL, 0, SYSUTCDATETIME() FROM n; " +
$"INSERT {db.Conn.Q("Sample")} {cols} " +
$"SELECT {id}, DATEADD(millisecond, 250, DATEADD(second, x, {T(At(0))})), " +
"CASE x % 7 WHEN 5 THEN 0 WHEN 6 THEN 4 ELSE 3 END, CASE WHEN x % 7 IN (5, 6) THEN NULL ELSE x END, NULL, " +
"CASE WHEN x % 7 = 6 THEN N's' + CAST(x AS nvarchar(10)) END, " +
$"CASE x % 7 WHEN 3 THEN {unchecked((int)ReadTestDb.Bad)} WHEN 4 THEN {unchecked((int)ReadTestDb.Uncertain)} " +
$"WHEN 5 THEN {unchecked((int)ReadTestDb.BadNoCommunication)} ELSE 0 END, SYSUTCDATETIME() FROM n WHERE x < {WindowSeconds} " +
$"UNION ALL SELECT {id}, DATEADD(second, -1 - x, {T(At(0))}), 3, x, NULL, NULL, 0, SYSUTCDATETIME() FROM n WHERE x < {HistoryRowsEachSide} " +
$"UNION ALL SELECT {id}, DATEADD(second, 1 + x, {T(At(WindowSeconds))}), 3, x, NULL, NULL, 0, SYSUTCDATETIME() FROM n WHERE x < {HistoryRowsEachSide}; " +
"SELECT 0");
await db.ScalarAsync($"UPDATE STATISTICS {db.Conn.Q("Sample")}; SELECT 0");
return (db, id, window);
await db.ScalarAsync($"UPDATE STATISTICS {db.Conn.Q("Sample")} WITH FULLSCAN; SELECT 0");
return (db, id);
}
/// <summary>
/// Runs the StartEnd SQL (optionally with a query hint) and returns its rows as strings plus the session's logical
/// reads during the statement (<c>sys.dm_exec_sessions.logical_reads</c> before/after — STATISTICS IO messages do
/// not reach SqlClient's InfoMessage here). The two probes add a few reads of their own; the bound is far apart.
/// </summary>
private static async Task<(List<string> Rows, long LogicalReads)> RunAsync(ReadTestDb db, int id, BucketPlan plan, string? hint)
/// <summary>Runs the StartEnd SQL (optional query hint) and returns its rows as strings plus the session's logical reads.</summary>
private static async Task<(List<string> Rows, long LogicalReads)> RunAsync(ReadTestDb db, int id, BucketPlan plan, string? hint = null)
{
var sql = SqlAggregateQueries.StartEnd(db.Conn.Q("Sample"), plan.Reversed);
if (hint is not null) sql = sql.TrimEnd().TrimEnd(';') + $" OPTION ({hint});";
@@ -72,6 +67,7 @@ public sealed class StartEndJoinBackTests(SqlHistorianFixture fx)
}
var before = await SessionReadsAsync();
await using var cmd = db.Conn.CreateCommand(c, sql);
cmd.CommandTimeout = 300;
cmd.Parameters.Add(new SqlParameter("@id", SqlDbType.Int) { Value = id });
SqlHistorianConnection.AddDateTime2(cmd, "@start", plan.StartUtc);
SqlHistorianConnection.AddDateTime2(cmd, "@end", plan.EndUtc);
@@ -80,44 +76,75 @@ public sealed class StartEndJoinBackTests(SqlHistorianFixture fx)
var rows = new List<string>();
await using (var r = await cmd.ExecuteReaderAsync(Ct))
while (await r.ReadAsync(Ct))
rows.Add(string.Join("|", Enumerable.Range(0, r.FieldCount).Select(i => r.IsDBNull(i) ? "NULL" : Convert.ToString(r.GetValue(i), System.Globalization.CultureInfo.InvariantCulture))));
rows.Add(string.Join("|", Enumerable.Range(0, r.FieldCount).Select(i => r.IsDBNull(i) ? "NULL" : r.GetValue(i) is DateTime d ? d.Ticks.ToString(System.Globalization.CultureInfo.InvariantCulture) : Convert.ToString(r.GetValue(i), System.Globalization.CultureInfo.InvariantCulture))));
return (rows, await SessionReadsAsync() - before);
}
private static BucketPlan Day(bool reverse, TimeSpan interval) =>
reverse ? BucketPlan.Create(At(WindowSeconds), At(0), interval) : BucketPlan.Create(At(0), At(WindowSeconds), interval);
/// <summary>
/// Forward: bucket <c>b</c> = [b·60, b·60 + 60) holds +b·60.25 … +b·60+59.25. Reverse: (b·60, b·60 + 60] holds the
/// same samples (none sits on an edge).
/// </summary>
private static void AssertPicks(List<string> rows)
{
rows.Count.ShouldBe(WindowSeconds / 60);
for (var b = 0; b < rows.Count; b++)
{
var f = rows[b].Split('|');
f[0].ShouldBe(b.ToString(System.Globalization.CultureInfo.InvariantCulture));
f[1].ShouldBe("60"); // TotalCount
// any-quality first / last times (ordinals 16, 22)
long.Parse(f[16], System.Globalization.CultureInfo.InvariantCulture).ShouldBe(At(b * 60 + 0.25).Ticks);
long.Parse(f[22], System.Globalization.CultureInfo.InvariantCulture).ShouldBe(At(b * 60 + 59.25).Ticks);
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task The_pick_joins_read_only_the_window_even_under_a_forced_hash_join(bool reverse)
public async Task A_plan_compiled_under_understated_statistics_still_seeks_each_pick_by_its_exact_key(bool reverse)
{
fx.SkipIfUnavailable();
var (db, id, window) = await SeedAsync(fx);
var plan = reverse
? BucketPlan.Create(At(3600), At(0), TimeSpan.FromMinutes(10))
: BucketPlan.Create(At(0), At(3600), TimeSpan.FromMinutes(10));
var (db, id) = await SeedAsync(fx);
// Live gate 9 F1's recipe: the optimizer believes the table is tiny, compiles on a small read, and the cached plan is
// then reused for a wide many-bucket read. (The schema is unique to this test, so the plan is compiled here.)
await db.ScalarAsync($"UPDATE STATISTICS {db.Conn.Q("Sample")} WITH ROWCOUNT = 34, PAGECOUNT = 1; SELECT 0");
var small = reverse ? BucketPlan.Create(At(3600), At(0), TimeSpan.FromMinutes(1)) : BucketPlan.Create(At(0), At(3600), TimeSpan.FromMinutes(1));
await RunAsync(db, id, small);
var (plain, _) = await RunAsync(db, id, plan, hint: null);
var (hashed, hashReads) = await RunAsync(db, id, plan, hint: "HASH JOIN");
var (merged, mergeReads) = await RunAsync(db, id, plan, hint: "MERGE JOIN");
var (rows, reads) = await RunAsync(db, id, Day(reverse, TimeSpan.FromMinutes(1)));
plain.Count.ShouldBe(6);
hashed.ShouldBe(plain);
merged.ShouldBe(plain);
// Measured: 183 session logical reads bounded vs 8,055 with the window bound removed from the joins (300k history
// rows either side; the threshold sits ~5x above the one and ~8x below the other). Every join
// bounded ⇒ each input is a window-range seek, whatever the join algorithm.
hashReads.ShouldBeGreaterThan(0L, "the session read counter must move (positive control)");
hashReads.ShouldBeLessThan(1000, "a hash join's inner input must be bounded to the window");
mergeReads.ShouldBeLessThan(1000, "a merge join's inner input must be bounded to the window");
AssertPicks(rows);
reads.ShouldBeGreaterThan(0L, "the session read counter must move (positive control)");
// Measured: 18,645 reads (one window scan + 4 key seeks per bucket); the previous query (4a3b1256, bounded joins,
// no hint) took the window-range-seek-per-bucket plan here: 1,645,845 reads, 23 s — this test failed against it.
reads.ShouldBeLessThan(200_000, "every pick must be an exact-key seek, never a per-bucket window range seek");
(await RunAsync(db, id, Day(reverse, TimeSpan.FromMinutes(1)), "RECOMPILE")).Rows.ShouldBe(rows);
}
// And the picks are right: Start / End are each bucket's first / last sample of any quality.
var src = db.Source();
var start = await src.ReadProcessedAsync("H", reverse ? At(3600) : At(0), reverse ? At(0) : At(3600),
TimeSpan.FromMinutes(10), HistoryAggregateType.Start, Ct);
var end = await src.ReadProcessedAsync("H", reverse ? At(3600) : At(0), reverse ? At(0) : At(3600),
TimeSpan.FromMinutes(10), HistoryAggregateType.End, Ct);
var buckets = Enumerable.Range(0, 6).Select(b => window.Skip(b * 10).Take(10).ToList()).ToList();
if (reverse) buckets.Reverse();
start.Samples.Select(s => s.SourceTimestampUtc).ShouldBe(buckets.Select(b => (DateTime?)b[0].T));
end.Samples.Select(s => s.SourceTimestampUtc).ShouldBe(buckets.Select(b => (DateTime?)b[^1].T));
[Fact]
public async Task With_accurate_statistics_the_long_history_outside_the_window_is_never_read()
{
fx.SkipIfUnavailable();
var (db, id) = await SeedAsync(fx);
var (rows, reads) = await RunAsync(db, id, Day(false, TimeSpan.FromMinutes(1)));
AssertPicks(rows);
reads.ShouldBeGreaterThan(0L);
reads.ShouldBeLessThan(200_000, "the 300k history rows outside the window must not be read");
}
[Theory]
[InlineData("HASH JOIN")]
[InlineData("MERGE JOIN")]
public async Task No_hash_or_merge_pick_join_exists_to_fall_back_to(string hint)
{
// The FORCESEEK hint makes the exact-key nested-loops seek the ONLY legal pick join: forcing a hash or merge join
// (the whole-history shape of 910befc8) is refused outright (8622) rather than silently chosen.
fx.SkipIfUnavailable();
var (db, id) = await SeedAsync(fx);
var ex = await Should.ThrowAsync<SqlException>(() => RunAsync(db, id, Day(false, TimeSpan.FromHours(1)), hint));
ex.Number.ShouldBe(8622);
}
}