New collector query-stats reads sys.dm_exec_query_stats for the statements that ran since the previous collection, grouped by query_hash (SQL Server 2008+) or sql_handle + offsets (2005, via legacy ODBC; literals only). Only the 50 heaviest by CPU, duration, reads and executions, text cut to 4000 characters, no plan XML. The database comes from the plan's dbid attribute, because ad hoc batches (as Dynamics AX sends) have none in sql_text. QueryStatsDelta diffs against the previous collection: a statement seen before with the same plan gets the difference, a plan compiled since then counts in full, a recompiled or evicted plan or counters that went backwards start over (never negative), a restart gives no delta. Up to 2000 statements are remembered between collections. Repository migration 0006: insight.QueryStatSamples (deltas, top 100 per collection) and insight.QueryTexts (text once per statement), kept for Retention:RawDays. The text is customer data: never stored as a JSON snapshot and never part of hub submissions. Window queries for metrics, waits (raw and hourly), file I/O, blocking, deadlocks, findings active in a window and top SQL by CPU/duration/reads/executions. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
134 lines
6 KiB
C#
134 lines
6 KiB
C#
using SQLInsight.Core.Data;
|
|
|
|
namespace SQLInsight.Core.Tests;
|
|
|
|
public class QueryStatsDeltaTests
|
|
{
|
|
private static readonly DateTime Start = new(2026, 9, 20, 0, 0, 0);
|
|
private static readonly DateTimeOffset T0 = new(2026, 9, 20, 3, 0, 0, TimeSpan.Zero);
|
|
|
|
private static QueryStat Stat(string key, long executions, long cpu, DateTime created, DateTime lastRun, long reads = 0) => new()
|
|
{
|
|
Key = key,
|
|
Text = "SELECT " + key,
|
|
Executions = executions,
|
|
WorkerTimeUs = cpu,
|
|
ElapsedTimeUs = cpu * 2,
|
|
LogicalReads = reads,
|
|
PhysicalReads = 0,
|
|
LogicalWrites = 0,
|
|
CreatedAt = created,
|
|
LastExecutionAt = lastRun,
|
|
};
|
|
|
|
private static QueryStatsSnapshot Snapshot(DateTimeOffset at, DateTime serverTime, params QueryStat[] queries) => new()
|
|
{
|
|
CapturedAt = at,
|
|
ServerTime = serverTime,
|
|
SqlServerStartTime = Start,
|
|
Queries = queries,
|
|
};
|
|
|
|
[Fact]
|
|
public void Statement_seen_before_with_the_same_plan_gets_the_difference()
|
|
{
|
|
var created = new DateTime(2026, 9, 20, 1, 0, 0);
|
|
var before = Snapshot(T0, new DateTime(2026, 9, 20, 3, 0, 0), Stat("0xA", 100, 5_000, created, created.AddHours(1), reads: 1_000));
|
|
var now = Snapshot(T0.AddMinutes(5), new DateTime(2026, 9, 20, 3, 5, 0), Stat("0xA", 130, 8_000, created, new DateTime(2026, 9, 20, 3, 4, 0), reads: 1_600));
|
|
|
|
var delta = QueryStatsDelta.Compute(before, now)!;
|
|
|
|
var q = Assert.Single(delta.Queries);
|
|
Assert.Equal(30, q.Executions);
|
|
Assert.Equal(3_000, q.WorkerTimeUs);
|
|
Assert.Equal(6_000, q.ElapsedTimeUs);
|
|
Assert.Equal(600, q.LogicalReads);
|
|
Assert.Equal("SELECT 0xA", q.Text);
|
|
}
|
|
|
|
[Fact]
|
|
public void Plan_compiled_after_the_previous_collection_counts_in_full()
|
|
{
|
|
var before = Snapshot(T0, new DateTime(2026, 9, 20, 3, 0, 0));
|
|
var now = Snapshot(T0.AddMinutes(5), new DateTime(2026, 9, 20, 3, 5, 0),
|
|
Stat("0xB", 4, 900, new DateTime(2026, 9, 20, 3, 2, 0), new DateTime(2026, 9, 20, 3, 4, 0)));
|
|
|
|
var q = Assert.Single(QueryStatsDelta.Compute(before, now)!.Queries);
|
|
|
|
Assert.Equal(4, q.Executions);
|
|
Assert.Equal(900, q.WorkerTimeUs);
|
|
}
|
|
|
|
[Fact]
|
|
public void Old_statement_seen_for_the_first_time_has_no_known_starting_point()
|
|
{
|
|
var before = Snapshot(T0, new DateTime(2026, 9, 20, 3, 0, 0));
|
|
var now = Snapshot(T0.AddMinutes(5), new DateTime(2026, 9, 20, 3, 5, 0),
|
|
Stat("0xC", 1_000_000, 9_000_000, new DateTime(2026, 9, 19, 8, 0, 0), new DateTime(2026, 9, 20, 3, 4, 0)));
|
|
|
|
Assert.Empty(QueryStatsDelta.Compute(before, now)!.Queries);
|
|
}
|
|
|
|
[Fact]
|
|
public void Recompiled_plan_starts_over_without_negative_numbers()
|
|
{
|
|
var oldPlan = new DateTime(2026, 9, 20, 1, 0, 0);
|
|
var before = Snapshot(T0, new DateTime(2026, 9, 20, 3, 0, 0), Stat("0xD", 500, 50_000, oldPlan, oldPlan.AddHours(1)));
|
|
|
|
// Evicted and compiled again at 03:03: smaller totals, newer creation time. Everything since 03:03 is new.
|
|
var recompiled = Snapshot(T0.AddMinutes(5), new DateTime(2026, 9, 20, 3, 5, 0),
|
|
Stat("0xD", 7, 700, new DateTime(2026, 9, 20, 3, 3, 0), new DateTime(2026, 9, 20, 3, 4, 0)));
|
|
var q = Assert.Single(QueryStatsDelta.Compute(before, recompiled)!.Queries);
|
|
Assert.Equal(7, q.Executions);
|
|
Assert.Equal(700, q.WorkerTimeUs);
|
|
|
|
// Counters went backwards but the oldest plan is from before: one of several plans was evicted. Skip, never negative.
|
|
var partlyEvicted = Snapshot(T0.AddMinutes(5), new DateTime(2026, 9, 20, 3, 5, 0),
|
|
Stat("0xD", 300, 30_000, oldPlan, new DateTime(2026, 9, 20, 3, 4, 0)));
|
|
Assert.Empty(QueryStatsDelta.Compute(before, partlyEvicted)!.Queries);
|
|
}
|
|
|
|
[Fact]
|
|
public void Restart_gives_no_delta_and_forgets_remembered_statements()
|
|
{
|
|
var before = Snapshot(T0, new DateTime(2026, 9, 20, 3, 0, 0), Stat("0xE", 1, 1, Start, Start));
|
|
var after = Snapshot(T0.AddMinutes(5), new DateTime(2026, 9, 20, 3, 5, 0)) with { SqlServerStartTime = Start.AddHours(3) };
|
|
|
|
Assert.Null(QueryStatsDelta.Compute(before, after));
|
|
Assert.Empty(QueryStatsDelta.Remember(before, [], after.SqlServerStartTime));
|
|
}
|
|
|
|
[Fact]
|
|
public void Statements_not_run_now_are_remembered_for_later_up_to_a_limit()
|
|
{
|
|
var created = new DateTime(2026, 9, 20, 1, 0, 0);
|
|
var previous = Snapshot(T0, new DateTime(2026, 9, 20, 3, 0, 0),
|
|
[.. Enumerable.Range(0, QueryStatsSnapshot.MaxRemembered + 10).Select(i => Stat("0x" + i, 1, 1, created, created.AddSeconds(i)))]);
|
|
var current = new[] { Stat("0xNEW", 1, 1, created, created.AddHours(2)) };
|
|
|
|
var kept = QueryStatsDelta.Remember(previous, current, Start);
|
|
|
|
Assert.Equal(QueryStatsSnapshot.MaxRemembered, kept.Count);
|
|
Assert.Equal("0xNEW", kept[0].Key);
|
|
Assert.DoesNotContain(kept, q => q.Key == "0x0"); // least recently run is forgotten first
|
|
}
|
|
|
|
[Fact]
|
|
public void Sql_server_2005_key_is_handle_and_offsets()
|
|
{
|
|
// The key is whatever the collector builds; on 2005 sql_handle:start:end, so the same batch's statements stay apart.
|
|
var created = new DateTime(2026, 9, 20, 1, 0, 0);
|
|
var first = "0x0200000012ab:0:120";
|
|
var second = "0x0200000012ab:122:-1";
|
|
var before = Snapshot(T0, new DateTime(2026, 9, 20, 3, 0, 0), Stat(first, 10, 100, created, created), Stat(second, 10, 100, created, created));
|
|
var now = Snapshot(T0.AddMinutes(5), new DateTime(2026, 9, 20, 3, 5, 0),
|
|
Stat(first, 12, 160, created, new DateTime(2026, 9, 20, 3, 1, 0)), Stat(second, 15, 400, created, new DateTime(2026, 9, 20, 3, 1, 0)));
|
|
|
|
var delta = QueryStatsDelta.Compute(before, now)!;
|
|
|
|
Assert.Equal(2, delta.Queries.Count);
|
|
Assert.Equal(second, delta.Queries[0].Key); // most CPU first
|
|
Assert.Equal(300, delta.Queries[0].WorkerTimeUs);
|
|
Assert.Equal(60, delta.Queries[1].WorkerTimeUs);
|
|
}
|
|
}
|